c8d883d409
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>
338 lines
15 KiB
JavaScript
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
|