Files
paperclip-adapter-claude-k8s/dist/server/job-manifest.js
T
Pawla Abdul c8d883d409 Remove duplicate/internal fields from UI config schema
Fields like model, reasoning effort, instructions file path, max turns,
timeout, and grace period are either surfaced elsewhere in the platform
UI or are internal operational settings that shouldn't be user-facing
in the adapter config panel. These values remain functional when set
via the API/backend — only the UI exposure is removed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 17:24:31 +00:00

338 lines
15 KiB
JavaScript

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-claude-${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,
fsGroupChangePolicy: "OnRootMismatch",
};
// 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