mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat: add ACPx and AgentCore runner support
This commit is contained in:
1 parent
39c4fbe421
commit
b50eaea4bb
133 files changed
+56352
-488
No files matched your search
@@ -4,7 +4,16 @@ import type {
|
||||
ServerAdapterModule,
|
||||
} from "./types.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
AcpxRuntimeHost,
|
||||
inspectQualifiedAcpxInstallation,
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
|
||||
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
|
||||
import {
|
||||
@@ -188,6 +197,113 @@ async function testPaperclipRunnerOpenCodeEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaperclipRunnerAcpxEnvironment(
|
||||
context: Parameters<typeof codexTestEnvironment>[0],
|
||||
) {
|
||||
const testedAt = new Date().toISOString();
|
||||
const agent = context.config.acpxAgent;
|
||||
const model = typeof context.config.model === "string" ? context.config.model.trim() : "";
|
||||
if (context.config.permissionPolicy !== undefined && context.config.permissionPolicy !== "interactive") {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_permission_policy_invalid", level: "error" as const, message: "ACPX v1 requires permissionPolicy=interactive." }],
|
||||
};
|
||||
}
|
||||
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_agent_invalid", level: "error" as const, message: "ACPX agent must be Pi, Claude, or Codex." }],
|
||||
};
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
profile = resolveQualifiedAcpxProfile(agent, model);
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_model_unqualified", level: "error" as const, message: error instanceof Error ? error.message : "ACPX model is not qualified." }],
|
||||
};
|
||||
}
|
||||
const nodeParts = process.versions.node.split(".").map(Number);
|
||||
if ((nodeParts[0] ?? 0) < 22 || ((nodeParts[0] ?? 0) === 22 && (nodeParts[1] ?? 0) < 13)) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_node_too_old", level: "error" as const, message: `ACPX 0.13.1 requires Node 22.13 or newer; found ${process.versions.node}.` }],
|
||||
};
|
||||
}
|
||||
const configuredEnv = context.config.env && typeof context.config.env === "object"
|
||||
? context.config.env as Record<string, unknown>
|
||||
: {};
|
||||
const environment = { ...process.env };
|
||||
for (const [key, value] of Object.entries(configuredEnv)) if (typeof value === "string") environment[key] = value;
|
||||
const credentialReady = agent === "pi"
|
||||
? Boolean(environment.OPENROUTER_API_KEY)
|
||||
: agent === "claude"
|
||||
? Boolean(environment.ANTHROPIC_API_KEY || environment.CLAUDE_CODE_OAUTH_TOKEN)
|
||||
: Boolean(environment.OPENAI_API_KEY || environment.CODEX_API_KEY);
|
||||
if (!credentialReady) {
|
||||
const required = agent === "pi"
|
||||
? "OPENROUTER_API_KEY"
|
||||
: agent === "claude"
|
||||
? "ANTHROPIC_API_KEY or the managed Claude credential"
|
||||
: "the managed Codex credential or OPENAI_API_KEY";
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_auth_missing", level: "error" as const, message: `ACPX ${agent} requires ${required}.` }],
|
||||
};
|
||||
}
|
||||
let probeRoot: string | null = null;
|
||||
try {
|
||||
const installation = await inspectQualifiedAcpxInstallation(agent as QualifiedAcpxAgent, model);
|
||||
probeRoot = await mkdtemp(join(tmpdir(), "paperclip-acpx-probe-"));
|
||||
const host = await AcpxRuntimeHost.open({
|
||||
runtimeDirectory: probeRoot,
|
||||
normalizedSessionId: "environment-probe",
|
||||
workingDirectory: probeRoot,
|
||||
agent: agent as QualifiedAcpxAgent,
|
||||
model,
|
||||
environment,
|
||||
dynamicTools: [],
|
||||
dynamicToolHandler: async () => { throw new Error("environment probe exposes no semantic tools"); },
|
||||
});
|
||||
const identity = host.identity();
|
||||
await host.close({ reason: "environment probe complete", discardPersistentState: true });
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "pass" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "acpx_profile_qualified",
|
||||
level: "info" as const,
|
||||
message: `Verified ACPX ${profile.acpxVersion}, ${profile.agentServerPackage}@${profile.agentServerVersion}, exact model ${identity.effectiveModel}, semantic bridge startup, and clean shutdown (digest ${installation.commandDigest}).`,
|
||||
}, {
|
||||
code: "acpx_auth_ready",
|
||||
level: "info" as const,
|
||||
message: `Credential readiness was verified for ACPX ${agent}; no credential value was retained.`,
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "acpx_handshake_failed", level: "error" as const, message: error instanceof Error ? error.message : "ACPX handshake failed." }],
|
||||
};
|
||||
} finally {
|
||||
if (probeRoot) await rm(probeRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaperclipRunnerClaudeManagedEnvironment(
|
||||
context: Parameters<typeof codexTestEnvironment>[0],
|
||||
) {
|
||||
@@ -337,6 +453,68 @@ async function testPaperclipRunnerClaudeManagedEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaperclipRunnerAwsAgentCoreEnvironment(
|
||||
context: Parameters<typeof codexTestEnvironment>[0],
|
||||
) {
|
||||
const testedAt = new Date().toISOString();
|
||||
const required = [
|
||||
["agentCoreProfileId", "AgentCore profile ID"], ["awsRegion", "AWS region"],
|
||||
["harnessId", "Harness ID"], ["harnessVersion", "Harness version"],
|
||||
["endpointQualifier", "endpoint qualifier"], ["memoryId", "Memory ID"],
|
||||
["invocationRoleArn", "invocation role ARN"], ["model", "Bedrock model"],
|
||||
] as const;
|
||||
const missing = required.filter(([key]) => typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0);
|
||||
if (missing.length > 0 || context.config.agentCoreRetentionAcknowledged !== true) {
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "fail" as const, testedAt,
|
||||
checks: [{ code: "aws_agentcore_profile_incomplete", level: "error" as const,
|
||||
message: missing.length > 0
|
||||
? `AWS AgentCore profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
|
||||
: "AWS AgentCore requires an administrator acknowledgement of 90-day Memory retention." }],
|
||||
};
|
||||
}
|
||||
const cap = Number(context.config.maxEstimatedSessionCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
|
||||
checks: [{ code: "aws_agentcore_estimated_cap_invalid", level: "error" as const, message: "AWS AgentCore requires a positive estimated session ceiling." }] };
|
||||
}
|
||||
const sourceEnvironment = context.config.env && typeof context.config.env === "object"
|
||||
? { ...process.env, ...(context.config.env as Record<string, string>) }
|
||||
: process.env;
|
||||
const region = String(context.config.awsRegion);
|
||||
try {
|
||||
const version = await execFileAsync("aws", ["--version"], { timeout: 5_000, env: sourceEnvironment });
|
||||
if (!`${version.stdout}${version.stderr}`.includes("aws-cli/2.")) throw new Error("AWS CLI v2 is required");
|
||||
const assumed = await execFileAsync("aws", ["--region", region, "sts", "assume-role", "--role-arn", String(context.config.invocationRoleArn), "--role-session-name", "paperclip-environment-probe", "--duration-seconds", "900", "--output", "json"], { timeout: 15_000, env: sourceEnvironment });
|
||||
const credentials = (JSON.parse(assumed.stdout) as { Credentials?: { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string } }).Credentials;
|
||||
if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) throw new Error("STS omitted temporary credentials");
|
||||
const env = {
|
||||
...sourceEnvironment,
|
||||
AWS_ACCESS_KEY_ID: credentials.AccessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: credentials.SecretAccessKey,
|
||||
AWS_SESSION_TOKEN: credentials.SessionToken,
|
||||
AWS_REGION: region,
|
||||
AWS_DEFAULT_REGION: region,
|
||||
AWS_PROFILE: undefined,
|
||||
};
|
||||
await Promise.all([
|
||||
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness", "--harness-id", String(context.config.harnessId), "--harness-version", String(context.config.harnessVersion)], { timeout: 15_000, env }),
|
||||
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness-endpoint", "--harness-id", String(context.config.harnessId), "--endpoint-name", String(context.config.endpointQualifier)], { timeout: 15_000, env }),
|
||||
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-memory", "--memory-id", String(context.config.memoryId), "--view", "full"], { timeout: 15_000, env }),
|
||||
]);
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "warn" as const, testedAt,
|
||||
checks: [
|
||||
{ code: "aws_agentcore_profile_qualified", level: "info" as const, message: `Verified pinned Harness ${String(context.config.harnessId)} version ${String(context.config.harnessVersion)} and Memory ${String(context.config.memoryId)} without creating a paid session.` },
|
||||
{ code: "aws_agentcore_retention_notice", level: "warn" as const, message: "AgentCore Memory retains short-term events for 90 days; the session cost shown by Paperclip is an estimate, not an AWS currency hard stop." },
|
||||
],
|
||||
};
|
||||
} catch {
|
||||
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
|
||||
checks: [{ code: "aws_agentcore_probe_failed", level: "error" as const, message: "AWS AgentCore read-only qualification failed. Verify AWS CLI v2, the source profile, sts:AssumeRole, region, Harness endpoint, and Memory identifiers." }] };
|
||||
}
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number {
|
||||
const a = left.split(".").map(Number);
|
||||
const b = right.split(".").map(Number);
|
||||
@@ -553,18 +731,32 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
timedOut: false,
|
||||
errorMessage: message,
|
||||
errorCode: "paperclip_runner_coordinator_required",
|
||||
provider: "codex",
|
||||
provider: ctx.config.provider === "acpx"
|
||||
? "acpx"
|
||||
: ctx.config.provider === "opencode"
|
||||
? "opencode"
|
||||
: ctx.config.provider === "claude_managed"
|
||||
? "anthropic"
|
||||
: ctx.config.provider === "aws_agentcore"
|
||||
? "amazon-bedrock"
|
||||
: "codex",
|
||||
summary: message,
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const provider = context.config.provider === "opencode"
|
||||
? "opencode"
|
||||
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
|
||||
: context.config.provider === "claude_managed" ? "claude_managed"
|
||||
: context.config.provider === "aws_agentcore" ? "aws_agentcore"
|
||||
: context.config.provider === "acpx" ? "acpx" : "codex";
|
||||
const result = provider === "claude_managed"
|
||||
? await testPaperclipRunnerClaudeManagedEnvironment(context)
|
||||
: provider === "aws_agentcore"
|
||||
? await testPaperclipRunnerAwsAgentCoreEnvironment(context)
|
||||
: provider === "opencode"
|
||||
? await testPaperclipRunnerOpenCodeEnvironment(context)
|
||||
: provider === "acpx"
|
||||
? await testPaperclipRunnerAcpxEnvironment(context)
|
||||
: await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
},
|
||||
@@ -575,6 +767,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
...codexModels,
|
||||
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
|
||||
],
|
||||
modelProfiles: codexModelProfiles,
|
||||
listModels: listCodexModels,
|
||||
@@ -583,12 +776,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
|
||||
getRuntimeCommandSpec: (config) => config.provider === "claude_managed" || config.provider === "aws_agentcore" || config.provider === "acpx"
|
||||
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
|
||||
: config.provider === "opencode"
|
||||
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.148.0"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns native Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Pi/Claude/Codex ACP agent through ACPX 0.13.1 and authenticated PRP. Provider processes never receive a Paperclip credential or unrestricted server environment.\n`,
|
||||
getConfigSchema: () => ({
|
||||
fields: [{
|
||||
key: "provider",
|
||||
@@ -599,8 +792,29 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "opencode", label: "OpenCode 1.18.17" },
|
||||
{ value: "claude_managed", label: "Claude Agent" },
|
||||
{ value: "aws_agentcore", label: "AWS AgentCore" },
|
||||
{ value: "acpx", label: "ACPX" },
|
||||
],
|
||||
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
|
||||
hint: "Select a local harness, ACPX agent, Anthropic Claude Agent, or AWS AgentCore Harness.",
|
||||
}, {
|
||||
key: "acpxAgent",
|
||||
label: "ACP agent",
|
||||
type: "select",
|
||||
default: "pi",
|
||||
options: [
|
||||
{ value: "pi", label: "Pi via ACPX" },
|
||||
{ value: "claude", label: "Claude via ACPX" },
|
||||
{ value: "codex", label: "Codex via ACPX (control)" },
|
||||
],
|
||||
hint: "Qualified ACP server profile. Configuration is immutable after session creation.",
|
||||
meta: { provider: "acpx" },
|
||||
}, {
|
||||
key: "permissionPolicy",
|
||||
label: "Permission policy",
|
||||
type: "text",
|
||||
default: "interactive",
|
||||
hint: "ACPX v1 supports the interactive policy only.",
|
||||
meta: { provider: "acpx", readOnly: true },
|
||||
}, {
|
||||
key: "lifecycleMode",
|
||||
label: "Runner lifecycle",
|
||||
@@ -649,6 +863,51 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
}, {
|
||||
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
|
||||
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "agentCoreProfileId", label: "AgentCore profile", type: "text", required: true,
|
||||
hint: "Company-scoped provisioned profile identifier.", meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "awsRegion", label: "AWS region", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "awsAccountId", label: "AWS account ID", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "harnessArn", label: "Harness ARN", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "harnessId", label: "Harness ID", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "harnessVersion", label: "Pinned Harness version", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "endpointQualifier", label: "Harness endpoint", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "endpointArn", label: "Harness endpoint ARN", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "agentRuntimeArn", label: "Underlying Runtime ARN", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "memoryId", label: "AgentCore Memory ID", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "memoryArn", label: "AgentCore Memory ARN", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "invocationRoleArn", label: "Invocation role ARN", type: "text", required: true,
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "maxEstimatedSessionCostUsd", label: "Estimated session ceiling (USD)", type: "number", default: 1,
|
||||
hint: "Paperclip estimate; AWS has no per-session currency hard stop.", meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "qualificationRevision", label: "Qualification revision", type: "text", default: "aws-agentcore-harness-v1",
|
||||
meta: { provider: "aws_agentcore" },
|
||||
}, {
|
||||
key: "agentCoreRetentionAcknowledged", label: "Acknowledge 90-day Memory retention", type: "toggle", default: false,
|
||||
hint: "AgentCore short-term Memory retains events for 90 days.", meta: { provider: "aws_agentcore" },
|
||||
}],
|
||||
}),
|
||||
loginCapability: codexLoginCapability,
|
||||
|
||||
@@ -83,6 +83,7 @@ import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
|
||||
import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
@@ -524,6 +525,7 @@ export async function createApp(
|
||||
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(secretRoutes(db));
|
||||
api.use(managedAgentProfileRoutes(db));
|
||||
api.use(remoteAgentProfileRoutes(db));
|
||||
const trustedLocalStdioRuntimeHost =
|
||||
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
|
||||
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type DurablePrpControlPlane,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
queueRunnerPrpRuntimeRequestResolution,
|
||||
registerRunnerPrpAuthority,
|
||||
RunnerPrpRuntimeRequestResolutionError,
|
||||
runnerPrpWebSocketInternals,
|
||||
setupRunnerPrpWebSocketServer,
|
||||
} from "./runner-prp-ws.js";
|
||||
@@ -57,6 +59,42 @@ describe("runner PRP websocket route", () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("queues one idempotent, turn-bound runtime request resolution", async () => {
|
||||
const server = createServer();
|
||||
setupRunnerPrpWebSocketServer(server, { port: 3212 });
|
||||
const queueCommand = vi.fn(() => ({ commandId: "command-resolution-1" }));
|
||||
const runId = "00000000-0000-4000-8000-000000000780";
|
||||
const registration = await registerRunnerPrpAuthority({
|
||||
runId,
|
||||
authority: { queueCommand } as unknown as DurablePrpControlPlane,
|
||||
});
|
||||
const input = {
|
||||
runId,
|
||||
requestId: "request-1",
|
||||
turnId: "turn-1",
|
||||
resolution: { action: "accept" as const },
|
||||
};
|
||||
|
||||
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
|
||||
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
|
||||
expect(queueCommand).toHaveBeenCalledTimes(1);
|
||||
expect(queueCommand).toHaveBeenCalledWith("request.resolve", {
|
||||
requestId: "request-1",
|
||||
turnId: "turn-1",
|
||||
resolution: { action: "accept" },
|
||||
}, undefined, true);
|
||||
expect(() => queueRunnerPrpRuntimeRequestResolution({
|
||||
...input,
|
||||
resolution: { action: "decline" },
|
||||
})).toThrowError(RunnerPrpRuntimeRequestResolutionError);
|
||||
|
||||
await registration.release();
|
||||
expect(() => queueRunnerPrpRuntimeRequestResolution(input)).toThrowError(
|
||||
"runner_prp_authority_not_active",
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("carries an authorized runnerd tool call over the shared Paperclip route", async () => {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { IncomingMessage, Server } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { DurablePrpControlPlane } from "../vendor/paperclip-runner/index.js";
|
||||
import type {
|
||||
DurablePrpControlPlane,
|
||||
HarnessRuntimeRequestResolution,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
|
||||
@@ -8,6 +11,7 @@ const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
|
||||
type Registration = {
|
||||
authority: DurablePrpControlPlane;
|
||||
generation: symbol;
|
||||
runtimeRequestResolutions: Map<string, { fingerprint: string; commandId: string }>;
|
||||
};
|
||||
|
||||
const registrations = new Map<string, Registration>();
|
||||
@@ -56,7 +60,11 @@ export async function registerRunnerPrpAuthority(input: {
|
||||
if (loopbackOrigin === null) throw new Error("runner_prp_websocket_server_not_configured");
|
||||
if (registrations.has(input.runId)) throw new Error("runner_prp_authority_already_registered");
|
||||
const generation = Symbol(input.runId);
|
||||
registrations.set(input.runId, { authority: input.authority, generation });
|
||||
registrations.set(input.runId, {
|
||||
authority: input.authority,
|
||||
generation,
|
||||
runtimeRequestResolutions: new Map(),
|
||||
});
|
||||
return {
|
||||
connectUrl: `${loopbackOrigin}${CONNECT_PATH_PREFIX}${input.runId}`,
|
||||
release: async () => {
|
||||
@@ -65,6 +73,51 @@ export async function registerRunnerPrpAuthority(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export class RunnerPrpRuntimeRequestResolutionError extends Error {
|
||||
constructor(
|
||||
readonly code: "runner_prp_authority_not_active" | "runtime_request_resolution_conflict",
|
||||
) {
|
||||
super(code);
|
||||
this.name = "RunnerPrpRuntimeRequestResolutionError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues one turn-bound runtime response on the active durable PRP authority.
|
||||
* Replayed identical browser submissions return the original command instead
|
||||
* of answering the provider twice; a different answer for the same request
|
||||
* fails closed.
|
||||
*/
|
||||
export function queueRunnerPrpRuntimeRequestResolution(input: {
|
||||
runId: string;
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
resolution: HarnessRuntimeRequestResolution;
|
||||
}): { commandId: string } {
|
||||
const registration = registrations.get(input.runId);
|
||||
if (!registration) {
|
||||
throw new RunnerPrpRuntimeRequestResolutionError("runner_prp_authority_not_active");
|
||||
}
|
||||
const fingerprint = JSON.stringify({ turnId: input.turnId, resolution: input.resolution });
|
||||
const previous = registration.runtimeRequestResolutions.get(input.requestId);
|
||||
if (previous) {
|
||||
if (previous.fingerprint !== fingerprint) {
|
||||
throw new RunnerPrpRuntimeRequestResolutionError("runtime_request_resolution_conflict");
|
||||
}
|
||||
return { commandId: previous.commandId };
|
||||
}
|
||||
const command = registration.authority.queueCommand("request.resolve", {
|
||||
requestId: input.requestId,
|
||||
turnId: input.turnId,
|
||||
resolution: input.resolution,
|
||||
}, undefined, true);
|
||||
registration.runtimeRequestResolutions.set(input.requestId, {
|
||||
fingerprint,
|
||||
commandId: command.commandId,
|
||||
});
|
||||
return { commandId: command.commandId };
|
||||
}
|
||||
|
||||
export const runnerPrpWebSocketInternals = {
|
||||
connectPathPrefix: CONNECT_PATH_PREFIX,
|
||||
resetForTests(): void {
|
||||
|
||||
@@ -94,6 +94,16 @@ import {
|
||||
} from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import {
|
||||
HarnessRuntimeRequestResolutionError,
|
||||
parseHarnessRuntimeRequestResolution,
|
||||
type HarnessRuntimeRequestKind,
|
||||
type HarnessRuntimeRequestResolution,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
queueRunnerPrpRuntimeRequestResolution,
|
||||
RunnerPrpRuntimeRequestResolutionError,
|
||||
} from "../realtime/runner-prp-ws.js";
|
||||
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
|
||||
import {
|
||||
instanceSettingsService,
|
||||
@@ -5015,6 +5025,70 @@ export function agentRoutes(
|
||||
res.json(run);
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/runtime-requests/:requestId/resolve", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const runId = req.params.runId as string;
|
||||
const requestId = req.params.requestId as string;
|
||||
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
if (!existing) return;
|
||||
if (existing.runtimeMode !== "native" || existing.status !== "running") {
|
||||
throw conflict("This runner session is no longer accepting runtime responses.");
|
||||
}
|
||||
const turnId = typeof req.body?.turnId === "string" ? req.body.turnId.trim() : "";
|
||||
const rawRequestKind = typeof req.body?.requestKind === "string" ? req.body.requestKind : "";
|
||||
const runtimeRequestKinds: readonly HarnessRuntimeRequestKind[] = [
|
||||
"command_approval",
|
||||
"file_approval",
|
||||
"permission_approval",
|
||||
"user_input",
|
||||
"elicitation",
|
||||
];
|
||||
if (!requestId || requestId.length > 160 || !turnId || turnId.length > 160) {
|
||||
throw badRequest("Runtime request and turn identifiers are required.");
|
||||
}
|
||||
if (!runtimeRequestKinds.includes(rawRequestKind as HarnessRuntimeRequestKind)) {
|
||||
throw badRequest("Unsupported runtime request kind.");
|
||||
}
|
||||
let resolution: HarnessRuntimeRequestResolution;
|
||||
try {
|
||||
resolution = parseHarnessRuntimeRequestResolution(
|
||||
rawRequestKind as HarnessRuntimeRequestKind,
|
||||
req.body?.resolution,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessRuntimeRequestResolutionError) {
|
||||
throw badRequest("Invalid runtime request response.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const queued = queueRunnerPrpRuntimeRequestResolution({
|
||||
runId,
|
||||
requestId,
|
||||
turnId,
|
||||
resolution,
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: existing.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "heartbeat.runtime_request_resolution_queued",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: existing.id,
|
||||
details: { requestId, action: resolution.action },
|
||||
});
|
||||
res.status(202).json({ accepted: true, commandId: queued.commandId });
|
||||
} catch (error) {
|
||||
if (error instanceof RunnerPrpRuntimeRequestResolutionError) {
|
||||
throw conflict(error.code === "runtime_request_resolution_conflict"
|
||||
? "A different response was already submitted for this runtime request."
|
||||
: "The runner session is no longer accepting runtime responses.");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
|
||||
|
||||
@@ -40,3 +40,4 @@ export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
|
||||
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
|
||||
export { remoteAgentProfileRoutes } from "./remote-agent-profiles.js";
|
||||
@@ -4447,6 +4447,26 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/heartbeat-runs/{runId}/runtime-requests/{requestId}/resolve",
|
||||
tags: ["runs"],
|
||||
summary: "Resolve a pending Paperclip runner runtime request",
|
||||
request: {
|
||||
params: z.object({ runId: z.string(), requestId: z.string() }),
|
||||
body: jsonBody(z.object({
|
||||
turnId: z.string().min(1).max(160),
|
||||
requestKind: z.enum(["command_approval", "file_approval", "permission_approval", "user_input", "elicitation"]),
|
||||
resolution: z.union([
|
||||
z.object({ action: z.enum(["accept", "accept_for_session", "decline", "cancel"]) }),
|
||||
z.object({ action: z.literal("submit"), answers: z.record(z.string(), z.object({ answers: z.array(z.string()) })) }),
|
||||
z.object({ action: z.literal("submit"), content: z.record(z.string(), z.unknown()) }),
|
||||
]),
|
||||
})),
|
||||
},
|
||||
responses: { 202: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/heartbeat-runs/{runId}/watchdog-decisions",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { remoteAgentProfileService, type RemoteAgentProfileInput, type RemoteAgentService } from "../services/remote-agent-profiles.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
function profileInput(value: unknown): RemoteAgentProfileInput {
|
||||
if (!value || typeof value !== "object") throw unprocessable("Remote Agent profile body is required");
|
||||
const body = value as Record<string, unknown>;
|
||||
const service = body.service;
|
||||
if (service !== "anthropic_managed_agents" && service !== "aws_bedrock_agentcore_harness") throw unprocessable("Unsupported remote agent service");
|
||||
return {
|
||||
profileKey: String(body.profileKey ?? ""), displayName: String(body.displayName ?? ""),
|
||||
service: service as RemoteAgentService,
|
||||
configuration: body.configuration && typeof body.configuration === "object" && !Array.isArray(body.configuration) ? body.configuration as Record<string, unknown> : {},
|
||||
credentialSecretId: typeof body.credentialSecretId === "string" ? body.credentialSecretId : null,
|
||||
enabled: body.enabled === true, retentionAcknowledged: body.retentionAcknowledged === true,
|
||||
qualification: body.qualification && typeof body.qualification === "object" && !Array.isArray(body.qualification) ? body.qualification as Record<string, unknown> : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function remoteAgentProfileRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const profiles = remoteAgentProfileService(db);
|
||||
router.get("/companies/:companyId/remote-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const service = typeof req.query.service === "string" ? req.query.service as RemoteAgentService : undefined;
|
||||
res.json(await profiles.list(companyId, service));
|
||||
});
|
||||
router.post("/companies/:companyId/remote-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const profile = await profiles.upsert(companyId, profileInput(req.body));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId,
|
||||
action: "remote_agent_profile.qualified", entityType: "remote_agent_profile", entityId: profile.id,
|
||||
details: { profileKey: profile.profileKey, service: profile.service, enabled: profile.enabled, retentionAcknowledged: profile.retentionAcknowledged } });
|
||||
res.status(201).json(profile);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
// git-credentials module became its canonical home; existing importers keep working.
|
||||
export { scrubGitCredentialText };
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { documentService } from "./documents.js";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import {
|
||||
buildNativeProviderEnvironment,
|
||||
@@ -92,13 +93,15 @@ import {
|
||||
ensureNativeCompletionContract,
|
||||
executePaperclipNativeSession,
|
||||
finalizeNativeRun,
|
||||
isNativeSessionId,
|
||||
materializeNativeInteractionResponses,
|
||||
rebindNativeSessionCheckpoint,
|
||||
reconcileNativeFinalizations,
|
||||
resolveHeartbeatNativeRuntimeMode,
|
||||
} from "./native-runtime/index.js";
|
||||
import {
|
||||
parseNativeExecutionInput,
|
||||
type NativeExecutionInputV1,
|
||||
type NativeExecutionInput,
|
||||
type NativeSessionBackend,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
|
||||
@@ -305,6 +308,7 @@ import { environmentService } from "./environments.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { managedAgentProfileService } from "./managed-agent-profiles.js";
|
||||
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
|
||||
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
|
||||
@@ -6677,7 +6681,7 @@ export interface HeartbeatServiceOptions {
|
||||
* the seam here exercises the production reaper, claim, execution, package
|
||||
* session loop, persistence port, and finalizer without spawning a provider.
|
||||
*/
|
||||
nativeSessionBackendFactory?: (execution: NativeExecutionInputV1) => NativeSessionBackend;
|
||||
nativeSessionBackendFactory?: (execution: NativeExecutionInput) => NativeSessionBackend;
|
||||
}
|
||||
|
||||
type WorkspaceReadyCommentWriter = {
|
||||
@@ -13607,9 +13611,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (now.getTime() - refTime < staleThresholdMs) continue;
|
||||
}
|
||||
|
||||
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
|
||||
const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
|
||||
const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
|
||||
const nativeLocalChild = run.runtimeMode === "native";
|
||||
const tracksLocalChild = nativeLocalChild || isTrackedLocalChildProcessAdapter(adapterType);
|
||||
let processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
|
||||
let processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
|
||||
if (nativeLocalChild && (processPidAlive || processGroupAlive)) {
|
||||
// A native runner's reconnect URL is bound to the old run authority.
|
||||
// If startup resumption did not adopt it above, leaving it alive makes
|
||||
// the next run fight an orphan for the same provider thread writer.
|
||||
await terminateHeartbeatRunProcess({
|
||||
pid: run.processPid,
|
||||
processGroupId: run.processGroupId,
|
||||
});
|
||||
processPidAlive = false;
|
||||
processGroupAlive = false;
|
||||
}
|
||||
if (
|
||||
(processPidAlive || processGroupAlive) &&
|
||||
readHotRestartAdoptionMetadata(parseObject(run.resultJson))
|
||||
@@ -15876,7 +15892,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
target: executionTarget,
|
||||
workspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
});
|
||||
let nativeExecution: ReturnType<typeof buildNativeExecutionInput> | null = null;
|
||||
let nativeExecution: NativeExecutionInput | null = null;
|
||||
let nativeRunnerInstanceId: string | null = null;
|
||||
if (nativeRuntimeResolution.kind === "native") {
|
||||
if (!issueRef) {
|
||||
@@ -15902,8 +15918,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
actorId: agent.id,
|
||||
immediateRequest: safeWakeCommentContext?.body ?? null,
|
||||
});
|
||||
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
|
||||
const nativeSessionId = run.nativeSessionId ?? randomUUID();
|
||||
const taskNativeSessionId = readNonEmptyString(taskSessionDecodedParams?.sessionId);
|
||||
const resumableTaskSessionId =
|
||||
taskSessionForRun?.lastRunId
|
||||
&& taskSessionForRun.lastRunId !== run.id
|
||||
&& isNativeSessionId(taskNativeSessionId)
|
||||
? taskNativeSessionId
|
||||
: null;
|
||||
const previousNativeRun = resumableTaskSessionId && taskSessionForRun?.lastRunId
|
||||
? await db.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
runnerInstanceId: heartbeatRuns.runnerInstanceId,
|
||||
nativeSessionId: heartbeatRuns.nativeSessionId,
|
||||
runnerProfileJson: heartbeatRuns.runnerProfileJson,
|
||||
}).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, taskSessionForRun.lastRunId),
|
||||
eq(heartbeatRuns.companyId, agent.companyId),
|
||||
eq(heartbeatRuns.agentId, agent.id),
|
||||
eq(heartbeatRuns.nativeSessionId, resumableTaskSessionId),
|
||||
)).limit(1).then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
nativeRunnerInstanceId =
|
||||
previousNativeRun?.runnerInstanceId
|
||||
&& previousNativeRun.nativeSessionId === (run.nativeSessionId ?? resumableTaskSessionId)
|
||||
? previousNativeRun.runnerInstanceId
|
||||
: run.runnerInstanceId ?? randomUUID();
|
||||
let nativeSessionId = run.nativeSessionId ?? resumableTaskSessionId ?? randomUUID();
|
||||
let nativeResumeCheckpoint: ReturnType<typeof rebindNativeSessionCheckpoint> = null;
|
||||
const persistedProfile = persistedRunnerProfile;
|
||||
if (persistedNativeExecutionInput) {
|
||||
nativeExecution = persistedNativeExecutionInput;
|
||||
@@ -15932,6 +15975,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
|
||||
)
|
||||
: null;
|
||||
const agentCoreProfile = nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
|
||||
? await remoteAgentProfileService(db).requireQualified(
|
||||
agent.companyId,
|
||||
readNonEmptyString(parseObject(agent.adapterConfig).agentCoreProfileId) ?? "",
|
||||
"aws_bedrock_agentcore_harness",
|
||||
)
|
||||
: null;
|
||||
const agentCoreConfig = parseObject(agentCoreProfile?.configuration);
|
||||
if (managedProfile) {
|
||||
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
|
||||
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
|
||||
@@ -15952,6 +16003,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
);
|
||||
}
|
||||
}
|
||||
const executionMode = issueRef.workMode === "planning" && !acceptedPlanContinuationWake
|
||||
? "plan" as const
|
||||
: "default" as const;
|
||||
const pinnedPlan = executionMode === "plan"
|
||||
? await documentService(db).getIssueDocumentByKey(issueRef.id, "plan")
|
||||
: null;
|
||||
const pinnedReviewContext = executionMode === "plan"
|
||||
? await buildPlanReviewContext({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
issueWorkMode: issueRef.workMode,
|
||||
interactionId: readNonEmptyString(context.interactionId),
|
||||
})
|
||||
: null;
|
||||
const pinnedPlanMarkdown = pinnedPlan?.body ?? "";
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
@@ -15970,14 +16037,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
executionMode,
|
||||
planningContext: executionMode === "plan" ? {
|
||||
documentId: pinnedPlan?.id ?? null,
|
||||
baseRevisionId: pinnedPlan?.latestRevisionId ?? null,
|
||||
baseRevisionNumber: pinnedPlan?.latestRevisionNumber ?? 0,
|
||||
markdown: pinnedPlanMarkdown,
|
||||
sha256: createHash("sha256").update(pinnedPlanMarkdown).digest("hex"),
|
||||
reviewContext: pinnedReviewContext
|
||||
? structuredClone(pinnedReviewContext) as unknown as Record<string, unknown>
|
||||
: {},
|
||||
} : null,
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? "opencode"
|
||||
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
|
||||
? "claude_managed"
|
||||
: nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
|
||||
? "aws_agentcore"
|
||||
: nativeRuntimeResolution.profile.backend === "acpx_runtime"
|
||||
? "acpx"
|
||||
: "codex",
|
||||
model: typeof parseObject(runtimeConfig).model === "string"
|
||||
? String(parseObject(runtimeConfig).model)
|
||||
: managedProfile?.defaultModel ?? null,
|
||||
...(nativeRuntimeResolution.profile.backend === "acpx_runtime" ? {
|
||||
acpxAgent: parseObject(runtimeConfig).acpxAgent as "pi" | "claude" | "codex",
|
||||
} : {}),
|
||||
model: typeof parseObject(agent.adapterConfig).model === "string"
|
||||
? String(parseObject(agent.adapterConfig).model)
|
||||
: managedProfile?.defaultModel ?? readNonEmptyString(agentCoreConfig.defaultModel) ?? null,
|
||||
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
|
||||
managedProfile: {
|
||||
profileId: managedProfile!.id,
|
||||
@@ -15991,7 +16076,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
?? managedProfile!.defaultMaxListCostCents / 100,
|
||||
),
|
||||
} : {}),
|
||||
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
|
||||
...(nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api" ? {
|
||||
agentCoreProfile: {
|
||||
profileId: agentCoreProfile!.id,
|
||||
region: readNonEmptyString(agentCoreConfig.region) ?? "",
|
||||
accountId: readNonEmptyString(agentCoreConfig.accountId) ?? "",
|
||||
harnessArn: readNonEmptyString(agentCoreConfig.harnessArn) ?? "",
|
||||
harnessVersion: readNonEmptyString(agentCoreConfig.harnessVersion) ?? "",
|
||||
endpointArn: readNonEmptyString(agentCoreConfig.endpointArn) ?? "",
|
||||
endpointQualifier: readNonEmptyString(agentCoreConfig.endpointQualifier) ?? "",
|
||||
agentRuntimeArn: readNonEmptyString(agentCoreConfig.agentRuntimeArn) ?? "",
|
||||
memoryArn: readNonEmptyString(agentCoreConfig.memoryArn) ?? "",
|
||||
memoryId: readNonEmptyString(agentCoreConfig.memoryId) ?? "",
|
||||
invocationRoleArn: readNonEmptyString(agentCoreConfig.invocationRoleArn) ?? "",
|
||||
qualificationRevision: readNonEmptyString(agentCoreConfig.qualificationRevision) ?? "aws-agentcore-harness-v1",
|
||||
eventExpiryDays: 90 as const,
|
||||
},
|
||||
maxEstimatedSessionCostUsd: Number(parseObject(agent.adapterConfig).maxEstimatedSessionCostUsd ?? agentCoreConfig.defaultMaxEstimatedSessionCostUsd ?? 1),
|
||||
invocationLimits: {
|
||||
maxIterations: Math.min(8, Number(parseObject(agent.adapterConfig).maxIterations ?? 8)),
|
||||
maxOutputTokens: Math.min(4096, Number(parseObject(agent.adapterConfig).maxOutputTokens ?? 4096)),
|
||||
timeoutSeconds: Math.min(300, Number(parseObject(agent.adapterConfig).timeoutSeconds ?? 300)),
|
||||
},
|
||||
} : {}),
|
||||
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
|
||||
? {
|
||||
mode: "warm",
|
||||
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
|
||||
@@ -16008,6 +16116,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
contract: completionContract.contract,
|
||||
},
|
||||
});
|
||||
if (previousNativeRun && nativeSessionId === resumableTaskSessionId) {
|
||||
nativeResumeCheckpoint = rebindNativeSessionCheckpoint({
|
||||
previousRun: previousNativeRun,
|
||||
currentExecution: nativeExecution,
|
||||
});
|
||||
if (!nativeResumeCheckpoint) {
|
||||
nativeSessionId = randomUUID();
|
||||
nativeExecution = parseNativeExecutionInput({
|
||||
...nativeExecution,
|
||||
session: {
|
||||
...nativeExecution.session,
|
||||
normalizedSessionId: nativeSessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const lockedRun = await tx.select().from(heartbeatRuns)
|
||||
@@ -16017,6 +16141,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (lockedRun.runtimeModeResolvedAt && lockedRun.runtimeMode !== "native") {
|
||||
throw new Error("native_runtime_mode_conflict");
|
||||
}
|
||||
const lockedProfile = parseObject(lockedRun.runnerProfileJson);
|
||||
await tx.update(heartbeatRuns).set({
|
||||
runtimeMode: "native",
|
||||
runtimeModeResolverVersion: lockedRun.runtimeModeResolverVersion ?? nativeRuntimeResolution.resolverVersion,
|
||||
@@ -16024,10 +16149,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
runtimeModeResolvedAt: lockedRun.runtimeModeResolvedAt ?? new Date(),
|
||||
runnerProfileJson: {
|
||||
...nativeRuntimeResolution.profile,
|
||||
...parseObject(lockedRun.runnerProfileJson),
|
||||
nativeExecutionInput: parseObject(lockedRun.runnerProfileJson).nativeExecutionInput ?? nativeExecution,
|
||||
...lockedProfile,
|
||||
nativeExecutionInput: lockedProfile.nativeExecutionInput ?? nativeExecution,
|
||||
...(lockedProfile.sessionCheckpoint !== undefined
|
||||
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
|
||||
: nativeResumeCheckpoint
|
||||
? { sessionCheckpoint: nativeResumeCheckpoint as unknown as Record<string, unknown> }
|
||||
: {}),
|
||||
},
|
||||
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
|
||||
runnerInstanceId:
|
||||
previousNativeRun?.runnerInstanceId
|
||||
&& lockedRun.nativeSessionId !== null
|
||||
&& lockedRun.nativeSessionId === previousNativeRun.nativeSessionId
|
||||
? previousNativeRun.runnerInstanceId
|
||||
: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
|
||||
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
|
||||
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
|
||||
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
|
||||
|
||||
@@ -197,3 +197,8 @@ export {
|
||||
CLAUDE_MANAGED_BETA_VERSION,
|
||||
type ManagedAgentProfileInput,
|
||||
} from "./managed-agent-profiles.js";
|
||||
export {
|
||||
remoteAgentProfileService,
|
||||
type RemoteAgentProfileInput,
|
||||
type RemoteAgentService,
|
||||
} from "./remote-agent-profiles.js";
|
||||
@@ -2,6 +2,7 @@ export * from "./runtime-mode.js";
|
||||
export * from "./completion-contracts.js";
|
||||
export * from "./native-execution-input.js";
|
||||
export * from "./native-session-executor.js";
|
||||
export * from "./native-session-resume.js";
|
||||
export * from "./native-interaction-bridge.js";
|
||||
export * from "./paperclip-control-plane-port.js";
|
||||
export * from "./native-run-finalizer.js";
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type {
|
||||
NativeExecutionInputV1,
|
||||
NativeAcpxAgent,
|
||||
NativeExecutionInputV2,
|
||||
NativeInteractionResponseEnvelope,
|
||||
NativePlanningContext,
|
||||
StrictCompletionContractInput,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
parseNativeExecutionInput,
|
||||
resolveQualifiedAcpxProfile,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
/** Closed constructor: callers cannot spread legacy context or environment data. */
|
||||
export function buildNativeExecutionInput(input: {
|
||||
@@ -26,7 +31,8 @@ export function buildNativeExecutionInput(input: {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
|
||||
acpxAgent?: NativeAcpxAgent;
|
||||
model?: string | null;
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
@@ -36,7 +42,26 @@ export function buildNativeExecutionInput(input: {
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
};
|
||||
maxSessionListCostUsd?: number;
|
||||
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
|
||||
agentCoreProfile?: {
|
||||
profileId: string;
|
||||
region: string;
|
||||
accountId: string;
|
||||
harnessArn: string;
|
||||
harnessVersion: string;
|
||||
endpointArn: string;
|
||||
endpointQualifier: string;
|
||||
agentRuntimeArn: string;
|
||||
memoryArn: string;
|
||||
memoryId: string;
|
||||
invocationRoleArn: string;
|
||||
qualificationRevision: string;
|
||||
eventExpiryDays: 90;
|
||||
};
|
||||
maxEstimatedSessionCostUsd?: number;
|
||||
invocationLimits?: { maxIterations: number; maxOutputTokens: number; timeoutSeconds: number };
|
||||
lifecyclePolicy?: NativeExecutionInputV2["session"]["lifecyclePolicy"];
|
||||
executionMode?: "default" | "plan";
|
||||
planningContext?: NativePlanningContext | null;
|
||||
interactionResponses?: NativeInteractionResponseEnvelope[];
|
||||
completionContract: {
|
||||
id: string;
|
||||
@@ -44,12 +69,22 @@ export function buildNativeExecutionInput(input: {
|
||||
schemaVersion: string;
|
||||
contract: StrictCompletionContractInput;
|
||||
};
|
||||
}): NativeExecutionInputV1 {
|
||||
if (input.issue.workMode !== "standard") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard");
|
||||
}): NativeExecutionInputV2 {
|
||||
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard or planning");
|
||||
}
|
||||
const executionMode = input.executionMode
|
||||
?? (input.issue.workMode === "planning" ? "plan" : "default");
|
||||
const acpxProfile = input.provider === "acpx"
|
||||
? resolveQualifiedAcpxProfile(
|
||||
input.acpxAgent ?? "pi",
|
||||
input.model ?? "",
|
||||
)
|
||||
: null;
|
||||
return parseNativeExecutionInput({
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
schema: "paperclip.native-execution-input.v2",
|
||||
executionMode,
|
||||
planningContext: input.planningContext ?? null,
|
||||
binding: {
|
||||
companyId: input.companyId,
|
||||
runId: input.runId,
|
||||
@@ -62,7 +97,7 @@ export function buildNativeExecutionInput(input: {
|
||||
title: input.issue.title,
|
||||
description: input.issue.description,
|
||||
prompt: input.taskPrompt,
|
||||
workMode: "standard",
|
||||
workMode: input.issue.workMode,
|
||||
},
|
||||
workspace: {
|
||||
cwd: input.workspace.cwd,
|
||||
@@ -76,21 +111,54 @@ export function buildNativeExecutionInput(input: {
|
||||
? "opencode_server"
|
||||
: input.provider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: input.provider === "aws_agentcore"
|
||||
? "aws_agentcore_harness_api"
|
||||
: input.provider === "acpx"
|
||||
? "acpx_runtime"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: input.provider === "claude_managed" ? {
|
||||
kind: "claude_managed",
|
||||
model: input.model,
|
||||
managedProfile: input.managedProfile,
|
||||
maxSessionListCostUsd: input.maxSessionListCostUsd,
|
||||
} : {
|
||||
kind: input.provider ?? "codex",
|
||||
model: input.model ?? null,
|
||||
},
|
||||
provider: input.provider === "claude_managed"
|
||||
? {
|
||||
kind: "claude_managed",
|
||||
model: input.model,
|
||||
managedProfile: input.managedProfile,
|
||||
maxSessionListCostUsd: input.maxSessionListCostUsd,
|
||||
}
|
||||
: input.provider === "aws_agentcore"
|
||||
? {
|
||||
kind: "aws_agentcore",
|
||||
model: input.model,
|
||||
agentCoreProfile: input.agentCoreProfile,
|
||||
maxEstimatedSessionCostUsd: input.maxEstimatedSessionCostUsd,
|
||||
invocationLimits: input.invocationLimits,
|
||||
}
|
||||
: input.provider === "acpx"
|
||||
? {
|
||||
kind: "acpx",
|
||||
agent: acpxProfile!.agent,
|
||||
model: input.model,
|
||||
permissionPolicy: "interactive",
|
||||
profile: {
|
||||
driverKind: acpxProfile!.driverKind,
|
||||
protocolVersion: acpxProfile!.protocolVersion,
|
||||
acpxVersion: acpxProfile!.acpxVersion,
|
||||
agent: acpxProfile!.agent,
|
||||
agentProfileVersion: acpxProfile!.agentProfileVersion,
|
||||
agentServerPackage: acpxProfile!.agentServerPackage,
|
||||
agentServerVersion: acpxProfile!.agentServerVersion,
|
||||
agentRuntimePackage: acpxProfile!.agentRuntimePackage,
|
||||
agentRuntimeVersion: acpxProfile!.agentRuntimeVersion,
|
||||
commandDigest: acpxProfile!.commandDigest,
|
||||
},
|
||||
}
|
||||
: {
|
||||
kind: input.provider ?? "codex",
|
||||
model: input.model ?? null,
|
||||
},
|
||||
completionContract: input.completionContract,
|
||||
interactionResponses: input.interactionResponses ?? [],
|
||||
credentialBindings: [],
|
||||
});
|
||||
}) as NativeExecutionInputV2;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
nativeSessionFailureDisposition,
|
||||
nativeSessionFailureSourceCode,
|
||||
providerPlanMarkdown,
|
||||
semanticProviderPlanMarkdown,
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
describe("native provider bootstrap environment", () => {
|
||||
@@ -77,6 +78,23 @@ const execution = {
|
||||
} as NativeExecutionInputV1;
|
||||
|
||||
describe("provider plan synchronization", () => {
|
||||
it("prefers the provider's completed Markdown when it is available", () => {
|
||||
expect(providerPlanMarkdown({
|
||||
markdown: "# Release plan\n\n1. Prepare\n2. Deploy",
|
||||
explanation: "This fallback must not replace the completed plan.",
|
||||
steps: [{ body: "Fallback", status: "pending" }],
|
||||
})).toBe("# Release plan\n\n1. Prepare\n2. Deploy");
|
||||
});
|
||||
|
||||
it("extracts a completed plan from the semantic result artifact", () => {
|
||||
expect(semanticProviderPlanMarkdown({
|
||||
artifacts: [{
|
||||
kind: "native_provider_plan",
|
||||
ref: "<proposed_plan>\n# Health check\n\n1. Add endpoint\n2. Verify it\n</proposed_plan>",
|
||||
}],
|
||||
})).toBe("# Health check\n\n1. Add endpoint\n2. Verify it");
|
||||
});
|
||||
|
||||
it("renders a bounded Markdown checklist without embedding provenance", () => {
|
||||
const markdown = providerPlanMarkdown({
|
||||
explanation: "Release safely",
|
||||
@@ -257,6 +275,9 @@ describe("native session bounded recovery", () => {
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"provider_transport_failed: invalid JSON-RPC",
|
||||
))).toBe("provider_transport_failed");
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"planning_mode_unsupported: installed Codex app-server did not confirm plan mode",
|
||||
))).toBe("planning_mode_unsupported");
|
||||
});
|
||||
|
||||
it("retries the same run twice and stops at the third failed attempt", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
|
||||
import type { AdapterExecutionResult } from "../../adapters/index.js";
|
||||
import type { NativeFinalizationResult } from "@paperclipai/shared";
|
||||
import type {
|
||||
NativeExecutionInputV1,
|
||||
NativeExecutionInput,
|
||||
NativeSession,
|
||||
NativeSessionBackend,
|
||||
PersistedNativeSession,
|
||||
@@ -26,6 +26,8 @@ import { persistActivity, publishActivity } from "../activity-log.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { documentService } from "../documents.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import {
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
@@ -90,12 +92,17 @@ type PlanSynchronization = {
|
||||
eventId: string;
|
||||
planId: string;
|
||||
providerRevision: number;
|
||||
status: "synchronized" | "already_synchronized" | "conflict" | "invalid";
|
||||
status: "synchronized" | "already_synchronized" | "conflict" | "invalid" | "approval_failed";
|
||||
baseRevisionId: string | null;
|
||||
digest: string;
|
||||
documentRevision: number | null;
|
||||
currentRevisionId: string | null;
|
||||
confirmationId: string | null;
|
||||
};
|
||||
|
||||
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
|
||||
const completedMarkdown = typeof payload.markdown === "string" ? payload.markdown.trim() : "";
|
||||
if (completedMarkdown) return completedMarkdown.slice(0, 256_000);
|
||||
const explanation = typeof payload.explanation === "string" ? payload.explanation.trim() : "";
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps : [];
|
||||
const lines = steps.slice(0, 256).flatMap((value) => {
|
||||
@@ -110,46 +117,126 @@ export function providerPlanMarkdown(payload: Record<string, unknown>): string {
|
||||
return [explanation, lines.join("\n")].filter(Boolean).join("\n\n").slice(0, 256_000);
|
||||
}
|
||||
|
||||
async function synchronizeCompletedProviderPlan(input: {
|
||||
export function semanticProviderPlanMarkdown(result: Record<string, unknown>): string | null {
|
||||
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
|
||||
for (const value of artifacts) {
|
||||
const artifact = record(value);
|
||||
if (artifact.kind !== "native_provider_plan" || typeof artifact.ref !== "string") continue;
|
||||
const match = artifact.ref.match(/<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i);
|
||||
const markdown = (match?.[1] ?? artifact.ref).trim();
|
||||
if (markdown) return markdown.slice(0, 256_000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function synchronizeCompletedProviderPlan(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
execution: NativeExecutionInput;
|
||||
event: { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> };
|
||||
}): Promise<PlanSynchronization | null> {
|
||||
if (input.event.eventType !== "plan.updated" || input.event.payload.complete !== true) return null;
|
||||
if (!("executionMode" in input.execution) || input.execution.executionMode !== "plan") return null;
|
||||
const planningContext = input.execution.planningContext;
|
||||
if (!planningContext) return null;
|
||||
const planId = typeof input.event.payload.planId === "string" ? input.event.payload.planId : "";
|
||||
const providerRevision = Number.isSafeInteger(input.event.payload.revision) ? Number(input.event.payload.revision) : 0;
|
||||
const body = providerPlanMarkdown(input.event.payload);
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
if (!planId || providerRevision < 1 || !body) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", documentRevision: null, currentRevisionId: null };
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: null, currentRevisionId: null, confirmationId: null };
|
||||
}
|
||||
const provenance = `runner-plan-sync:v1 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision}`;
|
||||
const provenance = `runner-plan-sync:v2 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision} digest=${digest}`;
|
||||
const existingRevision = await input.db.select({ revisionNumber: documentRevisions.revisionNumber, id: documentRevisions.id })
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(issueDocuments.documentId, documentRevisions.documentId))
|
||||
.where(and(eq(issueDocuments.issueId, input.execution.binding.issueId), eq(issueDocuments.key, "plan"), eq(documentRevisions.changeSummary, provenance)))
|
||||
.orderBy(desc(documentRevisions.revisionNumber)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (existingRevision) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "already_synchronized", documentRevision: existingRevision.revisionNumber, currentRevisionId: existingRevision.id };
|
||||
}
|
||||
const documents = documentService(input.db);
|
||||
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
let revision = existingRevision;
|
||||
let status: PlanSynchronization["status"] = existingRevision ? "already_synchronized" : "synchronized";
|
||||
if (!revision) {
|
||||
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
if (latest?.latestRevisionId && latest.body === body) {
|
||||
const sameRunRevision = await input.db.select({
|
||||
id: documentRevisions.id,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
createdByRunId: documentRevisions.createdByRunId,
|
||||
}).from(documentRevisions)
|
||||
.where(eq(documentRevisions.id, latest.latestRevisionId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (sameRunRevision?.createdByRunId === input.execution.binding.runId) {
|
||||
revision = sameRunRevision;
|
||||
status = "already_synchronized";
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const write = await documents.upsertIssueDocument({
|
||||
issueId: input.execution.binding.issueId,
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: current?.latestRevisionId ?? null,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
});
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "synchronized", documentRevision: write.document.latestRevisionNumber, currentRevisionId: write.document.latestRevisionId };
|
||||
if (!revision) {
|
||||
const write = await documents.upsertIssueDocument({
|
||||
issueId: input.execution.binding.issueId,
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
});
|
||||
revision = { revisionNumber: write.document.latestRevisionNumber, id: write.document.latestRevisionId! };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
||||
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null };
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null, confirmationId: null };
|
||||
}
|
||||
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
if (!current || !revision?.id || current.latestRevisionId !== revision.id) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: current?.latestRevisionNumber ?? null, currentRevisionId: current?.latestRevisionId ?? null, confirmationId: null };
|
||||
}
|
||||
try {
|
||||
const confirmation = await issueThreadInteractionService(input.db).create(
|
||||
{ id: input.execution.binding.issueId, companyId: input.execution.binding.companyId },
|
||||
{
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `runner-plan-approval:v1:${input.execution.binding.runId}:${planId}:${providerRevision}:${digest}`,
|
||||
sourceRunId: input.execution.binding.runId,
|
||||
title: `Review plan revision ${revision.revisionNumber}`,
|
||||
summary: "Review the synchronized Paperclip plan.",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: `Approve plan revision ${revision.revisionNumber}?`,
|
||||
detailsMarkdown: "The completed provider plan has been synchronized to the canonical Plan document.",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: true,
|
||||
supersedeOnUserComment: false,
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: input.execution.binding.issueId,
|
||||
documentId: current.id,
|
||||
key: "plan",
|
||||
revisionId: revision.id,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
label: `Plan v${revision.revisionNumber}`,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{ agentId: input.execution.binding.agentId, runId: input.execution.binding.runId },
|
||||
);
|
||||
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1).then((rows) => rows[0] ?? null);
|
||||
if (currentIssue && currentIssue.status !== "review") {
|
||||
await issueService(input.db).update(input.execution.binding.issueId, {
|
||||
status: "review",
|
||||
actorAgentId: input.execution.binding.agentId,
|
||||
});
|
||||
}
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: confirmation.id };
|
||||
} catch {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,12 +262,41 @@ class SessionToolAuthorityRouter {
|
||||
|
||||
const sessionToolRouters = new Map<string, SessionToolAuthorityRouter>();
|
||||
|
||||
function nativeSessionKey(execution: NativeExecutionInputV1): string {
|
||||
function nativeSessionKey(execution: NativeExecutionInput): string {
|
||||
return execution.session.normalizedSessionId ?? `session-${execution.binding.runId}`;
|
||||
}
|
||||
|
||||
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
|
||||
const executionLocation = execution.provider.kind === "claude_managed"
|
||||
function runnerdStateRoot(execution: NativeExecutionInput): string {
|
||||
return resolve(
|
||||
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
|
||||
createHash("sha256").update(nativeSessionKey(execution)).digest("hex"),
|
||||
);
|
||||
}
|
||||
|
||||
function loadRunnerdDurableBinding(execution: NativeExecutionInput): {
|
||||
runnerInstanceId: string;
|
||||
environmentLeaseId: string;
|
||||
} | null {
|
||||
const statePath = resolve(runnerdStateRoot(execution), "control-plane", "mock-core-state.json");
|
||||
if (!existsSync(statePath)) return null;
|
||||
try {
|
||||
const identity = record(record(JSON.parse(readFileSync(statePath, "utf8"))).identity);
|
||||
if (
|
||||
identity.normalizedSessionId !== nativeSessionKey(execution)
|
||||
|| typeof identity.runnerInstanceId !== "string"
|
||||
|| typeof identity.environmentLeaseId !== "string"
|
||||
) return null;
|
||||
return {
|
||||
runnerInstanceId: identity.runnerInstanceId,
|
||||
environmentLeaseId: identity.environmentLeaseId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function nativeSessionConfigDigest(execution: NativeExecutionInput): string {
|
||||
const executionLocation = execution.provider.kind === "claude_managed" || execution.provider.kind === "aws_agentcore"
|
||||
? { executionKind: "remote_service", workspace: null }
|
||||
: {
|
||||
executionKind: "local_process",
|
||||
@@ -194,6 +310,7 @@ function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
|
||||
provider: execution.provider,
|
||||
driverKind: execution.session.driverKind,
|
||||
lifecyclePolicy: execution.session.lifecyclePolicy,
|
||||
executionMode: "executionMode" in execution ? execution.executionMode : "default",
|
||||
})).digest("hex")}`;
|
||||
}
|
||||
|
||||
@@ -222,7 +339,7 @@ function persistWarmNativeCheckpoint(
|
||||
}
|
||||
|
||||
function loadWarmNativeCheckpoint(
|
||||
execution: NativeExecutionInputV1,
|
||||
execution: NativeExecutionInput,
|
||||
configDigest: string,
|
||||
): PersistedNativeSession | null {
|
||||
const path = nativeSessionCheckpointPath(nativeSessionKey(execution));
|
||||
@@ -289,6 +406,7 @@ export function nativeSessionFailureSourceCode(error: unknown):
|
||||
| "provider_frame_too_large"
|
||||
| "provider_transport_failed"
|
||||
| "native_runner_process_exited"
|
||||
| "planning_mode_unsupported"
|
||||
| "native_session_interrupted" {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/provider_frame_too_large|stdout frame exceeded/i.test(message)) {
|
||||
@@ -300,6 +418,9 @@ export function nativeSessionFailureSourceCode(error: unknown):
|
||||
if (/native_runner_process_exited|runnerd exited|runner process failed/i.test(message)) {
|
||||
return "native_runner_process_exited";
|
||||
}
|
||||
if (/planning_mode_unsupported/i.test(message)) {
|
||||
return "planning_mode_unsupported";
|
||||
}
|
||||
return "native_session_interrupted";
|
||||
}
|
||||
|
||||
@@ -495,7 +616,7 @@ export function resolveNativeCancellationStatus(input: {
|
||||
|
||||
export async function executePaperclipNativeSession(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
execution: NativeExecutionInput;
|
||||
runnerInstanceId: string;
|
||||
leaseOwner?: string;
|
||||
onSpawn?: (meta: {
|
||||
@@ -510,7 +631,19 @@ export async function executePaperclipNativeSession(input: {
|
||||
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
|
||||
const durableRunnerBinding = input.useRunnerd ? loadRunnerdDurableBinding(input.execution) : null;
|
||||
const effectiveRunnerInstanceId = durableRunnerBinding?.runnerInstanceId ?? input.runnerInstanceId;
|
||||
if (effectiveRunnerInstanceId !== input.runnerInstanceId) {
|
||||
await input.db.update(heartbeatRuns).set({
|
||||
runnerInstanceId: effectiveRunnerInstanceId,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(
|
||||
eq(heartbeatRuns.id, input.execution.binding.runId),
|
||||
eq(heartbeatRuns.companyId, input.execution.binding.companyId),
|
||||
eq(heartbeatRuns.agentId, input.execution.binding.agentId),
|
||||
));
|
||||
}
|
||||
const leaseOwner = input.leaseOwner ?? `${effectiveRunnerInstanceId}:${randomUUID()}`;
|
||||
const leaseNow = new Date();
|
||||
const leaseExpiresAt = new Date(leaseNow.getTime() + 20 * 60_000);
|
||||
const attempt = await input.db.transaction(async (tx) => {
|
||||
@@ -545,8 +678,36 @@ export async function executePaperclipNativeSession(input: {
|
||||
}).where(eq(heartbeatRuns.id, coordinator.runId));
|
||||
return coordinator.attempt + 1;
|
||||
});
|
||||
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
|
||||
const controlPlaneInstanceId = `${effectiveRunnerInstanceId}:control`;
|
||||
const planSynchronizations: PlanSynchronization[] = [];
|
||||
const recordPlanSynchronization = async (event: {
|
||||
sourceEventId: string;
|
||||
turnId?: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => {
|
||||
const synchronization = await synchronizeCompletedProviderPlan({
|
||||
db: input.db,
|
||||
execution: input.execution,
|
||||
event,
|
||||
});
|
||||
if (!synchronization) return;
|
||||
planSynchronizations.push(synchronization);
|
||||
const activity = await persistActivity(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
actorType: "agent",
|
||||
actorId: input.execution.binding.agentId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
runId: input.execution.binding.runId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
action: "issue.document_updated",
|
||||
entityType: "issue",
|
||||
entityId: input.execution.binding.issueId,
|
||||
details: { key: "plan", source: "native_plan_synchronization", synchronization },
|
||||
});
|
||||
publishActivity(activity.publication);
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
|
||||
};
|
||||
const controlPlane = new PaperclipControlPlanePort(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
@@ -554,20 +715,17 @@ export async function executePaperclipNativeSession(input: {
|
||||
agentId: input.execution.binding.agentId,
|
||||
completionContractId: input.execution.completionContract.id,
|
||||
completionContractSha256: input.execution.completionContract.sha256,
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
sourceInstanceId: effectiveRunnerInstanceId,
|
||||
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
||||
}, {
|
||||
onCommittedEvent: async (event) => {
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
|
||||
const synchronization = await synchronizeCompletedProviderPlan({
|
||||
db: input.db,
|
||||
execution: input.execution,
|
||||
event: event as { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> },
|
||||
await recordPlanSynchronization(event as {
|
||||
sourceEventId: string;
|
||||
turnId?: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
});
|
||||
if (synchronization) {
|
||||
planSynchronizations.push(synchronization);
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
|
||||
}
|
||||
},
|
||||
});
|
||||
let native: Awaited<ReturnType<typeof executeNativeSession>>;
|
||||
@@ -592,7 +750,11 @@ export async function executePaperclipNativeSession(input: {
|
||||
}
|
||||
try {
|
||||
const runnerdBackend = input.useRunnerd && input.backend === undefined
|
||||
? createRunnerdBackend(input)
|
||||
? createRunnerdBackend({
|
||||
...input,
|
||||
runnerInstanceId: effectiveRunnerInstanceId,
|
||||
durableEnvironmentLeaseId: durableRunnerBinding?.environmentLeaseId,
|
||||
})
|
||||
: null;
|
||||
native = await executeNativeSession({
|
||||
input: input.execution,
|
||||
@@ -603,9 +765,10 @@ export async function executePaperclipNativeSession(input: {
|
||||
onSpawn: input.onSpawn,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
acpxRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "acpx"),
|
||||
}),
|
||||
controlPlane,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
runnerInstanceId: effectiveRunnerInstanceId,
|
||||
controlPlaneInstanceId,
|
||||
existingSession: existingWarmSession,
|
||||
persistedSession: persistedWarmSession,
|
||||
@@ -698,6 +861,29 @@ export async function executePaperclipNativeSession(input: {
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
planSynchronizations.length === 0
|
||||
&& "executionMode" in input.execution
|
||||
&& input.execution.executionMode === "plan"
|
||||
) {
|
||||
const markdown = semanticProviderPlanMarkdown(native.result as unknown as Record<string, unknown>);
|
||||
if (markdown) {
|
||||
const digest = createHash("sha256").update(markdown).digest("hex");
|
||||
await recordPlanSynchronization({
|
||||
sourceEventId: `semantic-plan:${input.execution.binding.runId}:${digest}`,
|
||||
...(native.turnId ? { turnId: native.turnId } : {}),
|
||||
eventType: "plan.updated",
|
||||
payload: {
|
||||
schema: "paperclip.plan.updated.v1",
|
||||
planId: `semantic:${native.turnId ?? input.execution.binding.runId}`,
|
||||
revision: 1,
|
||||
complete: true,
|
||||
markdown,
|
||||
source: "semantic_result_artifact",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
await input.db.update(nativeRunFinalizations).set({
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
@@ -715,7 +901,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
result: native.result as unknown as Record<string, unknown>,
|
||||
terminal: native.terminal,
|
||||
turnId: native.turnId,
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
sourceInstanceId: effectiveRunnerInstanceId,
|
||||
normalizedSessionId: native.normalizedSessionId,
|
||||
providerSessionId: native.providerSessionId,
|
||||
driverKind: native.driverKind,
|
||||
@@ -746,8 +932,16 @@ export async function executePaperclipNativeSession(input: {
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: input.execution.provider.kind === "claude_managed"
|
||||
? "anthropic"
|
||||
: input.execution.provider.kind === "aws_agentcore"
|
||||
? "amazon-bedrock"
|
||||
: input.execution.provider.kind === "opencode"
|
||||
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
|
||||
: input.execution.provider.kind === "acpx"
|
||||
? input.execution.provider.agent === "pi"
|
||||
? "openrouter"
|
||||
: input.execution.provider.agent === "claude"
|
||||
? "anthropic"
|
||||
: "openai"
|
||||
: "openai",
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage),
|
||||
@@ -783,8 +977,9 @@ function normalizeNativeUsage(usage: Record<string, unknown> | null) {
|
||||
|
||||
function createRunnerdBackend(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
execution: NativeExecutionInput;
|
||||
runnerInstanceId: string;
|
||||
durableEnvironmentLeaseId?: string;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): NativeSessionBackend {
|
||||
@@ -793,16 +988,14 @@ function createRunnerdBackend(input: {
|
||||
issueId: input.execution.binding.issueId,
|
||||
runId: input.execution.binding.runId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
workMode: input.execution.task.workMode,
|
||||
});
|
||||
const sessionId = nativeSessionKey(input.execution);
|
||||
const existingRouter = sessionToolRouters.get(sessionId);
|
||||
const authorityRouter = existingRouter ?? new SessionToolAuthorityRouter(authority);
|
||||
authorityRouter.bind(authority);
|
||||
sessionToolRouters.set(sessionId, authorityRouter);
|
||||
const root = resolve(
|
||||
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
|
||||
createHash("sha256").update(sessionId).digest("hex"),
|
||||
);
|
||||
const root = runnerdStateRoot(input.execution);
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
return createNativeSessionBackend(input.execution, {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
@@ -812,7 +1005,21 @@ function createRunnerdBackend(input: {
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
codexTransportFactory: () => createRunnerdCodexTransport({
|
||||
provider: input.execution.provider.kind,
|
||||
provider: input.execution.provider.kind === "codex"
|
||||
? "codex"
|
||||
: input.execution.provider.kind === "opencode"
|
||||
? "opencode"
|
||||
: input.execution.provider.kind === "claude_managed"
|
||||
? "claude_managed"
|
||||
: input.execution.provider.kind === "aws_agentcore"
|
||||
? "aws_agentcore"
|
||||
: input.execution.provider.kind === "acpx"
|
||||
? "acpx"
|
||||
: undefined,
|
||||
...(input.execution.provider.kind === "acpx" ? {
|
||||
acpxAgent: input.execution.provider.agent,
|
||||
acpxRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "acpx"),
|
||||
} : {}),
|
||||
...(input.execution.provider.kind === "claude_managed" ? {
|
||||
managedProfile: {
|
||||
...input.execution.provider.managedProfile,
|
||||
@@ -820,13 +1027,23 @@ function createRunnerdBackend(input: {
|
||||
model: input.execution.provider.model,
|
||||
},
|
||||
} : {}),
|
||||
...(input.execution.provider.kind === "aws_agentcore" ? {
|
||||
agentCoreProfile: {
|
||||
...input.execution.provider.agentCoreProfile,
|
||||
maxEstimatedSessionCostUsd: input.execution.provider.maxEstimatedSessionCostUsd,
|
||||
maxIterations: input.execution.provider.invocationLimits.maxIterations,
|
||||
maxOutputTokens: input.execution.provider.invocationLimits.maxOutputTokens,
|
||||
timeoutSeconds: input.execution.provider.invocationLimits.timeoutSeconds,
|
||||
model: input.execution.provider.model,
|
||||
},
|
||||
} : {}),
|
||||
stateDirectory: root,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
lifecyclePolicy: input.execution.session.lifecyclePolicy,
|
||||
resumeDynamicTools: authorityRouter.definitions(),
|
||||
prpIdentity: {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
environmentLeaseId: input.execution.binding.executionWorkspaceId,
|
||||
environmentLeaseId: input.durableEnvironmentLeaseId ?? input.execution.binding.executionWorkspaceId,
|
||||
runId: input.execution.binding.runId,
|
||||
normalizedSessionId: input.execution.session.normalizedSessionId ?? `session-${input.execution.binding.runId}`,
|
||||
turnId: `turn-${input.execution.binding.runId}`,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNativeExecutionInput } from "./native-execution-input.js";
|
||||
import { rebindNativeSessionCheckpoint } from "./native-session-resume.js";
|
||||
|
||||
const companyId = "10000000-0000-4000-8000-000000000001";
|
||||
const issueId = "20000000-0000-4000-8000-000000000002";
|
||||
const agentId = "30000000-0000-4000-8000-000000000003";
|
||||
const previousRunId = "40000000-0000-4000-8000-000000000004";
|
||||
const currentRunId = "50000000-0000-4000-8000-000000000005";
|
||||
const normalizedSessionId = "60000000-0000-4000-8000-000000000006";
|
||||
|
||||
function execution(runId: string, cwd = "/workspace") {
|
||||
return buildNativeExecutionInput({
|
||||
companyId,
|
||||
runId,
|
||||
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "standard" },
|
||||
taskPrompt: "Only the current turn",
|
||||
agentId,
|
||||
workspace: { id: runId, cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId,
|
||||
provider: "codex",
|
||||
completionContract: {
|
||||
id: "70000000-0000-4000-8000-000000000007",
|
||||
sha256: `sha256:${"a".repeat(64)}`,
|
||||
schemaVersion: "paperclip.run-result.v1",
|
||||
contract: {
|
||||
revision: "1",
|
||||
objective: "Test session resumption",
|
||||
criteria: [{ id: "objective", requirement: "Answer the current turn" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function planningExecution(runId: string, revisionId: string) {
|
||||
return buildNativeExecutionInput({
|
||||
companyId,
|
||||
runId,
|
||||
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "planning" },
|
||||
taskPrompt: "Revise the plan",
|
||||
agentId,
|
||||
workspace: { id: runId, cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId,
|
||||
provider: "codex",
|
||||
executionMode: "plan",
|
||||
planningContext: {
|
||||
documentId: "80000000-0000-4000-8000-000000000008",
|
||||
baseRevisionId: revisionId,
|
||||
baseRevisionNumber: revisionId.endsWith("9") ? 9 : 8,
|
||||
markdown: `Plan at ${revisionId}`,
|
||||
sha256: `sha256:${"b".repeat(64)}`,
|
||||
reviewContext: {},
|
||||
},
|
||||
completionContract: execution(runId).completionContract,
|
||||
});
|
||||
}
|
||||
|
||||
function previousRun(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: previousRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
nativeSessionId: normalizedSessionId,
|
||||
runnerProfileJson: {
|
||||
nativeExecutionInput: execution(previousRunId),
|
||||
sessionCheckpoint: {
|
||||
backendKind: "runner",
|
||||
driverKind: "codex_app_server",
|
||||
sessionId: "provider-thread-123",
|
||||
providerSessionId: "provider-thread-123",
|
||||
cursor: "42",
|
||||
identity: { runId: previousRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
|
||||
semanticResult: { schema: "paperclip.run-result.v1", reportedWorkDisposition: "done", summary: "old" },
|
||||
terminal: { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" },
|
||||
activeTurnId: "old-turn",
|
||||
terminalTurns: [{ turnId: "old-turn", state: "completed" }],
|
||||
pendingRuntimeRequests: [{ requestId: "old-request" }],
|
||||
lineage: [{ threadId: "provider-thread-123" }],
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("rebindNativeSessionCheckpoint", () => {
|
||||
it("retains provider identity but clears prior turn and event state", () => {
|
||||
const rebound = rebindNativeSessionCheckpoint({
|
||||
previousRun: previousRun(),
|
||||
currentExecution: execution(currentRunId),
|
||||
});
|
||||
expect(rebound).toMatchObject({
|
||||
sessionId: "provider-thread-123",
|
||||
providerSessionId: "provider-thread-123",
|
||||
driverKind: "codex_app_server",
|
||||
cursor: null,
|
||||
semanticResult: null,
|
||||
terminal: null,
|
||||
activeTurnId: null,
|
||||
terminalTurns: [],
|
||||
pendingRuntimeRequests: [],
|
||||
identity: { runId: currentRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to resume when the workspace changes", () => {
|
||||
expect(rebindNativeSessionCheckpoint({
|
||||
previousRun: previousRun(),
|
||||
currentExecution: execution(currentRunId, "/different-workspace"),
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a checkpoint whose prior-run binding was rewritten", () => {
|
||||
const source = previousRun();
|
||||
const profile = source.runnerProfileJson as Record<string, unknown>;
|
||||
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
|
||||
checkpoint.identity = { ...(checkpoint.identity as Record<string, unknown>), runId: currentRunId };
|
||||
expect(rebindNativeSessionCheckpoint({ previousRun: source, currentExecution: execution(currentRunId) })).toBeNull();
|
||||
});
|
||||
|
||||
it("reuses plan mode across canonical revisions but never across a mode change", () => {
|
||||
const source = previousRun({ nativeExecutionInput: planningExecution(previousRunId, "revision-8") });
|
||||
expect(rebindNativeSessionCheckpoint({
|
||||
previousRun: source,
|
||||
currentExecution: planningExecution(currentRunId, "revision-9"),
|
||||
})).not.toBeNull();
|
||||
expect(rebindNativeSessionCheckpoint({
|
||||
previousRun: source,
|
||||
currentExecution: execution(currentRunId),
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
NativeExecutionInput,
|
||||
PersistedNativeSession,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function isNativeSessionId(value: unknown): value is string {
|
||||
return typeof value === "string"
|
||||
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function sameProvider(
|
||||
previous: NativeExecutionInput["provider"],
|
||||
current: NativeExecutionInput["provider"],
|
||||
): boolean {
|
||||
return JSON.stringify(previous) === JSON.stringify(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebind a completed prior run's provider checkpoint to a new heartbeat run.
|
||||
* The provider/driver session identity is retained, while every per-turn and
|
||||
* per-event field is reset so the new run starts one clean turn via resume.
|
||||
*/
|
||||
export function rebindNativeSessionCheckpoint(input: {
|
||||
previousRun: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
nativeSessionId: string | null;
|
||||
runnerProfileJson: unknown;
|
||||
};
|
||||
currentExecution: NativeExecutionInput;
|
||||
}): PersistedNativeSession | null {
|
||||
const previousProfile = record(input.previousRun.runnerProfileJson);
|
||||
const rawCheckpoint = record(previousProfile.sessionCheckpoint);
|
||||
const checkpointIdentity = record(rawCheckpoint.identity);
|
||||
const current = input.currentExecution;
|
||||
const normalizedSessionId = current.session.normalizedSessionId;
|
||||
if (
|
||||
!isNativeSessionId(normalizedSessionId)
|
||||
|| input.previousRun.companyId !== current.binding.companyId
|
||||
|| input.previousRun.agentId !== current.binding.agentId
|
||||
|| input.previousRun.nativeSessionId !== normalizedSessionId
|
||||
|| typeof rawCheckpoint.sessionId !== "string"
|
||||
|| checkpointIdentity.runId !== input.previousRun.id
|
||||
|| checkpointIdentity.companyId !== current.binding.companyId
|
||||
|| checkpointIdentity.issueId !== current.binding.issueId
|
||||
|| checkpointIdentity.agentId !== current.binding.agentId
|
||||
|| checkpointIdentity.sessionId !== normalizedSessionId
|
||||
) return null;
|
||||
|
||||
let previousExecution: NativeExecutionInput;
|
||||
try {
|
||||
previousExecution = parseNativeExecutionInput(previousProfile.nativeExecutionInput);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
previousExecution.binding.runId !== input.previousRun.id
|
||||
|| previousExecution.binding.companyId !== current.binding.companyId
|
||||
|| previousExecution.binding.issueId !== current.binding.issueId
|
||||
|| previousExecution.binding.agentId !== current.binding.agentId
|
||||
|| previousExecution.session.normalizedSessionId !== normalizedSessionId
|
||||
|| previousExecution.session.driverKind !== current.session.driverKind
|
||||
|| previousExecution.workspace.cwd !== current.workspace.cwd
|
||||
|| ("executionMode" in previousExecution ? previousExecution.executionMode : "default")
|
||||
!== ("executionMode" in current ? current.executionMode : "default")
|
||||
|| !sameProvider(previousExecution.provider, current.provider)
|
||||
) return null;
|
||||
|
||||
return {
|
||||
...(structuredClone(rawCheckpoint) as unknown as PersistedNativeSession),
|
||||
identity: {
|
||||
runId: current.binding.runId,
|
||||
sessionId: normalizedSessionId,
|
||||
companyId: current.binding.companyId,
|
||||
issueId: current.binding.issueId,
|
||||
agentId: current.binding.agentId,
|
||||
},
|
||||
cursor: null,
|
||||
semanticResult: null,
|
||||
terminal: null,
|
||||
activeTurnId: null,
|
||||
terminalTurns: [],
|
||||
pendingRuntimeRequests: [],
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Binding = {
|
||||
issueId: string;
|
||||
runId: string;
|
||||
agentId: string;
|
||||
workMode?: "standard" | "planning";
|
||||
};
|
||||
|
||||
type ToolReceipt = {
|
||||
@@ -48,8 +49,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
constructor(readonly db: Db, readonly binding: Binding) {}
|
||||
|
||||
definitions(): Array<Record<string, unknown>> {
|
||||
const workMode = this.binding.workMode ?? "standard";
|
||||
return CAPABILITY_SEMANTIC_TOOL_CATALOG
|
||||
.filter((descriptor) => IMPLEMENTED_OPERATIONS.has(descriptor.operationId))
|
||||
.filter((descriptor) =>
|
||||
IMPLEMENTED_OPERATIONS.has(descriptor.operationId)
|
||||
&& descriptor.allowedModes.includes(workMode)
|
||||
)
|
||||
.map((descriptor) => ({
|
||||
name: descriptor.operationId,
|
||||
description: descriptor.description,
|
||||
@@ -60,6 +65,10 @@ export class PaperclipRunnerToolAuthority {
|
||||
async execute(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
|
||||
if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
|
||||
const context = await this.#boundContext();
|
||||
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((candidate) => candidate.operationId === call.tool);
|
||||
if (!descriptor?.allowedModes.includes(context.issue.workMode as "standard" | "planning")) {
|
||||
throw new Error("paperclip_runner_tool_mode_denied");
|
||||
}
|
||||
const input = record(call.arguments);
|
||||
switch (call.tool) {
|
||||
case "get_task_context": return {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "claude" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toThrow(/provider must be codex, opencode, or claude_managed/);
|
||||
})).toThrow(/provider must be codex, opencode, claude_managed, aws_agentcore, or acpx/);
|
||||
});
|
||||
|
||||
it("selects OpenCode only with a provider/model value", () => {
|
||||
@@ -125,4 +125,18 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
adapterConfig: { provider: "codex" },
|
||||
})).toEqual(expect.objectContaining({ kind: "native" }));
|
||||
});
|
||||
|
||||
it("admits planning only through paperclip_runner", () => {
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
issue: { id: "plan-issue", workMode: "planning" },
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "codex" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
issue: { id: "plan-issue", workMode: "planning" },
|
||||
})).toThrow("planning requires the paperclip_runner adapter");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ export type NativeRuntimeResolution =
|
||||
reason: "eligible_opt_in";
|
||||
profile: {
|
||||
mode: "native";
|
||||
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
|
||||
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
|
||||
protocolVersion: 1;
|
||||
};
|
||||
authorityDecision: NativeStatusDecision;
|
||||
@@ -51,8 +51,8 @@ export function resolveNativeRuntimeMode(input: {
|
||||
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
|
||||
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
|
||||
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
|
||||
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed"].includes(String(runnerProvider))) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, or claude_managed");
|
||||
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"].includes(String(runnerProvider))) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, claude_managed, aws_agentcore, or acpx");
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "opencode") {
|
||||
const model = record(input.adapterConfig).model;
|
||||
@@ -75,6 +75,39 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires a positive spend ceiling");
|
||||
}
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "aws_agentcore") {
|
||||
const config = record(input.adapterConfig);
|
||||
for (const key of ["agentCoreProfileId", "model"]) {
|
||||
if (typeof config[key] !== "string" || String(config[key]).trim().length === 0) {
|
||||
throw new NativeRuntimeEligibilityError(`paperclip_runner AWS AgentCore provider requires ${key}`);
|
||||
}
|
||||
}
|
||||
if (config.agentCoreRetentionAcknowledged !== true) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner AWS AgentCore provider requires the 90-day retention acknowledgement");
|
||||
}
|
||||
const cap = Number(config.maxEstimatedSessionCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner AWS AgentCore provider requires a positive estimated spend ceiling");
|
||||
}
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "acpx") {
|
||||
const config = record(input.adapterConfig);
|
||||
const agent = config.acpxAgent;
|
||||
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner ACPX provider requires acpxAgent pi, claude, or codex");
|
||||
}
|
||||
if (typeof config.model !== "string" || config.model.trim().length === 0) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner ACPX provider requires an exact model");
|
||||
}
|
||||
const qualifiedModel = agent === "pi"
|
||||
? "openrouter/deepseek/deepseek-v4-flash-0731"
|
||||
: agent === "claude"
|
||||
? "claude-sonnet-5"
|
||||
: "gpt-5.6-sol";
|
||||
if (config.model !== qualifiedModel) {
|
||||
throw new NativeRuntimeEligibilityError(`paperclip_runner ACPX ${agent} profile requires model ${qualifiedModel}`);
|
||||
}
|
||||
}
|
||||
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
|
||||
if (!runnerAdapterSelected && !input.enabled) {
|
||||
const compatibility = resolveNativeCompatibilityStatus({
|
||||
@@ -105,11 +138,14 @@ export function resolveNativeRuntimeMode(input: {
|
||||
) {
|
||||
throw new NativeRuntimeEligibilityError("agent must be an active Codex-backed native agent");
|
||||
}
|
||||
if (!input.issue || input.issue.workMode !== "standard") {
|
||||
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
|
||||
if (!input.issue || !["standard", "planning"].includes(input.issue.workMode)) {
|
||||
throw new NativeRuntimeEligibilityError("run must be bound to a standard or planning issue");
|
||||
}
|
||||
if (input.issue.workMode === "planning" && !runnerAdapterSelected) {
|
||||
throw new NativeRuntimeEligibilityError("native planning requires the paperclip_runner adapter");
|
||||
}
|
||||
if (
|
||||
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed")
|
||||
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed" && runnerProvider !== "aws_agentcore")
|
||||
|| input.target?.kind && input.target.kind !== "local"
|
||||
) {
|
||||
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
|
||||
@@ -132,6 +168,10 @@ export function resolveNativeRuntimeMode(input: {
|
||||
? "opencode_server"
|
||||
: runnerProvider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: runnerProvider === "aws_agentcore"
|
||||
? "aws_agentcore_harness_api"
|
||||
: runnerProvider === "acpx"
|
||||
? "acpx_runtime"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
@@ -171,6 +211,10 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
? "opencode_server"
|
||||
: input.persisted.driverKind === "claude_managed_agents_api"
|
||||
? "claude_managed_agents_api"
|
||||
: input.persisted.driverKind === "aws_agentcore_harness_api"
|
||||
? "aws_agentcore_harness_api"
|
||||
: input.persisted.driverKind === "acpx_runtime"
|
||||
? "acpx_runtime"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, remoteAgentProfiles } from "@paperclipai/db";
|
||||
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export type RemoteAgentService = "anthropic_managed_agents" | "aws_bedrock_agentcore_harness";
|
||||
|
||||
export interface RemoteAgentProfileInput {
|
||||
profileKey: string;
|
||||
displayName: string;
|
||||
service: RemoteAgentService;
|
||||
configuration: Record<string, unknown>;
|
||||
credentialSecretId?: string | null;
|
||||
enabled: boolean;
|
||||
retentionAcknowledged: boolean;
|
||||
qualification?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function required(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw unprocessable(`${label} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function validateConfiguration(service: RemoteAgentService, configuration: Record<string, unknown>) {
|
||||
const keys = service === "aws_bedrock_agentcore_harness"
|
||||
? ["region", "accountId", "harnessArn", "harnessVersion", "endpointArn", "endpointQualifier", "agentRuntimeArn", "memoryArn", "memoryId", "invocationRoleArn", "qualificationRevision", "defaultModel"]
|
||||
: ["anthropicAgentId", "agentVersion", "environmentId", "betaVersion", "defaultModel"];
|
||||
for (const key of keys) required(configuration[key], `configuration.${key}`);
|
||||
if (service === "aws_bedrock_agentcore_harness" && configuration.eventExpiryDays !== 90) {
|
||||
throw unprocessable("AWS AgentCore profile requires a 90-day Memory expiry");
|
||||
}
|
||||
if (service === "anthropic_managed_agents" && configuration.betaVersion !== "managed-agents-2026-04-01") {
|
||||
throw unprocessable("Anthropic profile beta version is not qualified");
|
||||
}
|
||||
}
|
||||
|
||||
export function remoteAgentProfileService(db: Db) {
|
||||
async function list(companyId: string, service?: RemoteAgentService) {
|
||||
return db.select().from(remoteAgentProfiles)
|
||||
.where(service
|
||||
? and(eq(remoteAgentProfiles.companyId, companyId), eq(remoteAgentProfiles.service, service))
|
||||
: eq(remoteAgentProfiles.companyId, companyId))
|
||||
.orderBy(asc(remoteAgentProfiles.displayName));
|
||||
}
|
||||
|
||||
async function get(companyId: string, profileIdOrKey: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profileIdOrKey);
|
||||
const rows = await db.select().from(remoteAgentProfiles).where(and(
|
||||
eq(remoteAgentProfiles.companyId, companyId),
|
||||
isUuid ? eq(remoteAgentProfiles.id, profileIdOrKey) : eq(remoteAgentProfiles.profileKey, profileIdOrKey),
|
||||
)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function requireQualified(companyId: string, profileIdOrKey: string, service?: RemoteAgentService) {
|
||||
const profile = await get(companyId, profileIdOrKey);
|
||||
if (!profile || service && profile.service !== service) throw notFound("Remote Agent profile not found");
|
||||
if (!profile.enabled || !profile.retentionAcknowledged || !profile.qualifiedAt) throw conflict("Remote Agent profile is not enabled and qualified");
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function upsert(companyId: string, input: RemoteAgentProfileInput) {
|
||||
const profileKey = required(input.profileKey, "Profile key");
|
||||
const displayName = required(input.displayName, "Display name");
|
||||
validateConfiguration(input.service, input.configuration);
|
||||
if (input.enabled && !input.retentionAcknowledged) throw unprocessable("Enabling a remote agent requires retention acknowledgement");
|
||||
if (input.service === "anthropic_managed_agents" && !input.credentialSecretId) throw unprocessable("Anthropic profiles require a credential secret reference");
|
||||
if (input.credentialSecretId) {
|
||||
const secret = await db.select({ id: companySecrets.id }).from(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId), eq(companySecrets.id, input.credentialSecretId),
|
||||
eq(companySecrets.scope, "company"), ne(companySecrets.status, "deleted"),
|
||||
)).limit(1);
|
||||
if (!secret[0]) throw unprocessable("Remote Agent credential secret reference is invalid");
|
||||
}
|
||||
const values = {
|
||||
companyId, profileKey, displayName, service: input.service,
|
||||
configuration: structuredClone(input.configuration),
|
||||
credentialSecretId: input.credentialSecretId ?? null,
|
||||
enabled: input.enabled, retentionAcknowledged: input.retentionAcknowledged,
|
||||
qualification: input.qualification ?? {}, qualifiedAt: input.enabled ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
} as const;
|
||||
const [row] = await db.insert(remoteAgentProfiles).values(values).onConflictDoUpdate({
|
||||
target: [remoteAgentProfiles.companyId, remoteAgentProfiles.profileKey], set: values,
|
||||
}).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
return { list, get, requireQualified, upsert };
|
||||
}
|
||||
Reference in new issue
Block a user