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