Merge master and renumber GitHub App setup migration

This commit is contained in:
Dotta committed 2026-10-07 07:11:26 -05:00
commit cf48352c85
79 files changed
+5008 -227

No files matched your search

@@ -21858,6 +21858,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
trigger: "subscribed_message",
});
// Keep this ordering assertion independent of database/CI wall-clock speed.
// The next drain below explicitly makes both deliveries due.
await db.update(chatDeliveries).set({ nextAttemptAt: new Date(Date.now() + 60_000) })
.where(and(eq(chatDeliveries.endpointId, endpoint.id), inArray(chatDeliveries.state, ["received", "retry"])));
await service.processPendingDeliveries();
expect(wakeup).not.toHaveBeenCalled();
await db
@@ -685,8 +685,9 @@ describe("claude execute", () => {
expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md");
expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile);
expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions");
expect(captured[1]?.appendedSystemPromptFileContents).toContain(
`The above agent instructions were loaded from ${instructionsFile}. ` +
expect(captured[1]?.appendedSystemPromptFileContents).not.toContain(instructionsFile);
for (const prompt of prompts) expect(prompt).toContain(
`Agent instructions for this run were loaded from ${instructionsFile}. ` +
`Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`,
@@ -1338,7 +1339,9 @@ describe("claude execute", () => {
expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true);
expect(capture1.instructionsContents).toContain("You are managed instructions.");
expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`);
expect(capture1.instructionsContents).not.toContain(instructionsPath);
expect(capture1.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
expect(capture2.prompt).toContain(`Agent instructions for this run were loaded from ${instructionsPath}.`);
expect(capture1.skillEntries).toContain("paperclip");
expect(capture2.argv).toContain("--resume");
expect(getFreshSessionHandoff).not.toHaveBeenCalled();
@@ -1362,6 +1365,81 @@ describe("claude execute", () => {
}
});
it("resumes across per-run instruction copies and refreshes their location", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-working-copy-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "claude");
const capturePath = path.join(root, "capture.json");
const skillDir = path.join(root, "skill");
await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(skillDir);
await fs.writeFile(path.join(skillDir, "SKILL.md"), "First skill version.\n");
await writeFakeClaudeCommand(commandPath);
vi.stubEnv("PAPERCLIP_HOME", path.join(root, "paperclip-home"));
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
try {
let sessionParams: Record<string, unknown> | null = null;
let previousBundleKey: unknown;
let previousInstructionsPath = "";
for (const [index, change] of ["initial", "location", "instructions", "skill"].entries()) {
const runId = `run-${index}`;
const instructionsPath = path.join(root, "companies", "company-1", "agents", "agent-1",
"file-sync", "runs", runId, "live", "AGENTS.md");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, index < 2 ? "First instructions.\n" : "Changed instructions.\n");
if (change === "skill") {
await fs.writeFile(path.join(skillDir, "SKILL.md"), "Changed skill version.\n");
}
const logs: string[] = [];
const result = await execute({
runId,
agent: { id: "agent-1", companyId: "company-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } },
runtime: { sessionId: null, sessionParams, sessionDisplayId: null, taskKey: "issue-1" },
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{ key: "test-skill", runtimeName: "test-skill", source: skillDir }],
paperclipSkillSync: { desiredSkills: ["test-skill"] },
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath, CLAUDE_CONFIG_DIR: path.join(root, "claude-config") },
},
context: { issueId: "issue-1" },
onLog: async (_stream, chunk) => { logs.push(chunk); },
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
if (change === "location") {
expect(capture.argv).toContain("--resume");
expect(capture.argv).toContain("11111111-1111-4111-8111-111111111111");
expect(capture.instructionsFilePath).toBeNull();
expect(result.sessionParams?.promptBundleKey).toBe(previousBundleKey);
} else {
expect(capture.argv).not.toContain("--resume");
expect(capture.instructionsContents).toContain(index < 2 ? "First instructions." : "Changed instructions.");
expect(result.sessionParams?.promptBundleKey).not.toBe(previousBundleKey);
if (index > 0) {
expect(capture.instructionsContents).not.toContain(instructionsPath);
expect(logs.join("")).toContain("was saved for prompt bundle");
expect(logs.join("")).not.toContain("was saved for cwd");
}
}
if (index > 0) {
expect(capture.prompt).toContain(instructionsPath);
expect(capture.prompt).toContain(`Resolve any relative file references from ${path.dirname(instructionsPath)}/.`);
}
if (previousInstructionsPath) expect(capture.prompt).not.toContain(previousInstructionsPath);
sessionParams = sessionCodec.deserialize(sessionCodec.serialize(result.sessionParams ?? null));
previousBundleKey = result.sessionParams?.promptBundleKey;
previousInstructionsPath = instructionsPath;
}
} finally {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
}
});
it("starts a fresh Claude session when the stable prompt bundle changes", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-"));
const workspace = path.join(root, "workspace");
@@ -1,3 +1,6 @@
import Ajv2020 from "ajv/dist/2020.js";
import { requestHumanInputAction } from "../../../packages/paperclip-runner/src/protocol-actions/request-human-input.js";
import { questionSetToAskUserQuestionsPayload } from "@paperclipai/shared";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@ -385,6 +388,18 @@ const support = await getEmbeddedPostgresTestSupport();
"via:zapier:hubspot",
]);
expect(result.providerQuestion?.prompt).toContain("external service");
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: false });
const input = { idempotencyKey: "provider-choice", interactionKind: "questions", title: "Choose a provider",
prompt: result.providerQuestion!.prompt, continuationPolicy: "wake_assignee" };
for (const payload of [{ version: 1, questionSet: result.providerQuestionSet }, { version: 1, questions: [result.providerQuestion] }]) {
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload }), JSON.stringify(ajv.errors)).toBe(true);
}
expect(questionSetToAskUserQuestionsPayload(result.providerQuestionSet!).questions).toEqual([result.providerQuestion]);
expect(result.instruction).toContain("questionSet:providerQuestionSet");
expect(result.instruction).not.toContain("do not add questionSet");
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1 } })).toBe(false);
expect(ajv.validate(requestHumanInputAction.live.descriptor.inputSchema, { ...input, payload: { version: 1,
questionSet: { ...result.providerQuestionSet, questions: [{ ...result.providerQuestionSet!.questions[0], answerMode: "text" }] } } })).toBe(false);
expect(result.results.every((item) => item.state !== "ready")).toBe(true);
expect(result.results.every((item) => item.aggregator?.evidenceUrl)).toBe(
true,
@@ -751,6 +751,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
claimedAt: now,
});
if (input?.includeIssue !== false) {
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover local adapter after lost process",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
}
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
@@ -778,19 +792,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
seq: 1, eventType: "adapter.invoke", payload: { adapterType: input?.adapterType ?? "codex_local" } });
if (input?.includeIssue !== false) {
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover local adapter after lost process",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.update(issues).set({ checkoutRunId: runId, executionRunId: runId }).where(eq(issues.id, issueId));
}
return { companyId, agentId, runId, wakeupRequestId, issueId };
@@ -1511,6 +1513,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
startedAt: now,
});
// Complete the circular issue/run fixture after both FK targets exist.
await db.update(heartbeatRuns)
.set({ scopeKind: "issue", issueId })
.where(eq(heartbeatRuns.id, runId));
return { companyId, agentId, runId, wakeupRequestId, issueId };
}
@@ -486,6 +486,11 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
identifier: `${issuePrefix}-1`,
});
// Complete the circular issue/run fixture after both FK targets exist.
await db.update(heartbeatRuns)
.set({ scopeKind: "issue", issueId })
.where(eq(heartbeatRuns.id, runId));
return { companyId, agentId, issueId, runId, now };
}
@@ -1262,8 +1267,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [currentWorkspaceId, foreignWorkspaceId]));
expect(workspaces).toEqual(expect.arrayContaining([
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: { current: true } }),
expect.objectContaining({ id: foreignWorkspaceId, status: "active", metadata: { foreign: true } }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
expect.objectContaining({ id: foreignWorkspaceId, status: "active", metadata: expect.objectContaining({ foreign: true }) }),
]));
const activity = await db
@@ -1390,8 +1395,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.id, [staleWorkspaceId, currentWorkspaceId]));
expect(workspaces).toEqual(expect.arrayContaining([
expect.objectContaining({ id: staleWorkspaceId, status: "active", metadata: { stale: true } }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: { current: true } }),
expect.objectContaining({ id: staleWorkspaceId, status: "active", metadata: expect.objectContaining({ stale: true }) }),
expect.objectContaining({ id: currentWorkspaceId, status: "active", metadata: expect.objectContaining({ current: true }) }),
]));
const activity = await db
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
costEvents,
@@ -363,12 +363,16 @@ describeEmbeddedPostgres("issue recovery actions", () => {
it.each(["company", "issue", "legacy", "finished"])("rejects an apparently live run with the wrong %s authority", async (mismatch) => {
const { companyId, sourceIssueId, runId, svc } = await seedNativeFinalizationRecovery("running");
const other = await seedCompany();
await db.update(heartbeatRuns).set({
// Exercise service rejection of historical corruption behind the immutable binding trigger.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({
...(mismatch === "company" ? { companyId: other.companyId, agentId: other.coderId } : {}),
...(mismatch === "issue" ? { nativeIssueId: other.sourceIssueId } : {}),
...(mismatch === "legacy" ? { runtimeMode: "legacy" } : {}),
...(mismatch === "finished" ? { finishedAt: new Date() } : {}),
}).where(eq(heartbeatRuns.id, runId));
});
expect((await svc.getActiveForIssue(companyId, sourceIssueId))?.nativeRunActivity).toBeNull();
});
@@ -602,18 +602,21 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
permissions: {},
});
await db.update(heartbeatRuns)
.set({ nativeIssueId: randomUUID() })
.where(eq(heartbeatRuns.id, fixture.runId));
// Simulate historical corruption to retain the service-level defense test.
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeIssueId: randomUUID() }).where(eq(heartbeatRuns.id, fixture.runId));
});
await expect(interactionsSvc.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionCreateInput(fixture.runId),
{ agentId: fixture.agentId, runId: fixture.runId },
)).rejects.toMatchObject({ status: 422, message: "sourceRunId must belong to the same issue" });
await db.update(heartbeatRuns)
.set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } })
.where(eq(heartbeatRuns.id, fixture.runId));
await db.transaction(async (tx) => {
await tx.execute(sql`set local session_replication_role = replica`);
await tx.update(heartbeatRuns).set({ nativeIssueId: null, contextSnapshot: { issueId: randomUUID(), paperclipWake: { comments: [] } } }).where(eq(heartbeatRuns.id, fixture.runId));
});
await expect(interactionsSvc.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionCreateInput(fixture.runId),
+1 -1
View File
@@ -5668,7 +5668,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
expect(workspace?.metadata).toEqual({
expect(workspace?.metadata).toMatchObject({
config: {
environmentId: null,
provisionCommand: "bash ./scripts/provision-new.sh",
@@ -0,0 +1,30 @@
import { randomUUID } from "node:crypto";
import { request } from "@playwright/test";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { RunnerApi } from "../../../tests/runner-e2e/api.js";
import { setupAggregatorFixture } from "../../../tests/runner-e2e/aggregator-fixture.js";
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID());
server = await startRunnerApiTestServer({ deploymentMode: "local_trusted" });
vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", new URL(server.apiUrl).port);
}, 60_000);
afterAll(async () => { await server?.close(); vi.unstubAllEnvs(); });
it("sets up an indexed company gateway without granting the eval agent access", async () => {
const fixture = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true });
const context = await request.newContext({ baseURL: server.apiUrl, extraHTTPHeaders: { Origin: server.apiUrl } });
const api = new RunnerApi(context);
let gateway: Awaited<ReturnType<typeof setupAggregatorFixture>> | undefined;
try {
gateway = await setupAggregatorFixture(api, fixture.companyId, fixture.agentId, "CONTACTS_local_only", true);
expect(gateway.initialAccess).toEqual({ installed: false, allowedToolIds: [] });
expect(gateway.invocationCount()).toBe(0);
const discovery = await fixture.authority.execute({ tool: "connections_search", callId: randomUUID(), arguments: { query: "HubSpot" } });
expect(discovery).toMatchObject({ providerQuestion: { id: "connection-provider:hubspot" }, providerQuestionSet: { schema: "paperclip.question_set.v1" } });
await expect(fixture.authority.execute({ tool: "connection_request", callId: randomUUID(), arguments: { service: "via:arcade:hubspot" } })).rejects.toThrow();
expect(gateway.invocationCount()).toBe(0);
} finally { await gateway?.close(); await context.dispose(); }
}, 60_000);
@@ -265,6 +265,7 @@ describeEmbeddedPostgres("gateway connector invocation telemetry", () => {
}, 30_000);
afterEach(async () => {
vi.useRealTimers();
track.mockClear();
failLogActivityForAction.current = null;
await db.delete(activityLog);
@@ -379,6 +380,9 @@ describeEmbeddedPostgres("gateway connector invocation telemetry", () => {
}, 30_000);
it("emits one denied event for a policy block and one rate_limited event past the limit", async () => {
// Both calls must land in the same policy window, even across a minute boundary.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-06T12:00:30Z"));
const f = await fixture();
await db.insert(toolPolicies).values({
companyId: f.company.id,