mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Merge master and renumber GitHub App setup migration
This commit is contained in:
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user