Initial commit: Paperclip adapter for Claude Code on Kubernetes
Adapter plugin that runs Claude Code agents as Kubernetes Jobs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vendored
+337
@@ -0,0 +1,337 @@
|
||||
import { asString, asNumber, asBoolean, asStringArray, parseObject, buildPaperclipEnv, renderTemplate, } from "@paperclipai/adapter-utils/server-utils";
|
||||
// Inline prompt assembly — these functions are not yet in the published adapter-utils
|
||||
function joinPromptSections(sections, separator = "\n\n") {
|
||||
return sections.filter((s) => s.trim().length > 0).join(separator);
|
||||
}
|
||||
function stringifyPaperclipWakePayload(wake) {
|
||||
if (!wake || typeof wake !== "object")
|
||||
return null;
|
||||
try {
|
||||
const json = JSON.stringify(wake);
|
||||
return json === "{}" ? null : json;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function renderPaperclipWakePrompt(wake, _opts) {
|
||||
if (!wake || typeof wake !== "object")
|
||||
return "";
|
||||
const w = wake;
|
||||
const reason = typeof w.reason === "string" ? w.reason.trim() : "";
|
||||
const comments = Array.isArray(w.comments) ? w.comments : [];
|
||||
if (!reason && comments.length === 0)
|
||||
return "";
|
||||
const parts = [];
|
||||
if (reason)
|
||||
parts.push(`Wake reason: ${reason}`);
|
||||
for (const c of comments) {
|
||||
if (typeof c === "object" && c !== null) {
|
||||
const comment = c;
|
||||
const body = typeof comment.body === "string" ? comment.body.trim() : "";
|
||||
if (body)
|
||||
parts.push(`Comment: ${body}`);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
function sanitizeForK8sName(value) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
|
||||
}
|
||||
function buildEnvVars(ctx, selfPod, config) {
|
||||
const { runId, agent, context } = ctx;
|
||||
const envConfig = parseObject(config.env);
|
||||
// Layer 1: PAPERCLIP_* base vars
|
||||
const paperclipEnv = buildPaperclipEnv(agent);
|
||||
// Layer 2: Context vars (run, wake, workspace — same as claude_local)
|
||||
paperclipEnv.PAPERCLIP_RUN_ID = runId;
|
||||
const setIfPresent = (envKey, value) => {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
paperclipEnv[envKey] = value.trim();
|
||||
}
|
||||
};
|
||||
setIfPresent("PAPERCLIP_TASK_ID", context.taskId ?? context.issueId);
|
||||
setIfPresent("PAPERCLIP_WAKE_REASON", context.wakeReason);
|
||||
setIfPresent("PAPERCLIP_WAKE_COMMENT_ID", context.wakeCommentId ?? context.commentId);
|
||||
setIfPresent("PAPERCLIP_APPROVAL_ID", context.approvalId);
|
||||
setIfPresent("PAPERCLIP_APPROVAL_STATUS", context.approvalStatus);
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
if (wakePayloadJson) {
|
||||
paperclipEnv.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_CWD", workspaceContext.cwd);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_SOURCE", workspaceContext.source);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_STRATEGY", workspaceContext.strategy);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_ID", workspaceContext.workspaceId);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_REPO_URL", workspaceContext.repoUrl);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_REPO_REF", workspaceContext.repoRef);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_BRANCH", workspaceContext.branchName);
|
||||
setIfPresent("PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspaceContext.worktreePath);
|
||||
setIfPresent("AGENT_HOME", workspaceContext.agentHome);
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((v) => typeof v === "string" && v.trim().length > 0)
|
||||
: [];
|
||||
if (linkedIssueIds.length > 0) {
|
||||
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (Array.isArray(context.paperclipWorkspaces) && context.paperclipWorkspaces.length > 0) {
|
||||
paperclipEnv.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(context.paperclipWorkspaces);
|
||||
}
|
||||
if (Array.isArray(context.paperclipRuntimeServiceIntents) && context.paperclipRuntimeServiceIntents.length > 0) {
|
||||
paperclipEnv.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(context.paperclipRuntimeServiceIntents);
|
||||
}
|
||||
if (Array.isArray(context.paperclipRuntimeServices) && context.paperclipRuntimeServices.length > 0) {
|
||||
paperclipEnv.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(context.paperclipRuntimeServices);
|
||||
}
|
||||
setIfPresent("PAPERCLIP_RUNTIME_PRIMARY_URL", context.paperclipRuntimePrimaryUrl);
|
||||
// Auth token for agent callback to Paperclip API
|
||||
if (ctx.authToken) {
|
||||
paperclipEnv.PAPERCLIP_API_KEY = ctx.authToken;
|
||||
}
|
||||
// PAPERCLIP_API_URL is inherited from the Deployment env via selfPod.inheritedEnv.
|
||||
// buildPaperclipEnv() sets a localhost value which is wrong for Job pods —
|
||||
// the inherited value (set in the infra repo) points to the in-cluster service.
|
||||
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
|
||||
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
|
||||
}
|
||||
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
|
||||
const merged = {
|
||||
...selfPod.inheritedEnv,
|
||||
...paperclipEnv,
|
||||
};
|
||||
// Layer 4: User-defined overrides from adapterConfig.env (wins over everything)
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string")
|
||||
merged[key] = value;
|
||||
}
|
||||
// HOME must be /paperclip to match PVC mount and enable session resume
|
||||
merged.HOME = "/paperclip";
|
||||
// Convert to V1EnvVar array
|
||||
const envVars = Object.entries(merged).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
return envVars;
|
||||
}
|
||||
export function buildJobManifest(input) {
|
||||
const { ctx, selfPod } = input;
|
||||
const { runId, agent, runtime, config: rawConfig, context } = ctx;
|
||||
const config = parseObject(rawConfig);
|
||||
// Resolve config values
|
||||
const namespace = asString(config.namespace, "") || selfPod.namespace;
|
||||
const image = asString(config.image, "") || selfPod.image;
|
||||
const model = asString(config.model, "");
|
||||
const effort = asString(config.effort, "");
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
// K8s Job pods are always unattended — no one to approve permission prompts
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const extraArgs = asStringArray(config.extraArgs);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
|
||||
const resources = parseObject(config.resources);
|
||||
const nodeSelector = parseObject(config.nodeSelector);
|
||||
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
|
||||
const extraLabels = parseObject(config.labels);
|
||||
// Resolve working directory — use workspace cwd, fall back to /paperclip
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
|
||||
const agentSlug = sanitizeForK8sName(agent.id);
|
||||
const runSlug = sanitizeForK8sName(runId);
|
||||
const jobName = `agent-${agentSlug}-${runSlug}`;
|
||||
// Build prompt (same logic as claude_local)
|
||||
const promptTemplate = asString(config.promptTemplate, "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.");
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedBootstrapPrompt = !runtimeSessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(runtimeSessionId) });
|
||||
const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0;
|
||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
wakePrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
// Build Claude CLI args
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (runtimeSessionId)
|
||||
claudeArgs.push("--resume", runtimeSessionId);
|
||||
if (dangerouslySkipPermissions)
|
||||
claudeArgs.push("--dangerously-skip-permissions");
|
||||
if (model)
|
||||
claudeArgs.push("--model", model);
|
||||
if (effort)
|
||||
claudeArgs.push("--effort", effort);
|
||||
if (maxTurns > 0)
|
||||
claudeArgs.push("--max-turns", String(maxTurns));
|
||||
if (instructionsFilePath)
|
||||
claudeArgs.push("--append-system-prompt-file", instructionsFilePath);
|
||||
if (extraArgs.length > 0)
|
||||
claudeArgs.push(...extraArgs);
|
||||
// Build env vars
|
||||
const envVars = buildEnvVars(ctx, selfPod, config);
|
||||
// Resource defaults
|
||||
const resourceRequests = parseObject(resources.requests);
|
||||
const resourceLimits = parseObject(resources.limits);
|
||||
const containerResources = {
|
||||
requests: {
|
||||
cpu: asString(resourceRequests.cpu, "1000m"),
|
||||
memory: asString(resourceRequests.memory, "2Gi"),
|
||||
},
|
||||
limits: {
|
||||
cpu: asString(resourceLimits.cpu, "4000m"),
|
||||
memory: asString(resourceLimits.memory, "8Gi"),
|
||||
},
|
||||
};
|
||||
// Labels
|
||||
const labels = {
|
||||
"app.kubernetes.io/managed-by": "paperclip",
|
||||
"app.kubernetes.io/component": "agent-job",
|
||||
"paperclip.io/agent-id": agent.id,
|
||||
"paperclip.io/run-id": runId,
|
||||
"paperclip.io/company-id": agent.companyId,
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
};
|
||||
for (const [key, value] of Object.entries(extraLabels)) {
|
||||
if (typeof value === "string")
|
||||
labels[key] = value;
|
||||
}
|
||||
// Volumes
|
||||
const volumes = [
|
||||
{
|
||||
name: "prompt",
|
||||
emptyDir: {},
|
||||
},
|
||||
];
|
||||
const volumeMounts = [
|
||||
{
|
||||
name: "prompt",
|
||||
mountPath: "/tmp/prompt",
|
||||
},
|
||||
];
|
||||
// Mount shared PVC for /paperclip (session state, workspaces, data)
|
||||
if (selfPod.pvcClaimName) {
|
||||
volumes.push({
|
||||
name: "data",
|
||||
persistentVolumeClaim: { claimName: selfPod.pvcClaimName },
|
||||
});
|
||||
volumeMounts.push({
|
||||
name: "data",
|
||||
mountPath: "/paperclip",
|
||||
});
|
||||
}
|
||||
// Mount secret volumes inherited from the Deployment pod
|
||||
for (const sv of selfPod.secretVolumes) {
|
||||
volumes.push({
|
||||
name: sv.volumeName,
|
||||
secret: { secretName: sv.secretName, defaultMode: sv.defaultMode, optional: true },
|
||||
});
|
||||
volumeMounts.push({
|
||||
name: sv.volumeName,
|
||||
mountPath: sv.mountPath,
|
||||
readOnly: true,
|
||||
});
|
||||
}
|
||||
// Security context matching the main Deployment
|
||||
const securityContext = {
|
||||
capabilities: { drop: ["ALL"] },
|
||||
readOnlyRootFilesystem: false,
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
allowPrivilegeEscalation: false,
|
||||
};
|
||||
const podSecurityContext = {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
};
|
||||
// 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 job = {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: {
|
||||
name: jobName,
|
||||
namespace,
|
||||
labels,
|
||||
annotations: {
|
||||
"paperclip.io/adapter-type": "claude_k8s",
|
||||
"paperclip.io/agent-name": agent.name,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
...(timeoutSec > 0 ? { activeDeadlineSeconds: timeoutSec } : {}),
|
||||
ttlSecondsAfterFinished: ttlSeconds,
|
||||
template: {
|
||||
metadata: { labels },
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
serviceAccountName: asString(config.serviceAccountName, "") || undefined,
|
||||
securityContext: podSecurityContext,
|
||||
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
|
||||
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
|
||||
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector } : {}),
|
||||
...(tolerations.length > 0 ? { tolerations: tolerations } : {}),
|
||||
initContainers: [
|
||||
{
|
||||
name: "write-prompt",
|
||||
image: "busybox:1.36",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
|
||||
env: [{ name: "PROMPT_CONTENT", value: prompt }],
|
||||
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
|
||||
securityContext,
|
||||
resources: {
|
||||
requests: { cpu: "10m", memory: "16Mi" },
|
||||
limits: { cpu: "100m", memory: "64Mi" },
|
||||
},
|
||||
},
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: "claude",
|
||||
image,
|
||||
imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"),
|
||||
workingDir,
|
||||
command: ["sh", "-c", mainCommand],
|
||||
env: envVars,
|
||||
volumeMounts,
|
||||
securityContext,
|
||||
resources: containerResources,
|
||||
},
|
||||
],
|
||||
volumes,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
|
||||
}
|
||||
//# sourceMappingURL=job-manifest.js.map
|
||||
Reference in New Issue
Block a user