Merge synchronized native tracing into portable controller runtime

Include the safe evidence-reader module in portable inventory and preserve current master checkbox behavior plus release completion waits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-26 09:35:35 -05:00
commit ebe8910c29
313 files changed
+13760 -1123

No files matched your search

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockActivityService = vi.hoisted(() => ({
list: vi.fn(),
forIssue: vi.fn(),
inputFactsForIssue: vi.fn(),
runsForIssue: vi.fn(),
issuesForRun: vi.fn(),
create: vi.fn(),
@@ -152,6 +153,22 @@ describe("activity routes", () => {
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
});
it("reads bounded input facts with the same issue access checks", async () => {
const commentId = "11111111-1111-4111-8111-111111111111";
mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1" });
mockActivityService.inputFactsForIssue.mockResolvedValue([{ action: "issue.queued_comment_steered" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get(`/api/issues/TEST-1/activity?view=comment-inputs&commentIds=${commentId}`));
expect(res.status).toBe(200);
expect(mockActivityService.inputFactsForIssue).toHaveBeenCalledWith("company-1", "issue-1", [commentId]);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "issue:read" }));
const invalid = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity?view=comment-inputs&commentIds=invalid"));
expect(invalid.status).toBe(400);
const oversized = await requestApp(app, baseUrl => request(baseUrl).get(`/api/issues/TEST-1/activity?view=comment-inputs&commentIds=${Array(101).fill(commentId).join(",")}`));
expect(oversized.status).toBe(400);
expect(mockActivityService.inputFactsForIssue).toHaveBeenCalledTimes(1);
});
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
mockIssueService.getById.mockResolvedValue(issue);
@@ -161,6 +178,9 @@ describe("activity routes", () => {
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
expect(res.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
const inputs = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity?view=comment-inputs"));
expect(inputs.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.inputFactsForIssue).not.toHaveBeenCalled();
});
it("returns redacted all-actors rows to a basic company reader", async () => {
@@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { actorMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/error-handler.js";
import { captureRunIdentity, initializeRunIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("agent authentication identity transfer", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "identity-transfer-test-secret");
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
database = await startEmbeddedPostgresTestDatabase("paperclip-auth-identity-transfer-");
db = createDb(database.connectionString);
}, 30_000);
afterAll(async () => {
vi.unstubAllEnvs();
await db?.$client.end({ timeout: 5 });
await database?.cleanup();
}, 60_000);
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Authentication transfer", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", role: "engineer", adapterType: "codex_local", status: "idle" });
await db.insert(issues).values({ id: issueId, companyId, title: "Authentication task", responsibleUserId: "owner" });
const padding = "retained input ".repeat(32_768);
const payloads = {
contextSnapshot: { issueId, launchInput: padding },
runnerProfileJson: { nativeExecutionInput: { savedInput: padding } },
resultJson: { savedResult: padding }, stdoutExcerpt: padding, stderrExcerpt: padding,
};
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", ...payloads });
const identity = await initializeRunIdentity(db, { companyId, runId, issueId, messageIds: [], responsibleUserId: "owner", cause: "instruction" });
const app = express();
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async () => null }));
app.get("/actor", (req, res) => res.json(req.actor));
app.patch("/actor", (req, res) => res.json(req.actor));
app.use(errorHandler);
const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "stale-user");
expect(token).not.toBeNull();
return { companyId, agentId, issueId, runId, app, token: token!, identity, payloads };
}
it("bounds actual middleware responses and preserves the complete stored run", async () => {
const fixture = await seed();
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
const response = await request(fixture.app).get("/actor").set("Authorization", `Bearer ${fixture.token}`);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ type: "agent", runId: fixture.runId,
identityContextId: fixture.identity.id, onBehalfOfUserId: "owner" });
} finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "heartbeat_runs" && (event.operation === "select" || event.operation === "execute"));
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThanOrEqual(3);
expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(16_384);
expect((await captureRunIdentity(db, fixture)).run).toMatchObject(fixture.payloads);
});
it("blocks uncertain steering, accepts its durable acknowledgement, and rechecks cancellation", async () => {
const fixture = await seed(), messageId = randomUUID();
await db.insert(issueComments).values({ id: messageId, companyId: fixture.companyId, issueId: fixture.issueId,
authorUserId: "next-user", body: "Next instruction" });
const pending = await reserveSteeredIdentity(db, { ...fixture, messageId });
expect(pending).not.toBeNull();
const blocked = await request(fixture.app).get("/actor").set("Authorization", `Bearer ${fixture.token}`);
expect(blocked.status).toBe(409);
await db.insert(heartbeatRunEvents).values({ companyId: fixture.companyId, agentId: fixture.agentId, runId: fixture.runId,
seq: 1, eventType: "item.completed", sourceEventId: randomUUID(),
payload: { prpEvent: { turnId: "auth-turn", itemId: `auth-turn:steer:${messageId}`,
payload: { kind: "steering_acknowledgement" } } } });
const accepted = await request(fixture.app).get("/actor").set("Authorization", `Bearer ${fixture.token}`);
expect(accepted.status).toBe(200);
expect(accepted.body).toMatchObject({ identityContextId: pending!.id, onBehalfOfUserId: "next-user" });
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, fixture.runId));
const revoked = await request(fixture.app).patch("/actor").set("Authorization", `Bearer ${fixture.token}`);
expect(revoked.status).toBe(403);
expect(revoked.body.code).toBe("agent_run_cancelled");
const [stored] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(stored).toMatchObject(fixture.payloads);
});
it.each(["company", "agent", "missing", "finished"] as const)("rejects %s run identity for the compact scope", async (fault) => {
const fixture = await seed();
if (fault === "finished") await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, fixture.runId));
const input = { companyId: fault === "company" ? randomUUID() : fixture.companyId,
agentId: fault === "agent" ? randomUUID() : fixture.agentId,
runId: fault === "missing" ? randomUUID() : fixture.runId };
await expect(captureRunIdentity(db, input, { readScope: "request_authentication" })).rejects.toThrow(/active run/);
});
});
@@ -16,6 +16,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunIssueSummaryForAgent: vi.fn(),
getRunReadAccess: vi.fn(),
getRunLogAccess: vi.fn(),
getRunWorkspaceAccess: vi.fn(),
readLog: vi.fn(),
wakeup: vi.fn(),
getRun: vi.fn(),
@@ -378,6 +379,10 @@ describe("agent live run routes", () => {
logStore: "local_file",
logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson",
});
mockHeartbeatService.getRunWorkspaceAccess.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", scopeKind: "company", issueId: null,
executionWorkspaceId: null,
});
mockHeartbeatService.readLog.mockResolvedValue({
runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
store: "local_file",
@@ -609,10 +614,10 @@ describe("agent live run routes", () => {
});
});
it("uses narrow run log metadata lookups for log polling", async () => {
it.each([false, true])("uses narrow run log metadata lookups (aligned tail: %s)", async (aligned) => {
const res = await requestApp(await createApp(), (baseUrl) =>
request(baseUrl).get(
"/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log?offset=12&limitBytes=64",
`/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log?offset=12&limitBytes=64${aligned ? "&alignStartToRecord=true" : ""}`,
),
);
@@ -630,6 +635,8 @@ describe("agent live run routes", () => {
{
offset: 12,
limitBytes: 64,
signal: expect.any(AbortSignal),
...(aligned ? { alignStartToRecord: true } : {}),
},
);
expect(res.body).toEqual({
@@ -687,6 +694,21 @@ describe("agent live run routes", () => {
},
);
it("lists workspace operations using narrow current run access", async () => {
mockHeartbeatService.getRunWorkspaceAccess.mockResolvedValue({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", scopeKind: "company", issueId: null,
executionWorkspaceId: " workspace-one ",
});
mockWorkspaceOperationService.listForRun.mockResolvedValue([]);
const response = await requestApp(await createApp(), baseUrl => request(baseUrl)
.get("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/workspace-operations"));
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(mockHeartbeatService.getRunWorkspaceAccess).toHaveBeenCalledWith("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
expect(mockWorkspaceOperationService.listForRun).toHaveBeenCalledWith("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "workspace-one");
});
it("caps company live run polling by default", async () => {
const rows = Array.from({ length: 75 }, (_, index) => ({
id: `run-${index}`,
@@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, agentWakeupRequests, activityLog, authUsers, companies, companyMemberships, createDb,
executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { registerAgentProjectAccessRoutes } from "../routes/agent-project-access.js";
import { errorHandler } from "../middleware/error-handler.js";
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "../services/task-workspace-access.js";
import { taskInterruptionPresentation } from "../services/task-interruption-presentation.js";
import { ProjectAccessDeniedError } from "../services/project-access-denial.js";
import { issueService } from "../services/issues.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("additive project access", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
if (external) db = createDb(external);
else { database = await startEmbeddedPostgresTestDatabase("agent-project-access-"); db = createDb(database.connectionString); }
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Project access", issuePrefix: `A${companyId.slice(0, 6)}` });
const selected = await db.insert(projects).values(["Existing", "Needed", "Third"].map(name => ({ companyId, name }))).returning();
const permissions = { canCreateAgents: false, canCreateSkills: false, trustPreset: "low_trust_review",
authorizationPolicy: { reviewPreset: { id: "low_trust_review", version: 1, rawOutputDisposition: "quarantine" },
customPolicy: { preserve: true }, trustBoundary: { mode: "low_trust_review", companyId,
projectIds: [selected[0]!.id], issueIds: [issueId], allowedToolClasses: ["git.read"], allowedSecretBindingIds: [] } } };
await db.insert(agents).values({ id: agentId, companyId, name: "Reviewer", adapterType: "process", status: "idle", permissions });
await db.insert(companyMemberships).values({ companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" });
const [issue] = await db.insert(issues).values({ id: issueId, companyId, title: "Saved message", status: "blocked", assigneeAgentId: agentId, projectId: selected[1]!.id }).returning();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "failed", errorCode: "setup_failed",
error: "Project is outside this low-trust boundary.", contextSnapshot: { issueId } });
return { companyId, agentId, issue: issue!, runId, selected, permissions };
}
function app(actor: express.Request["actor"]) {
const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); });
registerAgentProjectAccessRoutes(app, db); app.use(errorHandler); return app;
}
const board = { type: "board" as const, source: "local_implicit" as const };
it("keeps a historical denial blocked after the task moves to an allowed project but retains its source", async () => {
const f = await seed();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[1]!.id,
name: "Original repository", cwd: "/tmp/project-access-original" }).returning();
await db.update(issues).set({ projectWorkspaceId: source!.id,
workspaceSelection: { version: 1, source: "project", selection: {
kind: "configured_source", projectWorkspaceId: source!.id, mode: "managed_isolated",
} } }).where(eq(issues.id, f.issue.id));
const moved = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
expect(moved).toMatchObject({ projectId: f.selected[0]!.id, projectWorkspaceId: source!.id });
const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
await expect(assertTaskWorkspaceExecutionAccess(db, actor, f.companyId, { issueId: f.issue.id }))
.rejects.toBeInstanceOf(ProjectAccessDeniedError);
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({
canRetry: false, userAction: "project_access", projectAccess: { ready: false, projectId: null },
});
expect((await request(app(board)).post(`/agents/${f.agentId}/project-access`)
.send({ projectIds: [f.selected[1]!.id] })).status).toBe(200);
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
await expect(assertTaskWorkspaceExecutionAccess(db, actor, f.companyId, { issueId: f.issue.id })).resolves.toBeDefined();
});
it.each(["applied", "pending"] as const)("rechecks the selected source after an %s repair instead of the old denied project", async repair => {
const f = await seed();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[0]!.id,
name: "Allowed repository", cwd: "/tmp/project-access-allowed" }).returning();
await db.update(heartbeatRuns).set({ errorCode: "project_access_denied", error: null,
resultJson: { projectAccessDenied: { projectId: f.selected[1]!.id } } }).where(eq(heartbeatRuns.id, f.runId));
const moved = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
if (repair === "applied") {
await issueService(db).update(f.issue.id, { projectWorkspaceId: source!.id, workspaceSelectionActor: board });
} else {
await executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issue.id, actor: board,
selection: { kind: "configured_source", projectWorkspaceId: source!.id, mode: "managed_isolated" },
expectedBindingRevision: 0, requestKey: randomUUID() });
}
expect(await taskInterruptionPresentation(db, moved!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
// The original denied source was not granted, and presentation creates no execution.
expect((await db.select().from(agents).where(eq(agents.id, f.agentId)))[0]!.permissions).toEqual(f.permissions);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
if (repair === "pending") {
await db.update(issues).set({ workspaceBindingRevision: 2 }).where(eq(issues.id, f.issue.id));
const stale = await taskInterruptionPresentation(db, moved!);
expect(stale).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(stale).not.toHaveProperty("projectAccess");
}
});
it("keeps a pending existing selection blocked if its workspace is archived before retry", async () => {
const f = await seed();
const task = await issueService(db).update(f.issue.id, { projectId: f.selected[0]!.id });
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
projectId: f.selected[0]!.id, name: "Selected files", cwd: "/tmp/project-access-selected",
mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
const service = executionWorkspaceService(db);
await service.selectTaskWorkspace({ companyId: f.companyId, issueId: f.issue.id, actor: board,
selection: { kind: "existing", workspaceId: workspace!.id }, expectedBindingRevision: 0, requestKey: randomUUID() });
expect(await taskInterruptionPresentation(db, task!)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
expect(await service.archiveWorkspaceUnderLifecycleLock({ id: workspace!.id, patch: {}, closedAt: new Date() }))
.toMatchObject({ outcome: "archived" });
await expect(service.applyPendingTaskWorkspaceSelection({ companyId: f.companyId, issueId: f.issue.id,
runId: f.runId, actor: { type: "agent", source: "agent_key", agentId: f.agentId, companyId: f.companyId } }))
.rejects.toThrow("Workspace is unavailable or inaccessible");
const archived = await taskInterruptionPresentation(db, task!);
expect(archived).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(archived).not.toHaveProperty("projectAccess");
});
it.each(["denied_project", "foreign_company"] as const)("retains the %s gate on a bound workspace even when its source is allowed", async boundary => {
const f = await seed(), foreign = boundary === "foreign_company" ? await seed() : null;
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: f.selected[0]!.id,
name: "Allowed source", cwd: "/tmp/project-access-source" }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: foreign?.companyId ?? f.companyId,
projectId: foreign?.selected[0]!.id ?? f.selected[1]!.id, projectWorkspaceId: foreign ? null : source!.id,
name: "Retained files", cwd: "/tmp/project-access-bound", mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
const [task] = await db.update(issues).set({ projectId: f.selected[0]!.id, projectWorkspaceId: source!.id,
executionWorkspaceId: workspace!.id }).where(eq(issues.id, f.issue.id)).returning();
const blocked = await taskInterruptionPresentation(db, task!);
if (boundary === "denied_project") {
expect(blocked).toMatchObject({ canRetry: false,
userAction: "project_access", projectAccess: { ready: false, projectId: null } });
} else {
expect(blocked).toMatchObject({ state: "failed", canRetry: false, userAction: null });
expect(blocked).not.toHaveProperty("projectAccess");
}
});
it("preserves containment, audits concurrent additions, and changes the persisted denial into an explicit retry", async () => {
const f = await seed();
const actor = { type: "agent" as const, source: "agent_key" as const, agentId: f.agentId, companyId: f.companyId };
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).rejects.toBeInstanceOf(ProjectAccessDeniedError);
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ userAction: "project_access", canRetry: false });
const api = app(board);
const results = await Promise.all(f.selected.slice(1).map(project => request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [project.id] })));
expect(results.map(result => result.status)).toEqual([200, 200]);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved!.permissions).toEqual({ ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, projectIds: expect.arrayContaining(f.selected.map(project => project.id)) } } });
expect((saved!.permissions as typeof f.permissions).authorizationPolicy.trustBoundary.projectIds).toHaveLength(3);
await expect(assertTaskWorkspaceSourceProjectAccess(db, actor, f.companyId, f.selected[1]!.id)).resolves.toBeUndefined();
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
const again = await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] });
expect(again.status).toBe(200);
expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "agent.project_access_added")))).toHaveLength(2);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(0);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
});
it("rejects foreign projects, agent self-grants, unknown policy fields, and pending-approval agents", async () => {
const f = await seed(), other = await seed(), api = app(board);
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [other.selected[1]!.id] })).status).toBe(404);
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id], trustPreset: "standard" })).status).toBe(400);
expect((await request(app({ type: "agent", agentId: f.agentId, companyId: f.companyId, source: "agent_key" }))
.post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId));
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(409);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); expect(saved!.permissions).toEqual(f.permissions);
});
it.each(["standard", "root_issue"])("rechecks actual project authority after a %s repair and acknowledges an accepted retry", async repair => {
const f = await seed();
const permissions = repair === "standard" ? { canCreateAgents: false, trustPreset: "standard", authorizationPolicy: {} }
: { ...f.permissions, authorizationPolicy: { ...f.permissions.authorizationPolicy,
trustBoundary: { ...f.permissions.authorizationPolicy.trustBoundary, rootIssueId: f.issue.id } } };
await db.update(agents).set({ permissions }).where(eq(agents.id, f.agentId));
expect(await taskInterruptionPresentation(db, f.issue)).toMatchObject({ canRetry: true, projectAccess: { ready: true } });
await db.insert(agentWakeupRequests).values({ companyId: f.companyId, agentId: f.agentId,
source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", payload: { issueId: f.issue.id } });
const accepted = await taskInterruptionPresentation(db, f.issue);
expect(accepted).toMatchObject({ state: "working", canRetry: false });
expect(accepted).not.toHaveProperty("projectAccess");
// Current project policy remains authoritative even after agent access changes.
await db.update(projects).set({ executionWorkspacePolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.selected[0]!.id] },
} } }).where(eq(projects.id, f.selected[1]!.id));
if (repair === "standard") expect(await taskInterruptionPresentation(db, f.issue))
.toMatchObject({ canRetry: false, userAction: "project_access" });
});
it("shows read-only access to viewers and denies their saves and cross-company reads", async () => {
const f = await seed(), other = await seed(), userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Viewer", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "viewer", status: "active" });
const api = app({ type: "board", source: "session", userId, companyIds: [f.companyId], memberships: [{ companyId: f.companyId, membershipRole: "viewer", status: "active" }] });
expect((await request(api).get(`/agents/${f.agentId}/project-access`)).body).toMatchObject({ canManage: false });
expect((await request(api).post(`/agents/${f.agentId}/project-access`).send({ projectIds: [f.selected[1]!.id] })).status).toBe(403);
expect((await request(api).get(`/agents/${other.agentId}/project-access`)).status).toBe(404);
});
});
@@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import { agents, companies, costEvents, createDb } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentRecordQueries } from "../lib/agent-records.js";
import { agentService } from "../services/agents.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("agent roster read projections", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("agent-roster-read-");
db = createDb(database.connectionString);
}, 60_000);
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2020-06-15T12:00:00Z"));
});
afterEach(() => { vi.useRealTimers(); });
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
async function fixture() {
const companyId = randomUUID(), foreignCompanyId = randomUUID();
const managerId = randomUUID(), workerId = randomUUID(), unrelatedId = randomUUID(), archivedId = randomUUID(), foreignId = randomUUID();
await db.insert(companies).values([
{ id: companyId, name: "Roster fixture", issuePrefix: `R${companyId.slice(0, 7).toUpperCase()}` },
{ id: foreignCompanyId, name: "Foreign roster", issuePrefix: `F${foreignCompanyId.slice(0, 7).toUpperCase()}` },
]);
const bulk = "retained-other-agent-configuration🙂".repeat(32_768);
await db.insert(agents).values([
{ id: managerId, companyId, name: "Manager", status: "terminated", adapterType: "process", adapterConfig: { instructions: bulk } },
{ id: unrelatedId, companyId, name: "Other worker", status: "idle", adapterType: "process", adapterConfig: { instructions: bulk } },
{ id: archivedId, companyId, name: "Archived worker", status: "terminated", adapterType: "process", runtimeConfig: { retained: bulk } },
{ id: foreignId, companyId: foreignCompanyId, name: "Foreign manager", status: "idle", adapterType: "process", adapterConfig: { instructions: bulk } },
]);
const [worker] = await db.insert(agents).values({ id: workerId, companyId, name: "Selected worker", status: "idle",
reportsTo: managerId, adapterType: "process", adapterConfig: { instructions: "selected instructions", model: "fixture-model" },
runtimeConfig: { heartbeat: { enabled: false } }, metadata: { retained: "selected metadata" },
permissions: { canCreateAgents: true }, spentMonthlyCents: 999,
}).returning();
const now = new Date();
await db.insert(costEvents).values([
{ companyId, agentId: workerId, provider: "fixture", model: "fixture-model", costCents: 19.5, occurredAt: now },
{ companyId, agentId: workerId, provider: "fixture", model: "fixture-model", costCents: 50,
occurredAt: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 15)) },
{ companyId: foreignCompanyId, agentId: foreignId, provider: "fixture", model: "fixture-model", costCents: 100, occurredAt: now },
]);
return { companyId, foreignCompanyId, managerId, workerId, unrelatedId, archivedId, foreignId, worker, bulk };
}
async function observe<T>(run: (observed: ReturnType<typeof createDb>) => Promise<T>) {
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { agents, costEvents }, capture);
try {
const result = await run(observed);
capture.flush();
const reads = events.filter(event => event.table === "agents" && event.operation === "select");
return { result, calls: reads.reduce((n, event) => n + Number(event.calls), 0),
bytes: reads.reduce((n, event) => n + Number(event.responseValueBytes), 0), events };
} finally {
capture.flush();
await observed.$client.end();
}
}
it.each([false, true])("reads one full roster for list, preserving terminated-manager evidence and configurations (includeTerminated=%s)", async includeTerminated => {
const f = await fixture();
const captured = await observe(observed => agentService(observed).list(f.companyId, { includeTerminated }));
expect(captured.calls).toBe(1);
expect(captured.result.map(row => row.id).sort()).toEqual((includeTerminated
? [f.workerId, f.managerId, f.archivedId, f.unrelatedId] : [f.workerId, f.unrelatedId]).sort());
const selected = captured.result.find(row => row.id === f.workerId)!;
expect(selected).toMatchObject({ adapterConfig: f.worker.adapterConfig, runtimeConfig: f.worker.runtimeConfig,
metadata: f.worker.metadata, permissions: { canCreateAgents: true }, spentMonthlyCents: 19.5,
orgChainHealth: { status: "invalid_org_chain", reason: "terminated_ancestor", firstInvalidAncestor: { id: f.managerId } } });
expect(captured.result.find(row => row.id === f.unrelatedId)?.adapterConfig.instructions).toBe(f.bulk);
if (includeTerminated) expect(captured.result.find(row => row.id === f.archivedId)?.runtimeConfig.retained).toBe(f.bulk);
expect((await db.select().from(agents).where(eq(agents.id, f.workerId)))[0]).toEqual(f.worker);
expect(JSON.stringify(captured.events)).not.toContain("retained-other-agent-configuration");
});
it("keeps detail reads small while returning the selected agent's complete current configuration", async () => {
const f = await fixture();
const captured = await observe(observed => agentRecordQueries(observed).getById(f.workerId));
expect(captured.calls).toBe(2);
expect(captured.bytes).toBeLessThan(8192);
expect(captured.result).toMatchObject({ id: f.workerId, companyId: f.companyId, adapterConfig: f.worker.adapterConfig,
runtimeConfig: f.worker.runtimeConfig, metadata: f.worker.metadata, permissions: { canCreateAgents: true }, spentMonthlyCents: 19.5,
orgChainHealth: { reason: "terminated_ancestor", firstInvalidAncestor: { id: f.managerId, name: "Manager" } } });
expect((await db.select().from(agents).where(eq(agents.id, f.workerId)))[0]).toEqual(f.worker);
});
it("projects only current company chain fields for plugin normalization", async () => {
const f = await fixture();
const captured = await observe(observed => agentRecordQueries(observed).listCompanyAgentEligibilityRows(f.companyId));
expect(captured.calls).toBe(1);
expect(captured.bytes).toBeLessThan(4096);
expect(captured.result.map(row => row.id).sort()).toEqual([f.workerId, f.managerId, f.unrelatedId, f.archivedId].sort());
for (const row of captured.result) expect(Object.keys(row).sort()).toEqual(["companyId", "id", "name", "reportsTo", "status"]);
const normalized = agentRecordQueries(db).normalizeAgentRow(f.worker, captured.result);
expect(normalized).toMatchObject({ adapterConfig: f.worker.adapterConfig, metadata: f.worker.metadata,
orgChainHealth: { reason: "terminated_ancestor", firstInvalidAncestor: { id: f.managerId } } });
expect(await agentRecordQueries(db).listCompanyAgentEligibilityRows(randomUUID())).toEqual([]);
expect(await agentRecordQueries(db).getById(randomUUID())).toBeNull();
});
it("rechecks manager status, name, selected configuration, missing managers, and cycles on later reads", async () => {
const f = await fixture(), records = agentRecordQueries(db);
expect((await records.getById(f.workerId))?.orgChainHealth.reason).toBe("terminated_ancestor");
await db.update(agents).set({ status: "paused", name: "Current manager" }).where(eq(agents.id, f.managerId));
await db.update(agents).set({ adapterConfig: { instructions: "changed instructions" } }).where(eq(agents.id, f.workerId));
expect(await records.getById(f.workerId)).toMatchObject({ adapterConfig: { instructions: "changed instructions" },
orgChainHealth: { status: "healthy", pausedAncestors: [{ id: f.managerId, name: "Current manager", status: "paused" }] } });
await db.update(agents).set({ reportsTo: f.foreignId }).where(eq(agents.id, f.workerId));
expect((await records.getById(f.workerId))?.orgChainHealth.reason).toBe("missing_manager");
await db.update(agents).set({ reportsTo: f.managerId }).where(eq(agents.id, f.workerId));
await db.update(agents).set({ reportsTo: f.workerId }).where(eq(agents.id, f.managerId));
expect((await records.getById(f.workerId))?.orgChainHealth.reason).toBe("cycle");
});
});
+173 -1
View File
@@ -2,7 +2,8 @@ import { createAgentLifecycle } from "../services/agent-lifecycle.js";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
@@ -1034,6 +1035,177 @@ describeEmbeddedPostgres("attention service", () => {
expect(feed.items.filter((item) => item.sourceKind === "failed_run")).toEqual([]);
});
it("hides a failed run only after a newer run of the same agent on the same issue or task", async () => {
const { companyId, workerId, reviewerId } = await seedCompany("ATK");
const taskIssueId = await insertIssue({ companyId, identifier: "ATK-1", title: "Task-keyed", status: "in_progress" });
const issueKeyedId = await insertIssue({ companyId, identifier: "ATK-2", title: "Issue-keyed", status: "in_progress" });
const otherIssueId = await insertIssue({ companyId, identifier: "ATK-3", title: "Other work", status: "in_progress" });
const failedAt = new Date("2026-07-09T12:00:00.000Z");
const laterAt = new Date("2026-07-09T12:01:00.000Z");
const taskKeyedFailureId = randomUUID();
const issueKeyedFailureId = randomUUID();
const unkeyedHiddenFailureId = randomUUID();
const unkeyedKeptFailureId = randomUUID();
const failureIds = [taskKeyedFailureId, issueKeyedFailureId, unkeyedHiddenFailureId, unkeyedKeptFailureId];
const sameMillisecondRunId = randomUUID();
const run = (id: string, agentId: string, status: string, contextSnapshot: Record<string, unknown>, at: Date) => ({
id, companyId, agentId, invocationSource: "automation", status, contextSnapshot,
createdAt: at, updatedAt: at, finishedAt: at, ...(status === "failed" ? { error: "adapter failed" } : {}),
});
await db.insert(heartbeatRuns).values([
run(taskKeyedFailureId, workerId, "failed", { taskId: taskIssueId }, failedAt),
run(issueKeyedFailureId, workerId, "failed", { issueId: issueKeyedId }, failedAt),
run(unkeyedHiddenFailureId, workerId, "failed", {}, failedAt),
run(unkeyedKeptFailureId, reviewerId, "failed", {}, failedAt),
run(randomUUID(), workerId, "succeeded", { taskId: taskIssueId }, laterAt),
run(randomUUID(), workerId, "succeeded", { wakeReason: "timer" }, laterAt),
run(randomUUID(), reviewerId, "succeeded", { issueId: issueKeyedId }, laterAt),
run(randomUUID(), reviewerId, "succeeded", { issueId: otherIssueId }, laterAt),
run(randomUUID(), workerId, "succeeded", { issueId: otherIssueId }, laterAt),
run(randomUUID(), workerId, "succeeded", { issueId: otherIssueId, taskId: issueKeyedId }, laterAt),
run(randomUUID(), workerId, "succeeded", { issueId: "", taskId: issueKeyedId }, laterAt),
run(sameMillisecondRunId, workerId, "succeeded", { issueId: issueKeyedId }, failedAt),
run(randomUUID(), workerId, "succeeded", { issueId: issueKeyedId }, new Date("2026-07-09T11:59:00.000Z")),
]);
await db.execute(sql`update heartbeat_runs set created_at = created_at + interval '400 microseconds'
where id in (${sql.join(failureIds.map((id) => sql`${id}::uuid`), sql`, `)})`);
await db.execute(sql`update heartbeat_runs set created_at = created_at + interval '600 microseconds'
where id = ${sameMillisecondRunId}::uuid`);
await db.insert(heartbeatRunEvents).values(
failureIds.map((runId) => ({
companyId,
runId,
agentId: runId === unkeyedKeptFailureId ? reviewerId : workerId,
seq: 1,
eventType: "lifecycle",
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
createdAt: new Date("2026-07-09T12:00:01.000Z"),
})),
);
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.items.filter((item) => item.sourceKind === "failed_run").map((item) => item.subject.id).sort())
.toEqual([issueKeyedFailureId, unkeyedKeptFailureId].sort());
});
it("keeps newer-run response size bounded despite unrelated retained history", async () => {
const { companyId, workerId } = await seedCompany("ATW");
const issueId = await insertIssue({ companyId, identifier: "ATW-1", title: "Exhausted task", status: "in_progress" });
const otherIssueId = await insertIssue({ companyId, identifier: "ATW-2", title: "Other task", status: "done" });
const failedId = randomUUID(), failedAt = new Date("2026-07-09T12:00:00.000Z");
await db.insert(heartbeatRuns).values({
id: failedId, companyId, agentId: workerId, status: "failed", contextSnapshot: { issueId },
createdAt: failedAt, updatedAt: failedAt, finishedAt: failedAt,
});
await db.insert(heartbeatRunEvents).values({
companyId, agentId: workerId, runId: failedId, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after scheduled attempts",
});
for (let batch = 0; batch < 4; batch += 1) {
await db.insert(heartbeatRuns).values(Array.from({ length: 500 }, (_, index) => ({
id: randomUUID(), companyId, agentId: workerId, status: "succeeded",
contextSnapshot: { issueId: otherIssueId, prompt: "x".repeat(8_000) },
createdAt: new Date(failedAt.getTime() + batch * 500 + index + 1),
updatedAt: failedAt, finishedAt: failedAt,
})));
}
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
expect(feed.items.filter(item => item.sourceKind === "failed_run").map(item => item.subject.id)).toEqual([failedId]);
} finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "heartbeat_runs" && (event.operation === "select" || event.operation === "execute"));
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0);
expect(reads.reduce((sum, event) => sum + Number(event.rows), 0)).toBeLessThan(64);
expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(4096);
expect(reads.filter(event => event.operation === "execute")
.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0);
const [remaining] = await db.select({ count: sql<number>`count(*)::int` }).from(heartbeatRuns)
.where(eq(heartbeatRuns.companyId, companyId));
expect(remaining?.count).toBe(2001);
});
it("reads keyless history once per agent despite thousands of failures", async () => {
const { companyId, workerId, reviewerId } = await seedCompany("ATB");
const failedAt = new Date("2026-07-09T12:00:00.000Z");
const laterAt = new Date("2026-07-09T12:01:00.000Z");
const hiddenIds = Array.from({ length: 16_390 }, () => randomUUID());
const keptIds = Array.from({ length: 10 }, () => randomUUID());
const failure = (id: string, agentId: string) => ({
id, companyId, agentId, invocationSource: "automation", status: "failed", error: "adapter failed",
contextSnapshot: {}, createdAt: failedAt, updatedAt: failedAt, finishedAt: failedAt,
});
const failures = hiddenIds.map((id) => failure(id, workerId));
keptIds.forEach((id, index) => failures.splice(index * 1_700, 0, failure(id, reviewerId)));
for (let start = 0; start < failures.length; start += 1_000) {
const chunk = failures.slice(start, start + 1_000);
await db.insert(heartbeatRuns).values(chunk);
await db.insert(heartbeatRunEvents).values(chunk.map((run) => ({
companyId, runId: run.id, agentId: run.agentId, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
createdAt: new Date("2026-07-09T12:00:01.000Z"),
})));
}
await db.insert(heartbeatRuns).values({
id: randomUUID(), companyId, agentId: workerId, invocationSource: "automation", status: "succeeded",
contextSnapshot: { wakeReason: "timer" }, createdAt: laterAt, updatedAt: laterAt, finishedAt: laterAt,
});
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try {
const feed = await attentionService(db).list(companyId, {
includeDismissed: true, all: true, allowUnscopedAll: true,
});
expect(feed.items.filter((item) => item.sourceKind === "failed_run").map((item) => item.subject.id).sort())
.toEqual([...keptIds].sort());
} finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "heartbeat_runs" && (event.operation === "select" || event.operation === "execute"));
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0);
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeLessThan(4);
}, 180_000);
it("checks more failed runs for a newer run than one statement can bind", async () => {
const { companyId, workerId, reviewerId } = await seedCompany("ATQ");
const issueId = await insertIssue({ companyId, identifier: "ATQ-1", title: "Keyed retained failures", status: "in_progress" });
const failedAt = new Date("2026-07-09T12:00:00.000Z");
const laterAt = new Date("2026-07-09T12:01:00.000Z");
const hiddenIds = Array.from({ length: 16_390 }, () => randomUUID());
const keptIds = Array.from({ length: 10 }, () => randomUUID());
const failure = (id: string, agentId: string) => ({
id, companyId, agentId, invocationSource: "automation", status: "failed", error: "adapter failed",
contextSnapshot: { issueId }, createdAt: failedAt, updatedAt: failedAt, finishedAt: failedAt,
});
const failures = hiddenIds.map((id) => failure(id, workerId));
keptIds.forEach((id, index) => failures.splice(index * 1_700, 0, failure(id, reviewerId)));
for (let start = 0; start < failures.length; start += 1_000) {
const chunk = failures.slice(start, start + 1_000);
await db.insert(heartbeatRuns).values(chunk);
await db.insert(heartbeatRunEvents).values(chunk.map((run) => ({
companyId, runId: run.id, agentId: run.agentId, seq: 1, eventType: "lifecycle",
message: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued",
createdAt: new Date("2026-07-09T12:00:01.000Z"),
})));
}
await db.insert(heartbeatRuns).values({
id: randomUUID(), companyId, agentId: workerId, invocationSource: "automation", status: "succeeded",
contextSnapshot: { issueId }, createdAt: laterAt, updatedAt: laterAt, finishedAt: laterAt,
});
const feed = await attentionService(db).list(companyId, {
includeDismissed: true, all: true, allowUnscopedAll: true,
});
expect(feed.items.filter(item => item.sourceKind === "failed_run").map(item => item.subject.id).sort())
.toEqual([...keptIds].sort());
}, 180_000);
it("enriches interaction details with project, workspace, plan metadata, and images", async () => {
const { companyId, workerId } = await seedCompany("ATE");
const projectId = randomUUID();
@@ -5,6 +5,8 @@ import { createSystemRecovery } from "../services/system-recovery-state.js";
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
import { stageReceiptReactionRemovals } from "../services/chat-receipt-reactions.js";
import type { Db } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { githubResponseAuthorityFields } from "../services/chat-channels/github/response-authority-read.js";
import * as activityLogModule from "../services/activity-log.js";
import * as connectionCredentialBindings from "../services/connection-credential-bindings.js";
import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db";
@@ -24,7 +26,7 @@ import { toolActionRequests, toolInvocations, toolPolicies } from "@paperclipai/
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
import { secretService } from "../services/secrets.js";
import { githubChatWizardService } from "../services/chat-github-wizard.js";
import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js";
import { dispatchGitHubBotCloudEvent, registerGitHubBotCloudSetupPoller } from "../services/chat-github-cloud-ingress.js";
import { githubChatManagementService } from "../services/chat-github-management.js";
import { githubChatReviewService } from "../services/chat-github-reviews.js";
import { resolveGitHubReviewInstructions } from "../services/chat-channels/github/review-instructions.js";
@@ -2585,6 +2587,81 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.rejects.toMatchObject({ status: 403 });
});
it("bounds GitHub response authorization reads with large saved chat content", async () => {
const f = await toolReplyFixture();
const [delivery] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, f.endpoint.id));
const [resource] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.id, f.conversation.resourceId!));
const [configuration] = await db.select().from(chatGitHubConfigurations).where(eq(chatGitHubConfigurations.endpointId, f.endpoint.id));
const bulk = "unused-content-for-response-authorization".repeat(8_192);
const setup = { ...f.endpoint.setup, unusedContent: bulk };
await db.update(chatEndpoints).set({ communicationInstructions: bulk, setup }).where(eq(chatEndpoints.id, f.endpoint.id));
await db.update(chatConversations).set({ communicationGuidance: bulk }).where(eq(chatConversations.id, f.conversation.id));
await db.update(chatEndpointResources).set({ metadata: { ...resource.metadata, unusedContent: bulk } })
.where(eq(chatEndpointResources.id, resource.id));
await db.update(chatGitHubConfigurations).set({ configuration: { ...configuration.configuration,
defaults: { ...configuration.configuration.defaults, instructions: bulk } } })
.where(eq(chatGitHubConfigurations.endpointId, f.endpoint.id));
const normalizedEvent = { ...delivery.normalizedEvent, unusedContent: bulk };
await db.update(chatDeliveries).set({ normalizedEvent }).where(eq(chatDeliveries.id, delivery.id));
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatMessageLinks, chatDeliveries, chatEndpoints, chatConversations,
chatEndpointResources, chatGitHubConfigurations, chatExternalPrincipals, chatIdentityLinks,
companyMemberships, chatActions, heartbeatRuns }, capture);
try {
const receipt = await githubResponseCommentService(db, f.providerFetch).tryAcknowledge(delivery.id);
expect(receipt?.id).toBeTruthy();
} finally { capture.flush(); }
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(24_000);
expect(events.filter(event => event.table === "chat_message_links").length).toBeGreaterThan(0);
expect((await db.select({ normalizedEvent: chatDeliveries.normalizedEvent }).from(chatDeliveries)
.where(eq(chatDeliveries.id, delivery.id)))[0].normalizedEvent).toEqual(normalizedEvent);
expect((await db.select({ setup: chatEndpoints.setup }).from(chatEndpoints)
.where(eq(chatEndpoints.id, f.endpoint.id)))[0].setup).toEqual(setup);
});
it.each(["githubAutomatic", "githubIssue"])("preserves %s policy context while omitting body content", async key => {
const f = await toolReplyFixture();
const [delivery] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, f.endpoint.id));
const context = { event: key === "githubIssue" ? "issue_opened" : "opened", repositoryId: "418", repository: "acme/repo",
author: { id: "42", login: "octocat", isBot: false }, sender: { id: "42", login: "octocat" },
labels: ["review"], draft: false, baseBranch: "master", headSha: "a".repeat(40),
body: "unused-body".repeat(32_768), title: "unused-title".repeat(8_192) };
const normalizedEvent = { text: "unrelated message text", [key]: { context, revision: 42,
policy: { instructions: "unused-policy-instructions".repeat(32_768),
prompts: { opened: "unused-policy-prompt".repeat(32_768) } },
} };
await db.update(chatDeliveries).set({ normalizedEvent }).where(eq(chatDeliveries.id, delivery.id));
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { chatDeliveries }, capture);
let projected: { event: Record<string, unknown> };
try {
[projected] = await db.select({ event: githubResponseAuthorityFields.delivery.normalizedEvent })
.from(chatDeliveries).where(eq(chatDeliveries.id, delivery.id));
} finally { capture.flush(); }
const { body: _body, title: _title, ...policy } = context;
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(2_048);
expect(projected.event).toEqual({ [key]: { context: policy } });
expect((await db.select({ event: chatDeliveries.normalizedEvent }).from(chatDeliveries)
.where(eq(chatDeliveries.id, delivery.id)))[0].event).toEqual(normalizedEvent);
});
it.each([null, false, 7, "invalid-event", []])("preserves malformed GitHub authority input %j", async value => {
const f = await toolReplyFixture();
const [delivery] = await db.select().from(chatDeliveries).where(eq(chatDeliveries.endpointId, f.endpoint.id));
await db.update(chatDeliveries).set({ normalizedEvent: sql`${JSON.stringify(value)}::jsonb` })
.where(eq(chatDeliveries.id, delivery.id));
await db.update(chatEndpoints).set({ setup: sql`${JSON.stringify(value)}::jsonb` })
.where(eq(chatEndpoints.id, f.endpoint.id));
const [projected] = await db.select({ event: githubResponseAuthorityFields.delivery.normalizedEvent })
.from(chatDeliveries).where(eq(chatDeliveries.id, delivery.id));
const [endpoint] = await db.select({ setup: githubResponseAuthorityFields.endpoint.setup })
.from(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
expect(projected.event).toEqual(value);
expect(endpoint.setup).toEqual(value);
});
async function publicationRetryFixture() {
const f = await toolReplyFixture("github:paperclipai/paperclip:418", true);
const head = "b".repeat(40);
@@ -5135,13 +5212,22 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
(await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt,
).toBeNull();
expect(f.wakeup).not.toHaveBeenCalled();
event.payload.githubApp.headers["x-hub-signature-256"] =
`sha256=${createHmac("sha256", f.webhookSecret).update(body).digest("hex")}`;
expect(await dispatchGitHubBotCloudEvent(db, event)).toBe(true);
expect(
(await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt,
).toBeTruthy();
expect(f.wakeup).not.toHaveBeenCalled();
const poll = vi.fn(async () => { await dispatchGitHubBotCloudEvent(db, event); });
const unregister = registerGitHubBotCloudSetupPoller(db, poll);
try {
const waiting = await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
expect(waiting.state).toBe("verify");
expect(poll).toHaveBeenCalledOnce();
expect((await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt).toBeNull();
event.payload.githubApp.headers["x-hub-signature-256"] =
`sha256=${createHmac("sha256", f.webhookSecret).update(body).digest("hex")}`;
const ready = await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
expect(ready.state).not.toBe("verify");
expect((await f.service.get(f.endpoint.id)).setup.webhookVerifiedAt).toBeTruthy();
expect(f.wakeup).not.toHaveBeenCalled();
await f.service.githubWizard.advance(f.endpoint.id, "owner-user");
expect(poll).toHaveBeenCalledTimes(2);
} finally { unregister(); }
});
it("legacy name-only registration preserves an explicit empty repository selection", async () => {
const fixture = await seedCompany();
@@ -16,6 +16,7 @@ import {
projectWorkspaces,
} from "@paperclipai/db";
import { parseFrontmatterMarkdown } from "@paperclipai/shared";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -722,6 +723,73 @@ describeEmbeddedPostgres("companySkillService.list", () => {
expect(refreshed.filter((skill) => skill.key === key)).toHaveLength(1);
});
it("validates warm bundled releases without returning their stored file bodies", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Release read checks", issuePrefix: "RRC" });
await svc.list(companyId);
const expected = await svc.list(companyId);
const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))
.orderBy(companySkillVersions.id);
expect(before.length).toBeGreaterThan(0);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { companySkillVersions }, capture);
try { expect(await companySkillService(db).list(companyId)).toEqual(expected); }
finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "company_skill_versions" && event.operation === "select");
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0);
expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(4096);
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))
.orderBy(companySkillVersions.id)).toEqual(before);
});
it.each(["agent", "board"] as const)("checks %s company access before using a warm release validation", async type => {
const companyId = randomUUID(), otherCompanyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Release access checks", issuePrefix: "RAC" });
await svc.list(companyId);
await svc.list(companyId);
const actor = type === "agent"
? { type, companyId: otherCompanyId, agentId: randomUUID() }
: { type, companyIds: [otherCompanyId] };
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { companySkillVersions }, capture);
try { expect(await companySkillService(db, { actor }).list(companyId)).toEqual([]); }
finally { capture.flush(); }
expect(events.find(event => event.event === "paperclip.db_response_capture.summary.v1")?.completedQueries).toBe(0);
});
it("restores a deleted release after warm validation", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Release deletion checks", issuePrefix: "RDC" });
await svc.list(companyId);
await svc.list(companyId);
const before = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
const original = before[0]!;
await db.delete(companySkillVersions).where(eq(companySkillVersions.id, original.id));
await companySkillService(db).list(companyId);
const after = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
expect(after).toHaveLength(before.length);
expect(after.find(version => version.releaseId === original.releaseId)).toMatchObject({
companyId, companySkillId: original.companySkillId, fileInventory: original.fileInventory,
});
expect(after.some(version => version.id === original.id)).toBe(false);
});
it("rejects a changed bundled snapshot after warm validation without overwriting it", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Release integrity checks", issuePrefix: "RIC" });
await svc.list(companyId);
await svc.list(companyId);
const [version] = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId));
const changed = [{ path: "SKILL.md", kind: "skill" as const, content: "Changed stored snapshot" }];
await db.update(companySkillVersions).set({ fileInventory: changed }).where(eq(companySkillVersions.id, version!.id));
await expect(companySkillService(db).list(companyId)).rejects.toThrow("does not match its seeded snapshot");
expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.id, version!.id)))[0]!.fileInventory)
.toEqual(changed);
});
it("seeds bundled skill releases idempotently and materializes the frozen champion snapshot", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@@ -0,0 +1,105 @@
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { settleConversationTurnForRun } from "../services/agent-conversations.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("conversation settlement transfer", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-conversation-settlement-");
db = createDb(database.connectionString);
}, 30_000);
afterAll(async () => { await db?.$client.end({ timeout: 5 }); await database?.cleanup(); }, 60_000);
async function seed(reply: "response" | "interaction" | "reset" | "none" = "response") {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), wakeId = randomUUID();
const padding = "saved conversation ".repeat(32_768);
await db.insert(companies).values({ id: companyId, name: "Settlement transfer", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Conversation agent", role: "engineer" });
await db.insert(issues).values({ id: issueId, companyId, title: "Conversation", description: padding,
status: "in_progress", conversationState: "active", conversationUserId: "conversation-owner",
conversationAgentId: agentId, assigneeAgentId: agentId, conversationSessionGeneration: 3 });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "succeeded",
contextSnapshot: { issueId, conversationSessionGeneration: 3, wakeCommentId: wakeId,
conversationReset: reply === "reset", savedInput: padding },
runnerProfileJson: { savedProfile: padding }, resultJson: { savedResult: padding },
stdoutExcerpt: padding, stderrExcerpt: padding });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const wakeTime = new Date(Date.now() - 10_000);
await db.insert(issueComments).values({ id: wakeId, companyId, issueId, authorUserId: "conversation-owner",
body: padding, createdAt: wakeTime });
if (reply === "response") await db.insert(issueComments).values({ companyId, issueId,
createdByRunId: runId, authorAgentId: agentId, origin: "comment", body: padding });
if (reply === "interaction") await db.insert(issueThreadInteractions).values({ companyId, issueId,
sourceRunId: runId, createdByAgentId: agentId, status: "pending", kind: "request_confirmation", payload: { version: 1 } });
return { companyId, agentId, issueId, runId, wakeId, wakeTime };
}
async function sourceDigests(input: Awaited<ReturnType<typeof seed>>) {
return db.execute(sql`select
(select encode(sha256(convert_to(jsonb_build_array(context_snapshot,runner_profile_json,result_json,stdout_excerpt,stderr_excerpt)::text,'UTF8')),'hex')
from heartbeat_runs where id=${input.runId}::uuid) as run_digest,
(select encode(sha256(convert_to(description,'UTF8')),'hex') from issues where id=${input.issueId}::uuid) as description_digest,
(select encode(sha256(convert_to(jsonb_agg(jsonb_build_array(id,body) order by id)::text,'UTF8')),'hex')
from issue_comments where issue_id=${input.issueId}::uuid) as comments_digest`);
}
it.each(["response", "interaction", "reset"] as const)("bounds %s settlement reads and retains all saved text and run payloads", async reply => {
const input = await seed(reply), before = await sourceDigests(input);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns, issues, issueComments }, capture);
try { expect(await settleConversationTurnForRun(db, input.companyId, input.runId)).toBe(true); }
finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& ["heartbeat_runs", "issues", "issue_comments"].includes(String(event.table))
&& (event.operation === "select" || event.operation === "execute"));
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThanOrEqual(4);
expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(16_384);
const [issue] = await db.select({ status: issues.status, state: issues.conversationState }).from(issues).where(eq(issues.id, input.issueId));
expect(issue).toEqual({ status: "in_review", state: "waiting" });
expect(await sourceDigests(input)).toEqual(before);
});
it.each(["conversationReplyBoundaryCommentId", "wakeCommentId", "commentId"] as const)("keeps later user messages active through %s", async key => {
const input = await seed();
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: input.issueId,
conversationSessionGeneration: 3, [key]: input.wakeId } }).where(eq(heartbeatRuns.id, input.runId));
await db.insert(issueComments).values({ companyId: input.companyId, issueId: input.issueId,
authorUserId: "conversation-owner", body: "A new instruction", createdAt: new Date(input.wakeTime.getTime() + 1000) });
expect(await settleConversationTurnForRun(db, input.companyId, input.runId)).toBe(true);
const [issue] = await db.select({ status: issues.status, state: issues.conversationState }).from(issues).where(eq(issues.id, input.issueId));
expect(issue).toEqual({ status: "in_progress", state: "active" });
});
it.each(["missing", "company", "issue-company", "generation", "owner", "failed", "no-reply"] as const)("refuses to settle a %s mismatch", async fault => {
const input = await seed(fault === "no-reply" ? "none" : "response");
if (fault === "generation") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: input.issueId,
conversationSessionGeneration: 2, wakeCommentId: input.wakeId } }).where(eq(heartbeatRuns.id, input.runId));
if (fault === "owner") {
const otherRun = randomUUID();
await db.insert(heartbeatRuns).values({ id: otherRun, companyId: input.companyId, agentId: input.agentId });
await db.update(issues).set({ executionRunId: otherRun }).where(eq(issues.id, input.issueId));
}
if (fault === "failed") await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, input.runId));
if (fault === "issue-company") {
const otherCompany = randomUUID(), otherIssue = randomUUID(), otherAgent = randomUUID();
await db.insert(companies).values({ id: otherCompany, name: "Other company", issuePrefix: otherCompany.slice(0, 8) });
await db.insert(agents).values({ id: otherAgent, companyId: otherCompany, name: "Other agent" });
await db.insert(issues).values({ id: otherIssue, companyId: otherCompany, title: "Other conversation",
conversationUserId: "other-owner", conversationAgentId: otherAgent, assigneeAgentId: otherAgent,
conversationState: "active", conversationSessionGeneration: 3 });
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: otherIssue, conversationSessionGeneration: 3,
conversationReset: true } }).where(eq(heartbeatRuns.id, input.runId));
}
expect(await settleConversationTurnForRun(db, fault === "company" ? randomUUID() : input.companyId,
fault === "missing" ? randomUUID() : input.runId)).toBe(false);
const [issue] = await db.select({ status: issues.status }).from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
expect(issue.status).toBe("in_progress");
});
});
@@ -92,6 +92,7 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"],
listReadyPluginEnvironmentDrivers: vi.fn(async () => []),
resolvePluginSandboxProviderDriverByKey: vi.fn(async () => null),
resolvePluginSandboxProviderConfigSchema: vi.fn(async () => null),
validatePluginEnvironmentDriverConfig: vi.fn(async ({ config }) => config),
validatePluginSandboxProviderConfig: vi.fn(async ({ provider, config }) => ({
normalizedConfig: config,
@@ -79,6 +79,7 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"],
listReadyPluginEnvironmentDrivers: vi.fn(async () => []),
resolvePluginSandboxProviderDriverByKey: vi.fn(async () => null),
resolvePluginSandboxProviderConfigSchema: vi.fn(async () => null),
validatePluginEnvironmentDriverConfig: vi.fn(async ({ config }) => config),
validatePluginSandboxProviderConfig: vi.fn(async ({ provider, config }) => ({
normalizedConfig: config,
@@ -25,6 +25,8 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
probePluginEnvironmentDriver: mockProbePluginEnvironmentDriver,
probePluginSandboxProviderDriver: mockProbePluginSandboxProviderDriver,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
resolvePluginSandboxProviderConfigSchema: async (input: { db: unknown; driverKey: string }) =>
(await mockResolvePluginSandboxProviderDriverByKey(input))?.driver?.configSchema ?? null,
}));
vi.mock("../services/environment-runtime.js", () => ({
@@ -129,6 +129,8 @@ vi.mock("../services/plugin-environment-driver.js", () => ({
REUSABLE_LEASE_WORKER_METHODS: ["environmentResumeLease", "environmentReleaseLease", "environmentDestroyLease"],
listReadyPluginEnvironmentDrivers: mockListReadyPluginEnvironmentDrivers,
resolvePluginSandboxProviderDriverByKey: mockResolvePluginSandboxProviderDriverByKey,
resolvePluginSandboxProviderConfigSchema: async (input: { db: unknown; driverKey: string }) =>
(await mockResolvePluginSandboxProviderDriverByKey(input))?.driver?.configSchema ?? null,
startPluginEnvironmentInteractiveSetup: mockStartPluginEnvironmentInteractiveSetup,
getPluginEnvironmentInteractiveSetup: mockGetPluginEnvironmentInteractiveSetup,
capturePluginEnvironmentTemplate: mockCapturePluginEnvironmentTemplate,
@@ -9,6 +9,7 @@ import {
GIT_CREDENTIAL_TOKEN_ENV_KEY,
buildGitAuthInvocation,
createGitRemoteAuthProvider,
createIssueGitRemoteAuthProvider,
describeGitAuthFailure,
isGitHubHttpsRemoteUrl,
scrubGitCredentialText,
@@ -379,3 +380,39 @@ describe("DEFAULT_GITHUB_TOKEN_SECRET_NAMES", () => {
]);
});
});
describe("createIssueGitRemoteAuthProvider", () => {
const githubUrl = "https://github.com/paperclipai/private-repo.git";
function issueDb(rows: unknown[]) {
const where = vi.fn(async () => rows);
return { db: { select: vi.fn(() => ({ from: () => ({ where }) })) } as unknown as Db, where };
}
it("resolves credentials with the issue's responsible user and assigned agent", async () => {
const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: "agent-1" }]);
const inner = vi.fn(async () => ({ env: {}, configArgs: [], source: "managed_connection" }));
const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
await expect(provider(githubUrl)).resolves.toMatchObject({ source: "managed_connection" });
expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: "user-1", agentId: "agent-1" });
expect(inner).toHaveBeenCalledWith(githubUrl);
});
it("looks the issue up once across calls", async () => {
const { db } = issueDb([{ responsibleUserId: "user-1", assigneeAgentId: null }]);
const inner = vi.fn(async () => null);
const create = vi.fn(() => inner) as unknown as typeof createGitRemoteAuthProvider;
const provider = createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create);
await provider(githubUrl); await provider(githubUrl);
expect(db.select).toHaveBeenCalledTimes(1);
expect(create).toHaveBeenCalledTimes(1);
expect(inner).toHaveBeenCalledTimes(2);
});
it("falls back to the issue-only context when the issue is missing", async () => {
const { db } = issueDb([]);
const create = vi.fn(() => vi.fn(async () => null)) as unknown as typeof createGitRemoteAuthProvider;
await createIssueGitRemoteAuthProvider(db, "company-1", "issue-1", create)(githubUrl);
expect(create).toHaveBeenCalledWith(db, "company-1", { issueId: "issue-1", responsibleUserId: null, agentId: null });
});
});
@@ -7,6 +7,7 @@ import {
connectionGrants,
createDb,
externalObjects,
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
toolApplications,
toolConnections,
} from "@paperclipai/db";
@@ -30,7 +31,7 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-github-events-");
db = createDb(tempDb.connectionString);
}, 20_000);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(async () => {
await db.delete(activityLog);
@@ -46,7 +47,7 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
await tempDb?.cleanup();
});
it("applies a normalized merged PR once while retaining undecryptable siblings for retry", async () => {
it.each([false, true])("applies a normalized merged PR once while retaining undecryptable siblings for retry (active setup=%s)", async activeSetup => {
const companyId = randomUUID();
const userId = `github-owner-${randomUUID()}`;
const applicationId = randomUUID();
@@ -151,8 +152,21 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
let currentTime = new Date("2026-09-04T12:00:05.000Z");
const service = githubConnectionEventService(db, { connector, now: () => currentTime });
if (activeSetup) {
// An existing personal connection can leave a long empty-queue backoff
// before a new dedicated App starts its signed-ping verification.
for (const delay of [0, 10_000, 20_000, 40_000, 80_000]) {
currentTime = new Date(currentTime.getTime() + delay);
vi.mocked(connector.leaseEvents).mockResolvedValueOnce(null);
await service.pollOnce();
}
currentTime = new Date(currentTime.getTime() + 5_000);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 0 });
expect(connector.leaseEvents).toHaveBeenCalledTimes(5);
}
const warning = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 2, processed: 1, duplicate: 0, failed: 1 });
await expect(service.pollOnce({ forSetup: activeSetup })).resolves.toMatchObject({ leased: 2, processed: 1, duplicate: 0, failed: 1 });
expect(warning.mock.calls).toEqual([[{
deliveryId: "undecryptable-delivery", reason: "inner_event_authentication_failed",
}, "GitHub connector event retained for retry"]]);
@@ -6,6 +6,7 @@ import { createRuntimeToolsToken } from "../runtime-tools-token.js";
import { errorHandler } from "../middleware/index.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
@@ -31,6 +32,7 @@ import {
} from "./helpers/embedded-postgres.js";
import {
initializeRunIdentity,
captureRunIdentity,
reserveSteeredIdentity,
acceptSteeredIdentity,
} from "../services/run-identity.js";
@@ -192,6 +194,72 @@ const support = await getEmbeddedPostgresTestSupport();
});
return { id, connectionId, secretId, definitionId };
}
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
const input = await seed(), selected = await grant(input, "A");
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
await db.update(toolConnections).set({ config: {}, transportConfig: {}, [location]: { sourceTemplateKey: "github" } })
.where(eq(toolConnections.id, selected.connectionId));
const payload = "unrelated-connection-config🙂".repeat(10_000);
const irrelevant = [null, true, 42, ["github"], { name: "github" }, "GitHub", " github ", "other"].map(sourceTemplateKey => {
const id = randomUUID();
return { id, companyId: input.companyId, applicationId: connection.applicationId, name: id, uid: id,
transport: "mcp_remote" as const, config: { sourceTemplateKey, payload }, transportConfig: { sourceTemplateKey } };
});
await db.insert(toolConnections).values(irrelevant);
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { toolConnections }, capture);
try {
expect(await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" }))
.toMatchObject({ configured: true, grant: { id: selected.id } });
capture.flush();
const reads = events.filter(event => event.table === "tool_connections" && event.operation === "select");
expect(reads).toHaveLength(1);
expect(reads[0].rows).toBe(1);
expect(reads[0].responseValueBytes).toBeLessThan(8192);
await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, selected.connectionId));
expect((await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" })).grant)
.toBeUndefined();
await db.update(toolConnections).set({ [location]: { sourceTemplateKey: "other" } }).where(eq(toolConnections.id, selected.connectionId));
expect(await resolveManagedGitHubIdentitySelection(observed, input.companyId, { agentId: input.agentId, responsibleUserId: "A" }))
.toEqual({ configured: false });
expect(await resolveManagedGitHubIdentitySelection(observed, randomUUID(), { agentId: input.agentId, responsibleUserId: "A" }))
.toEqual({ configured: false });
const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, input.companyId));
for (const row of irrelevant) expect(saved.find(candidate => candidate.id === row.id)?.config).toEqual(row.config);
} finally { capture.flush(); await observed.$client.end(); }
});
it("resolves current GitHub credentials without transferring execution snapshots", async () => {
const input = await seed(), selected = await grant(input, "A");
const payload = "github-credential-saved-input🙂".repeat(25_000);
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: input.issueId, prompt: payload },
runnerProfileJson: { nativeExecutionInput: { savedInput: payload }, savedInput: payload },
resultJson: { stdout: payload }, stdoutExcerpt: payload, stderrExcerpt: payload,
}).where(eq(heartbeatRuns.id, input.runId));
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
expect(await resolveGitHubOperationCredentials(observed, input)).toMatchObject({
status: "available", login: "A", grantId: selected.id,
});
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192);
expect(JSON.stringify(events)).not.toContain("github-credential-saved-input");
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)))[0]).toEqual(before);
const full = await captureRunIdentity(observed, input);
expect(full.run.runnerProfileJson).toEqual(before.runnerProfileJson);
expect(full.run.resultJson).toEqual(before.resultJson);
expect(full.run.contextSnapshot).toEqual(before.contextSnapshot);
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.id));
expect(await resolveGitHubOperationCredentials(observed, input)).toMatchObject({ status: "unavailable" });
} finally { capture.flush(); await observed.$client.end(); }
});
async function switchTo(
input: Awaited<ReturnType<typeof seed>>,
user: string,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import {
agents,
companies,
@@ -15,6 +16,62 @@ import {
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
describe("P6-11..13 / P6-17 canonical event allocator", () => {
it("keeps native cursor responses bounded with long histories and a late gap fill", async () => {
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-cursor-budget-");
const db = createDb(temporary.connectionString);
const companyId = "20000000-0000-4000-8000-000000000001";
const agentId = "20000000-0000-4000-8000-000000000002";
const runId = "20000000-0000-4000-8000-000000000003";
const otherRunId = "20000000-0000-4000-8000-000000000004";
try {
await db.insert(companies).values({ id: companyId, name: "Cursor budget", issuePrefix: "CBG" });
await db.insert(agents).values({ id: agentId, companyId, name: "Cursor agent" });
await db.insert(heartbeatRuns).values([
{ id: runId, companyId, agentId, status: "running", nextEventSeq: 5005 },
{ id: otherRunId, companyId, agentId, status: "running" },
]);
for (let batch = 0; batch < 10; batch += 1) {
await db.insert(heartbeatRunEvents).values(Array.from({ length: 500 }, (_, index) => {
const seq = batch * 500 + index + 1;
return { companyId, agentId, runId, seq, eventType: "item.delta", sourceInstanceId: "runner-a",
sourceSeq: seq, sourceEventId: `seed:${seq}` };
}));
}
await db.insert(heartbeatRunEvents).values([
...[-1, 0, null].map((sourceSeq, index) => ({
companyId, agentId, runId, seq: 5001 + index, eventType: "item.delta",
sourceInstanceId: "runner-a", sourceSeq, sourceEventId: `legacy:${index}`,
})),
{ companyId, agentId, runId, seq: 5004, eventType: "item.delta", sourceInstanceId: "runner-b",
sourceSeq: 5001, sourceEventId: "other-source:5001" },
{ companyId, agentId, runId: otherRunId, seq: 1, eventType: "item.delta", sourceInstanceId: "runner-a",
sourceSeq: 5001, sourceEventId: "other-run:5001" },
]);
const append = (sourceSeq: number) => appendHeartbeatRunEvent(db, {
companyId, agentId, runId, eventType: "item.delta", payload: { sourceSeq },
nativeSource: { sourceInstanceId: "runner-a", sourceSeq, sourceEventId: `runner-a:${sourceSeq}`,
protocolSchemaVersion: 1, canonicalPayload: { sourceSeq } },
});
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRunEvents }, capture);
try { expect((await append(5002)).highestContiguousSourceSeq).toBe(5000); }
finally { capture.flush(); }
const reads = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "heartbeat_run_events" && (event.operation === "select" || event.operation === "execute"));
expect(reads.reduce((sum, event) => sum + Number(event.calls), 0)).toBeGreaterThan(0);
expect(reads.reduce((sum, event) => sum + Number(event.rows), 0)).toBeLessThan(16);
expect(reads.reduce((sum, event) => sum + Number(event.responseValueBytes), 0)).toBeLessThan(2048);
expect((await append(5001)).highestContiguousSourceSeq).toBe(5002);
const replay = await append(5002);
expect(replay.disposition).toBe("duplicate");
expect(replay.highestContiguousSourceSeq).toBe(5002);
const [remaining] = await db.select({ count: sql<number>`count(*)::int` }).from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(remaining?.count).toBe(5006);
} finally { await temporary.cleanup(); }
}, 60_000);
it.each([false, true])("deduplicates concurrent retry exhaustion (historical receipt: %s)", async (historical) => {
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-exhaustion-events-");
const db = createDb(temporary.connectionString);
@@ -165,4 +222,46 @@ describe("P6-11..13 / P6-17 canonical event allocator", () => {
await temporary.cleanup();
}
}, 60_000);
it("reports the highest contiguous source seq per source instance across gaps and late arrivals", async () => {
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-cursor-");
const db = createDb(temporary.connectionString);
const companyId = "20000000-0000-4000-8000-000000000001";
const agentId = "20000000-0000-4000-8000-000000000002";
const runId = "20000000-0000-4000-8000-000000000003";
try {
await db.insert(companies).values({ id: companyId, name: "Cursor fixture", issuePrefix: "CUR" });
await db.insert(agents).values({ id: agentId, companyId, name: "Cursor agent" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
const append = async (sourceInstanceId: string, sourceSeq: number) => (await appendHeartbeatRunEvent(db, {
companyId,
runId,
agentId,
eventType: "item.delta",
payload: { sourceInstanceId, sourceSeq },
nativeSource: {
sourceInstanceId,
sourceEventId: `${sourceInstanceId}:${sourceSeq}`,
sourceSeq,
protocolSchemaVersion: 1,
canonicalPayload: { sourceInstanceId, sourceSeq },
},
})).highestContiguousSourceSeq;
expect(await append("runner-a", 2)).toBe(0);
expect(await append("runner-a", 1)).toBe(2);
expect(await append("runner-a", 3)).toBe(3);
expect(await append("runner-a", 5)).toBe(3);
expect(await append("runner-a", 6)).toBe(3);
expect(await append("runner-b", 1)).toBe(1);
expect(await append("runner-a", 4)).toBe(6);
for (let sourceSeq = 7; sourceSeq <= 200; sourceSeq += 1) {
expect(await append("runner-a", sourceSeq)).toBe(sourceSeq);
}
expect(await append("runner-b", 3)).toBe(1);
expect(await append("runner-b", 2)).toBe(3);
} finally {
await temporary.cleanup();
}
}, 60_000);
});
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -257,6 +258,29 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
return app;
}
it("lists workspace operations with small run reads and checks a revoked issue grant again", async () => {
const f = await seedFixture(), payload = "workspace-operation-saved-input🙂".repeat(25_000);
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, prompt: payload },
runnerProfileJson: { instructions: payload }, stdoutExcerpt: payload, stderrExcerpt: payload,
}).where(eq(heartbeatRuns.id, f.privateRunId));
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.privateRunId));
const observed = createDb(tempDb!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
const app = createApp(f.companyId, f.grantedAgentId, observed);
const response = await request(app).get(`/api/heartbeat-runs/${f.privateRunId}/workspace-operations`).expect(200);
expect(response.body).toEqual([expect.objectContaining({ id: f.operationId })]);
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(2048);
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.subjectId, f.grantedAgentId));
await request(app).get(`/api/heartbeat-runs/${f.privateRunId}/workspace-operations`).expect(404);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.privateRunId)))[0]).toEqual(before);
} finally { capture.flush(); await observed.$client.end(); }
});
it("returns 404 for direct private-run content and metadata-only list stubs to a non-member", async () => {
const fixture = await seedFixture();
const app = createApp(fixture.companyId, fixture.otherAgentId);
@@ -459,6 +483,105 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
}
});
it("batches distinct issue bindings without changing current authorization decisions", async () => {
const fixture = await seedFixture();
const ids = Array.from({ length: 64 }, () => randomUUID());
await db.insert(issues).values(ids.map((id, index) => ({
id, companyId: fixture.companyId, title: `History ${index}`, status: "in_progress",
visibility: index % 2 ? "private" : "open", privacyRootIssueId: index % 2 ? id : null,
assigneeAgentId: fixture.ownerAgentId, responsibleUserId: fixture.ownerUserId,
})));
const queries: string[] = [];
const observedDb = drizzle(db.$client, { schema, logger: { logQuery(query) { queries.push(query); } } }) as typeof db;
const access = authorizationService(observedDb);
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.grantedAgentId };
const bindings = [...ids, fixture.issueId].map(issueId => ({ companyId: fixture.companyId, scopeKind: "issue" as const, issueId }));
const runs = [...bindings, ...bindings, { ...bindings[0], companyId: randomUUID() },
{ ...bindings[0], issueId: randomUUID() }, { companyId: fixture.companyId, scopeKind: "company" as const },
{ companyId: fixture.companyId, scopeKind: "issue" as const }];
const individual = await getHeartbeatRunReadDecisions(observedDb, { decide: access.decide }, actor, runs);
const individualCount = queries.length;
queries.length = 0;
const batched = await getHeartbeatRunReadDecisions(observedDb, access, actor, runs);
expect(batched).toEqual(individual);
expect(batched.slice(0, 64)).toEqual(ids.map((_, index) => index % 2 === 0));
expect(batched.at(64)).toBe(true);
expect(batched.slice(-4)).toEqual([false, false, true, false]);
expect(queries.length).toBeLessThan(25);
expect(queries.length).toBeLessThan(individualCount / 4);
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.subjectId, fixture.grantedAgentId));
expect((await getHeartbeatRunReadDecisions(observedDb, access, actor, runs)).at(64)).toBe(false);
});
it.each(["enforce", "shadow", "off"])("keeps missing and foreign issue bindings closed in %s mode", async mode => {
const fixture = await seedFixture();
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = mode;
try {
const access = authorizationService(db);
const actor = { type: "board" as const, userId: fixture.ownerUserId };
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
const runs = [binding, { ...binding, issueId: randomUUID() }, { ...binding, companyId: randomUUID() }];
const expected = await getHeartbeatRunReadDecisions(db, { decide: access.decide }, actor, runs);
expect(await getHeartbeatRunReadDecisions(db, access, actor, runs)).toEqual(expected);
expect(expected).toEqual([true, false, false]);
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, fixture.ownerUserId));
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual(
await getHeartbeatRunReadDecisions(db, { decide: access.decide }, actor, [binding]),
);
} finally { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; }
});
it("checks more than 500 task bindings with bounded company-scoped queries", async () => {
const fixture = await seedFixture();
const ids = Array.from({ length: 501 }, () => randomUUID());
await db.insert(issues).values(ids.map((id, index) => ({
id, companyId: fixture.companyId, title: `Batch ${index}`, visibility: "open", status: "in_progress",
})));
const queries: { query: string; params: unknown[] }[] = [];
const observedDb = drizzle(db.$client, { schema, logger: { logQuery(query, params) { queries.push({ query, params }); } } }) as typeof db;
const access = authorizationService(observedDb);
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
const runs = ids.map(issueId => ({ companyId: fixture.companyId, scopeKind: "issue" as const, issueId }));
expect(await getHeartbeatRunReadDecisions(observedDb, access, actor, runs)).toEqual(ids.map(() => true));
const existence = queries.filter(({ query }) => query.startsWith('select "id" from "issues"'));
expect(existence.map(({ params }) => params.length).sort((a, b) => a - b)).toEqual([2, 501]);
expect(existence.every(({ query, params }) => query.includes('"company_id"') && params[0] === fixture.companyId)).toBe(true);
});
it("captures batch inputs before awaiting reads and rejects a failed batch", async () => {
const fixture = await seedFixture();
const access = authorizationService(db);
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
const decideReads = vi.spyOn(access, "decideReads");
const pending = getHeartbeatRunReadDecisions(db, access, actor, [binding, binding]);
binding.issueId = randomUUID();
expect(await pending).toEqual([true, true]);
expect(decideReads).toHaveBeenCalledExactlyOnceWith(actor, [expect.objectContaining({ issueId: fixture.issueId })]);
const error = new Error("authorization unavailable");
decideReads.mockRejectedValueOnce(error);
const current = { ...binding, issueId: fixture.issueId };
await expect(getHeartbeatRunReadDecisions(db, access, actor, [current])).rejects.toBe(error);
const transient = Object.assign(new Error("temporary disconnect"), { code: "CONNECTION_CLOSED" });
decideReads.mockRejectedValueOnce(transient);
expect(await getHeartbeatRunReadDecisions(db, access, actor, [current])).toEqual([true]);
});
it("does no database or authorization work for company-only or empty batches", async () => {
const fixture = await seedFixture();
const queries: string[] = [];
const observedDb = drizzle(db.$client, { schema, logger: { logQuery(query) { queries.push(query); } } }) as typeof db;
const access = authorizationService(observedDb);
const decideReads = vi.spyOn(access, "decideReads");
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
expect(await getHeartbeatRunReadDecisions(observedDb, access, actor, [])).toEqual([]);
expect(await getHeartbeatRunReadDecisions(observedDb, access, actor, [
{ companyId: fixture.companyId, scopeKind: "company" }, { companyId: fixture.companyId, scopeKind: "issue" },
])).toEqual([true, false]);
expect(queries).toEqual([]);
expect(decideReads).not.toHaveBeenCalled();
});
it.each(["owner", "outsider"])("checks concurrent duplicate issue runs once for the %s", async (viewer) => {
const fixture = await seedFixture();
const access = authorizationService(db);
@@ -469,7 +592,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
agentId: viewer === "owner" ? fixture.ownerAgentId : fixture.otherAgentId,
};
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
const decisions = await getHeartbeatRunReadDecisions(db, access, actor, Array.from({ length: 200 }, () => ({ ...binding })));
const decisions = await getHeartbeatRunReadDecisions(db, { decide: access.decide }, actor, Array.from({ length: 200 }, () => ({ ...binding })));
expect(decisions).toEqual(Array(200).fill(viewer === "owner"));
expect(decide).toHaveBeenCalledTimes(1);
decide.mockRestore();
@@ -493,7 +616,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
const decide = vi.spyOn(access, "decide");
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
const pending = getHeartbeatRunReadDecisions(db, access, actor, [binding, binding]);
const pending = getHeartbeatRunReadDecisions(db, { decide: access.decide }, actor, [binding, binding]);
binding.issueId = randomUUID();
expect(await pending).toEqual([true, true]);
expect(decide).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
@@ -567,7 +690,7 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false },
const decide = vi.spyOn(access, "decide").mockRejectedValue(error);
const actor = { type: "agent" as const, companyId: fixture.companyId, agentId: fixture.ownerAgentId };
const binding = { companyId: fixture.companyId, scopeKind: "issue" as const, issueId: fixture.issueId };
await expect(getHeartbeatRunReadDecisions(db, access, actor, Array(20).fill(binding))).rejects.toBe(error);
await expect(getHeartbeatRunReadDecisions(db, { decide: access.decide }, actor, Array(20).fill(binding))).rejects.toBe(error);
expect(decide).toHaveBeenCalledTimes(1);
decide.mockRestore();
expect(await getHeartbeatRunReadDecisions(db, access, actor, [binding])).toEqual([true]);
@@ -25,10 +25,10 @@ describe("model-facing run errors", () => {
});
});
describe("scheduled continuation presentation", () => {
describe.each(["scheduled_monitor_waiting", "scheduled_monitor_dispatched", "scheduled_monitor_preserved"])("scheduled continuation presentation (%s)", finalizationReasonCode => {
const resultJson = {
finalizationPhase: "committed",
finalizationReasonCode: "scheduled_monitor_waiting",
finalizationReasonCode,
nativeResult: {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "yielded",
@@ -0,0 +1,109 @@
import { randomUUID } from "node:crypto";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { resolveIssueCreationSource, runBoundIssueReference } from "../services/issue-creation-source.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("creation source run projections", () => {
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID();
const sourceId = randomUUID(), secondId = randomUUID(), foreignId = randomUUID();
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let childNumber = 100;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("creation-source-read-");
db = createDb(database.connectionString);
await db.insert(companies).values([
{ id: companyId, name: "Creation source fixture", issuePrefix: "CSR" },
{ id: otherCompanyId, name: "Foreign source fixture", issuePrefix: "FSR" },
]);
await db.insert(agents).values({ id: agentId, companyId, name: "Source agent", adapterType: "process" });
await db.insert(issues).values([
{ id: sourceId, companyId, title: "Source task", identifier: "CSR-1", issueNumber: 1 },
{ id: secondId, companyId, title: "Second source task", identifier: "CSR-2", issueNumber: 2 },
{ id: foreignId, companyId: otherCompanyId, title: "Foreign task", identifier: "FSR-1", issueNumber: 1 },
]);
}, 60_000);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
async function fixture(values: Partial<typeof heartbeatRuns.$inferInsert>) {
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", ...values }).returning();
const number = childNumber++;
const [child] = await db.insert(issues).values({ companyId, title: "Created task", identifier: `CSR-${number}`,
issueNumber: number, originRunId: run.id }).returning();
return { run, child };
}
it.each(["scoped", "legacy task key", "legacy nested identifier"])("omits saved run payloads for %s bindings while preserving provenance and storage", async (binding) => {
const bulk = "retained-source-context🙂".repeat(32_768);
const contextSnapshot = binding === "scoped"
? { issueId: sourceId, paperclipIssue: { id: sourceId, retainedDescription: bulk }, retainedLaunchInput: bulk }
: binding === "legacy task key"
? { taskKey: "CSR-1", paperclipIssue: { retainedDescription: bulk }, retainedLaunchInput: bulk }
: { paperclipIssue: { id: "CSR-1", retainedDescription: bulk }, retainedLaunchInput: bulk };
const { run, child } = await fixture({ contextSnapshot,
runnerProfileJson: { retainedInput: bulk }, stdoutExcerpt: bulk, stderrExcerpt: bulk, resultJson: { retainedReceipt: bulk } });
expect(run.issueId).toBe(binding === "scoped" ? sourceId : null);
expect(run.nativeIssueId).toBeNull();
const observed = createDb(database.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns, issues }, capture);
try {
const result = await resolveIssueCreationSource(observed, child);
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.reduce((count, event) => count + Number(event.calls), 0)).toBe(1);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192);
expect(result).toMatchObject({ run: { id: run.id, companyId, agentId }, sourceIssue: { id: sourceId } });
expect(runBoundIssueReference(result!.run)).toBe(binding === "scoped" ? sourceId : "CSR-1");
for (const key of ["runnerProfileJson", "stdoutExcerpt", "stderrExcerpt", "resultJson"]) expect(result!.run).not.toHaveProperty(key);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run);
expect(JSON.stringify(events)).not.toContain("retained-source-context");
} finally {
capture.flush();
await observed.$client.end();
}
});
it.each([
{ label: "native identity", nativeIssueId: sourceId, issueId: secondId, contextSnapshot: { issueId: secondId }, expected: sourceId },
{ label: "issue scope", issueId: sourceId, contextSnapshot: { issueId: secondId }, expected: sourceId },
{ label: "legacy issue id", contextSnapshot: { issueId: sourceId, taskId: secondId }, expected: sourceId },
{ label: "typed task id fallback", contextSnapshot: { issueId: 42, taskId: sourceId }, expected: sourceId },
{ label: "nested issue identifier", contextSnapshot: { issueId: " ", paperclipIssue: { id: "CSR-1", ignored: "payload" }, taskKey: "CSR-2" }, expected: sourceId },
{ label: "typed nested id fallback", contextSnapshot: { paperclipIssue: { id: 42 }, taskKey: "CSR-1" }, expected: sourceId },
{ label: "foreign context", contextSnapshot: { taskId: foreignId }, expected: null },
{ label: "null context", contextSnapshot: null, expected: null },
])("preserves binding precedence for $label", async ({ label: _label, expected, ...values }) => {
const { child } = await fixture(values);
expect((await resolveIssueCreationSource(db, child))?.sourceIssue.id ?? null).toBe(expected);
});
it.each([42, "scalar", ["issueId", sourceId]])("does not invent a task binding from malformed context %j", async (context) => {
const { child } = await fixture({ contextSnapshot: context as unknown as Record<string, unknown> });
expect(await resolveIssueCreationSource(db, child)).toBeNull();
});
it("preserves historical activity lookup, current bindings, and missing or foreign source denial", async () => {
const { run, child } = await fixture({ contextSnapshot: { taskKey: "CSR-1" } });
await db.update(issues).set({ originRunId: null }).where(eq(issues.id, child.id));
await db.insert(activityLog).values({ companyId, actorType: "system", actorId: "fixture", action: "issue.created",
entityType: "issue", entityId: child.id, runId: run.id });
expect((await resolveIssueCreationSource(db, { ...child, originRunId: null }))?.sourceIssue.id).toBe(sourceId);
await db.update(heartbeatRuns).set({ contextSnapshot: { taskKey: "CSR-2" } }).where(eq(heartbeatRuns.id, run.id));
expect((await resolveIssueCreationSource(db, child))?.sourceIssue.id).toBe(secondId);
expect(await resolveIssueCreationSource(db, { ...child, companyId: otherCompanyId })).toBeNull();
expect(await resolveIssueCreationSource(db, { ...child, originRunId: randomUUID() })).toBeNull();
await db.update(heartbeatRuns).set({ contextSnapshot: { taskId: child.id } }).where(eq(heartbeatRuns.id, run.id));
expect(await resolveIssueCreationSource(db, child)).toBeNull();
});
});
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentRuntimeState,
@@ -79,7 +80,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
await tempDb?.cleanup();
});
function app(companyId: string, userId = "queue-owner", agentActor?: { agentId: string; runId: string }) {
function app(companyId: string, userId = "queue-owner", agentActor?: { agentId: string; runId: string }, database = db) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
@@ -94,7 +95,7 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
};
next();
});
testApp.use("/api", issueRoutes(db, {} as any, {}));
testApp.use("/api", issueRoutes(database, {} as any, {}));
testApp.use(errorHandler);
return testApp;
}
@@ -192,6 +193,104 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
return { companyId, agentId, issueId, runId, wakeId, commentIds };
}
it.each([false, true])("polls queued comments without transferring run snapshots (agent fallback: %s)", async fallback => {
const f = await seedQueue(), payload = "saved-execution-input🙂".repeat(25_000);
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, prompt: payload },
runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner", adapterConfig: { instructions: payload } } },
stdoutExcerpt: payload, stderrExcerpt: payload,
}).where(eq(heartbeatRuns.id, f.runId));
if (fallback) await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
const [before] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
const observed = createDb(tempDb!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { heartbeatRuns }, capture);
try {
const response = await request(app(f.companyId, "queue-owner", undefined, observed))
.get(`/api/issues/${f.issueId}/queued-comments`).expect(200);
expect(response.body.queueId).toBe(f.wakeId);
expect(response.body.targetRunId).toBe(f.runId);
expect(response.body.entries.map((entry: { comment: { id: string } }) => entry.comment.id)).toEqual(f.commentIds);
capture.flush();
const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(2048);
await request(app(randomUUID(), "queue-owner", undefined, observed))
.get(`/api/issues/${f.issueId}/queued-comments`).expect(404);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(before);
} finally { capture.flush(); await observed.$client.end(); }
});
it.each(["deferred", "queued"])("reads only queue display inputs from large %s wake payloads", async state => {
const f = await seedQueue();
if (state === "queued") await promoteQueue(f);
const client = app(f.companyId);
const path = `/api/issues/${f.issueId}/queued-comments`;
const expected = await request(client).get(path).expect(200);
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, f.wakeId));
const payload = "saved-wake-input🙂".repeat(30_000);
const context = wake.payload?._paperclipWakeContext as Record<string, unknown>;
await db.update(agentWakeupRequests).set({ payload: {
...wake.payload, prompt: payload,
_paperclipWakeContext: { ...context, prompt: payload, nativeExecutionInput: { instructions: payload } },
} }).where(eq(agentWakeupRequests.id, f.wakeId));
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, prompt: payload },
runnerProfileJson: { instructions: payload }, resultJson: { output: payload },
}).where(eq(heartbeatRuns.companyId, f.companyId));
const [before] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, f.wakeId));
const observed = createDb(tempDb!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { agentWakeupRequests, heartbeatRuns }, capture);
try {
const response = await request(app(f.companyId, "queue-owner", undefined, observed)).get(path).expect(200);
expect(response.body).toEqual(expected.body);
capture.flush();
const reads = events.filter(event => ["agent_wakeup_requests", "heartbeat_runs"].includes(String(event.table)) && event.operation === "select");
expect(reads.some(event => event.table === "agent_wakeup_requests")).toBe(true);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(4096);
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, f.wakeId)))[0]).toEqual(before);
await request(app(randomUUID(), "queue-owner", undefined, observed)).get(path).expect(404);
} finally { capture.flush(); await observed.$client.end(); }
});
it.each(["deferred", "queued"])("filters unrelated task wakes before reading the %s comment queue", async state => {
const f = await seedQueue();
if (state === "queued") await promoteQueue(f);
const path = `/api/issues/${f.issueId}/queued-comments`;
const expected = await request(app(f.companyId)).get(path).expect(200);
const unrelated = Array.from({ length: 80 }, (_, index) => ({
companyId: f.companyId, agentId: f.agentId, source: "issue_comment",
status: index % 2 ? "queued" : "deferred_issue_execution",
requestedAt: new Date("2020-01-01T00:00:00Z"),
payload: { issueId: randomUUID(), _paperclipWakeContext: {
wakeCommentIds: Array.from({ length: 60 }, () => randomUUID()),
} },
}));
const malformed = [null, [f.issueId], { id: f.issueId }, f.issueId.toUpperCase()];
await db.insert(agentWakeupRequests).values([
...unrelated,
...malformed.map(issueId => ({ companyId: f.companyId, agentId: f.agentId,
source: "issue_comment", status: "deferred_issue_execution",
requestedAt: new Date("2020-01-01T00:00:00Z"),
payload: { issueId, _paperclipWakeContext: { wakeCommentIds: f.commentIds } },
})),
]);
const observed = createDb(tempDb!.connectionString), events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(observed, { agentWakeupRequests }, capture);
try {
const response = await request(app(f.companyId, "queue-owner", undefined, observed)).get(path).expect(200);
expect(response.body).toEqual(expected.body);
capture.flush();
const reads = events.filter(event => event.table === "agent_wakeup_requests" && event.operation === "select");
expect(reads.length).toBeGreaterThan(0);
expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(4096);
expect(reads.reduce((rows, event) => rows + Number(event.rows), 0)).toBe(1);
expect(await db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests)
.where(eq(agentWakeupRequests.companyId, f.companyId))).toHaveLength(85);
await request(app(randomUUID(), "queue-owner", undefined, observed)).get(path).expect(404);
} finally { capture.flush(); await observed.$client.end(); }
});
it.each(["cancelled", "running"])("rejects a late Done from an interrupted %s task run at the write boundary", async status => {
const seeded = await seedQueue();
await db.update(heartbeatRuns).set({ status, resultJson: { executionCancellation: { state: "requested" } } })
@@ -14,6 +14,7 @@ const mockTreeControlService = vi.hoisted(() => ({
cancelIssueStatusesForHold: vi.fn(),
restoreIssueStatusesForHold: vi.fn(),
getHold: vi.fn(),
getActivePauseHoldGate: vi.fn(),
releaseHold: vi.fn(),
cancelUnclaimedWakeupsForTree: vi.fn(),
}));
@@ -72,6 +73,7 @@ describe("issue tree control routes", () => {
mockReplayBlocks.mockResolvedValue([]);
mockExecutionBlocker.mockResolvedValue(null);
mockTreeControlService.getHold.mockResolvedValue(null);
mockTreeControlService.getActivePauseHoldGate.mockResolvedValue(null);
mockIssueService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-2",
@@ -213,6 +215,36 @@ describe("issue tree control routes", () => {
},
);
it.each([
{ skipReason: "terminal_status", stillPaused: false },
{ skipReason: "already_held", stillPaused: false },
{ skipReason: "already_held", stillPaused: true },
])("rechecks skipped pause members with $skipReason and remaining pause=$stillPaused", async ({ skipReason, stillPaused }) => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
const childId = "44444444-4444-4444-8444-444444444444";
const root = { id: rootId, companyId: "company-2", status: "todo", assigneeAgentId: null };
const child = { id: childId, companyId: "company-2", status: "todo", assigneeAgentId: "current-agent" };
mockIssueService.getById.mockImplementation(async (id: string) => id === rootId ? root : child);
const hold = { id: holdId, rootIssueId: rootId, mode: "pause", members: [
{ issueId: childId, skipped: true, skipReason, issueStatus: "done" },
] };
mockTreeControlService.getHold.mockResolvedValue(hold);
mockTreeControlService.releaseHold.mockResolvedValue({ ...hold, status: "released" });
mockTreeControlService.getActivePauseHoldGate.mockResolvedValue(stillPaused ? { holdId: "remaining-hold" } : null);
mockReplayBlocks.mockResolvedValue([{ id: childId, identifier: "TEST-2" }]);
const app = await createApp({ type: "board", userId: "user-1", companyIds: ["company-2"], source: "session" });
const response = await request(app)
.post(`/api/issues/${rootId}/tree-holds/${holdId}/release`)
.send({ metadata: { wakeAgents: true } });
expect(response.status).toBe(200);
expect(mockExecutionBlocker).toHaveBeenCalledWith(expect.anything(), "company-2", childId);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(stillPaused ? 0 : 1);
if (!stillPaused) expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("current-agent", expect.objectContaining({
payload: expect.objectContaining({ issueId: childId }),
}));
});
it("resumes sandbox-backed work through the shared plugin worker manager", async () => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
@@ -284,14 +316,14 @@ describe("issue tree control routes", () => {
).toEqual([rootId, "child"]);
});
it("keeps the hold active when resume requests an unsafe execution replay", async () => {
it.each([false, true])("keeps the hold active when resume requests an unsafe execution replay (previously skipped=%s)", async (skipped) => {
const rootId = "11111111-1111-4111-8111-111111111111";
const holdId = "33333333-3333-4333-8333-333333333333";
mockTreeControlService.getHold.mockResolvedValue({
id: holdId,
rootIssueId: rootId,
mode: "pause",
members: [{ issueId: rootId }],
members: [{ issueId: rootId, skipped, skipReason: skipped ? "terminal_status" : null }],
});
mockReplayBlocks.mockResolvedValue([{ id: rootId, identifier: "TEST-1" }]);
mockExecutionBlocker.mockResolvedValue({ nextAction: "Resume without waking agents until the previous execution stops.",
@@ -6,6 +6,8 @@ import {
agents,
agentWakeupRequests,
companies,
applyPendingMigrations,
closeRegisteredClients,
createDb,
heartbeatRuns,
issueComments,
@@ -20,8 +22,10 @@ import {
} from "./helpers/embedded-postgres.js";
import { issueTreeControlService } from "../services/issue-tree-control.js";
import { issueService } from "../services/issues.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_ISSUE_TREE_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -35,6 +39,11 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (externalUrl) {
await applyPendingMigrations(externalUrl);
db = createDb(externalUrl);
return;
}
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-tree-control-");
db = createDb(tempDb.connectionString);
}, 20_000);
@@ -53,6 +62,7 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
});
afterAll(async () => {
if (externalUrl) await closeRegisteredClients(externalUrl);
await tempDb?.cleanup();
});
@@ -623,6 +633,58 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
});
it("previews only executable current assignees as resume wake targets", async () => {
const companyId = randomUUID();
const rootId = randomUUID();
const readyAgentId = randomUUID();
const parkedAgentId = randomUUID();
const previousAgentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId, name: "Resume preview", issuePrefix: "RESUME",
});
await db.insert(agents).values([readyAgentId, parkedAgentId, previousAgentId].map((id) => ({
id, companyId, name: id, role: "engineer", status: "idle", adapterType: "process",
})));
const children = ["todo", "in_progress", "in_review", "blocked", "backlog", "done"].map((status) => ({
id: randomUUID(), companyId, parentId: rootId, title: status, status,
assigneeAgentId: status === "blocked" || status === "backlog" ? parkedAgentId : readyAgentId,
}));
await db.insert(issues).values([
{ id: rootId, companyId, title: "Unassigned root", status: "todo" },
...children,
]);
const svc = issueTreeControlService(db);
const pause = await svc.createHold(companyId, rootId, {
mode: "pause",
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
});
expect(pause.hold.members?.find((member) => member.issueId === children[5].id)).toMatchObject({
skipped: true, skipReason: "terminal_status",
});
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, children[5].id));
// A previous executor is observable while stopping, but is not the current assignee to wake.
await db.insert(heartbeatRuns).values({
id: runId, companyId, agentId: previousAgentId, invocationSource: "assignment",
status: "running", contextSnapshot: { issueId: children[0].id },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, children[0].id));
const preview = await svc.preview(companyId, rootId, { mode: "resume" });
expect(preview.totals.affectedIssues).toBe(7);
expect(preview.affectedAgents).toEqual([{ agentId: readyAgentId, issueCount: 4, activeRunCount: 0 }]);
expect(preview.totals.affectedAgents).toBe(1);
expect(preview.warnings).toContainEqual({
code: "resume_tasks_remain_idle",
message: "Blocked, backlog, and unassigned tasks will be unpaused but will not start.",
issueIds: [rootId, children[3].id, children[4].id].sort(),
});
const rows = await db.select().from(issues).where(eq(issues.companyId, companyId));
expect(rows.find((row) => row.id === children[3].id)?.status).toBe("blocked");
expect(rows.find((row) => row.id === children[4].id)?.status).toBe("backlog");
expect((await svc.getHold(companyId, pause.hold.id))?.status).toBe("active");
});
it("resumes subtree pauses by releasing matching pause holds", async () => {
const companyId = randomUUID();
const rootIssueId = randomUUID();
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issueRecoveryActions, issues, projects } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { recordLegacyWorkspaceRestoreFailure, legacyExecutionNeedsReconciliationWithEvidence, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
@@ -8,6 +9,7 @@ import { hasConversationContinuationPolicy, conversationRecoveryActionPredicate
import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../services/legacy-workspace-restore-recovery.js";
import { hasConfirmedSandboxStopAndRetain, prepareSandboxStopAndRetain, settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js";
import { environmentService } from "../services/environments.js";
import { deliverExecutionStatuses } from "../services/execution-status-delivery.js";
import { hasRequiredWorkspaceRecovery } from "../services/workspace-restore-recovery-state.js";
const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim();
@@ -45,6 +47,127 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde
} };
const readLease = async (id: string) => (await db.select().from(environmentLeases).where(eq(environmentLeases.id, id)))[0];
it("locks only terminal control state while returning and retaining the complete run", async () => {
const f = await seed();
const bulk = randomBytes(128 * 1024).toString("base64");
const [saved] = await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.run.contextSnapshot, savedInput: bulk },
runnerProfileJson: { savedProfile: bulk }, stdoutExcerpt: bulk, stderrExcerpt: bulk,
}).where(eq(heartbeatRuns.id, f.runId)).returning();
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
let returned: Awaited<ReturnType<typeof terminalizeLegacyExecution>>;
try { returned = await terminalizeLegacyExecution({ db, run: saved!, status: "failed", patch }); }
finally { capture.flush(); }
const groups = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1");
const reads = groups.filter(group => group.table === "heartbeat_runs" && group.operation === "select");
expect(reads.reduce((sum, group) => sum + Number(group.calls), 0)).toBe(1);
expect(reads.reduce((sum, group) => sum + Number(group.responseValueBytes), 0)).toBeLessThan(4096);
expect(returned).toMatchObject({ id: f.runId, status: "failed", contextSnapshot: saved!.contextSnapshot,
runnerProfileJson: saved!.runnerProfileJson, stdoutExcerpt: bulk, stderrExcerpt: bulk });
expect(hasRequiredWorkspaceRecovery(returned!.resultJson)).toBe(true);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(returned);
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))
.toEqual([expect.objectContaining({ evidence: expect.objectContaining({ runId: f.runId }), ownerType: "board" })]);
expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
});
it.each(["legacy", "native"])("returns one complete run when retained workspace evidence is unchanged for %s", async (runtimeMode) => {
const f = await seed();
const bulk = randomBytes(128 * 1024).toString("base64");
const [saved] = await db.update(heartbeatRuns).set({ runtimeMode, status: "failed", finishedAt: new Date(),
contextSnapshot: { ...f.run.contextSnapshot, savedInput: bulk }, runnerProfileJson: { savedProfile: bulk },
stdoutExcerpt: bulk, stderrExcerpt: bulk,
}).where(eq(heartbeatRuns.id, f.runId)).returning();
const recordRestoreFailureOnly = runtimeMode === "native";
const failed = (await terminalizeLegacyExecution({ db, run: saved!, status: "failed", recordRestoreFailureOnly,
workspaceRestoreSource: f.lease,
patch: { resultJson: { workspaceRestoreFailure: "restore_failed", futureResult: bulk } },
}))!;
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
let returned: Awaited<ReturnType<typeof terminalizeLegacyExecution>>;
try { returned = await terminalizeLegacyExecution({ db, run: failed, status: "failed", recordRestoreFailureOnly,
workspaceRestoreSource: f.lease,
...(recordRestoreFailureOnly ? { patch: { resultJson: failed.resultJson } } : {}),
}); }
finally { capture.flush(); }
const writes = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "heartbeat_runs" && event.operation === "update");
expect(writes.reduce((sum, group) => sum + Number(group.rows), 0)).toBe(1);
expect(returned).toMatchObject({ id: f.runId, status: "failed", finishedAt: failed.finishedAt,
contextSnapshot: failed.contextSnapshot, runnerProfileJson: failed.runnerProfileJson, resultJson: failed.resultJson,
stdoutExcerpt: bulk, stderrExcerpt: bulk });
if (!recordRestoreFailureOnly) expect(returned!.executionStatusDeliveryId).toBe(failed.executionStatusDeliveryId);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0]).toEqual(returned);
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))
.toEqual([expect.objectContaining({ evidence: expect.objectContaining({ runId: f.runId }), ownerType: "board" })]);
expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
});
it("preserves pending and acknowledged status delivery on unpatched legacy recovery retries", async () => {
await deliverExecutionStatuses(db, { publish: vi.fn() });
const f = await seed();
const failed = (await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }))!;
const holds = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
expect(holds).toHaveLength(1);
expect(hasRequiredWorkspaceRecovery(failed.resultJson)).toBe(true);
const repeated = (await terminalizeLegacyExecution({ db, run: failed, status: "failed" }))!;
expect(repeated.executionStatusDeliveryId).toBe(failed.executionStatusDeliveryId);
const publish = vi.fn();
await deliverExecutionStatuses(db, { publish });
await terminalizeLegacyExecution({ db, run: failed, status: "failed" });
await deliverExecutionStatuses(db, { publish });
expect(publish.mock.calls.filter(([event]) => event.payload.runId === f.runId)).toHaveLength(1);
const after = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ id: holds[0].id, status: holds[0].status, outcome: holds[0].outcome,
ownerType: holds[0].ownerType, wakePolicy: null, evidence: { ...holds[0].evidence,
automaticRecovery: expect.objectContaining({ policy: "preserve_without_replay_v1", replay: "blocked",
runId: f.runId, actionOutcome: "unknown" }),
} });
expect(await readLease(f.lease.id)).toMatchObject({ status: "pending_cleanup", leasePolicy: "retain_on_failure" });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toEqual(failed.resultJson);
});
it("delivers new retained workspace evidence even when legacy run status is unchanged", async () => {
await deliverExecutionStatuses(db, { publish: vi.fn() });
const f = await seed();
const failed = (await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }))!;
const publish = vi.fn();
await deliverExecutionStatuses(db, { publish });
const [added] = await db.insert(environmentLeases).values({ ...f.lease, id: randomUUID(), providerLeaseId: randomUUID() }).returning();
const updated = (await terminalizeLegacyExecution({ db, run: failed, status: "failed" }))!;
expect(updated.executionStatusDeliveryId).toBeTruthy();
expect(updated.executionStatusDeliveryId).not.toBe(failed.executionStatusDeliveryId);
expect(updated.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: expect.arrayContaining([f.lease.id, added.id]) });
await deliverExecutionStatuses(db, { publish });
expect(publish.mock.calls.filter(([event]) => event.payload.runId === f.runId)).toHaveLength(2);
await terminalizeLegacyExecution({ db, run: updated, status: "failed" });
await deliverExecutionStatuses(db, { publish });
expect(publish.mock.calls.filter(([event]) => event.payload.runId === f.runId)).toHaveLength(2);
});
it("retains write conditions and delivers an explicit patch on unchanged legacy status", async () => {
await deliverExecutionStatuses(db, { publish: vi.fn() });
const f = await seed();
const failed = (await terminalizeLegacyExecution({ db, run: f.run, status: "failed", patch }))!;
const publish = vi.fn();
await deliverExecutionStatuses(db, { publish });
const holds = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
expect(await terminalizeLegacyExecution({ db, run: failed, status: "failed", writeConditions: [sql`false`] })).toBeNull();
await deliverExecutionStatuses(db, { publish });
expect(publish.mock.calls.filter(([event]) => event.payload.runId === f.runId)).toHaveLength(1);
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual(holds);
const patched = (await terminalizeLegacyExecution({ db, run: failed, status: "failed", patch: { error: "Retained workspace still needs repair" } }))!;
expect(patched.error).toBe("Retained workspace still needs repair");
await deliverExecutionStatuses(db, { publish });
expect(publish.mock.calls.filter(([event]) => event.payload.runId === f.runId)).toHaveLength(2);
});
async function stopAndRetain(f: Awaited<ReturnType<typeof seed>>, retry = false) {
let stopping = (await prepareSandboxStopAndRetain(db, f.lease))!;
expect(stopping.status).toBe("pending_cleanup");
@@ -58,9 +58,19 @@ describe("live-event run read boundaries", () => {
}).returning({ id: heartbeatRuns.id });
const [maintenance] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, scopeKind: "company" }).returning({ id: heartbeatRuns.id });
const [foreign] = await db.insert(heartbeatRuns).values({ companyId: foreignCompanyId, agentId: foreignAgent.id, scopeKind: "company" }).returning({ id: heartbeatRuns.id });
const hiddenIssueId = randomUUID();
await db.insert(issues).values({ id: hiddenIssueId, companyId, title: "Other private task", visibility: "private", privacyRootIssueId: hiddenIssueId, responsibleUserId: owner });
const [hiddenRun] = await db.insert(heartbeatRuns).values({ companyId, agentId: agent.id, scopeKind: "issue", issueId: hiddenIssueId }).returning({ id: heartbeatRuns.id });
const reads: string[] = [];
const queries: string[] = [];
let failNextRunRead = false;
let failNextIssueRead = false;
const observedDb = drizzle(db.$client, { schema, logger: { logQuery(query) {
queries.push(query);
if (query.startsWith("select ") && query.includes('from "issues"') && failNextIssueRead) {
failNextIssueRead = false;
throw new Error("fixture issue lookup unavailable");
}
if (query.startsWith("select ") && query.includes('from "heartbeat_runs"')) {
reads.push(query);
if (failNextRunRead) { failNextRunRead = false; throw new Error("fixture run lookup unavailable"); }
@@ -78,17 +88,32 @@ describe("live-event run read boundaries", () => {
socket.on("message", bytes => messages.push(JSON.parse(bytes.toString())));
try {
await once(socket, "open");
async function deliver(runId: string) {
let deliveries = 0;
async function deliver(runId: string, payloadIssueId?: string) {
deliveries += 1;
const start = messages.length;
const barrier = randomUUID();
publishLiveEvent({ companyId, type: "heartbeat.run.status", payload: { runId, status: "running" } });
publishLiveEvent({ companyId, type: "heartbeat.run.status", payload: {
runId, status: "running", ...(payloadIssueId ? { issueId: payloadIssueId } : {}),
} });
publishLiveEvent({ companyId, type: "agent.status", payload: { agentId: barrier, status: "idle" } });
await expect.poll(() => messages.some(message => message.payload.agentId === barrier)).toBe(true);
return messages.slice(start).filter(message => message.type === "heartbeat.run.status");
}
expect(await deliver(run.id)).toMatchObject([{ payload: { runId: run.id, status: "running" } }]);
const batchStart = queries.length;
expect(await deliver(run.id, issueId)).toMatchObject([{ payload: { runId: run.id, issueId, status: "running" } }]);
expect(queries.length - batchStart).toBeLessThanOrEqual(6);
expect(queries.slice(batchStart).filter(query => query.startsWith("select ") && query.includes('from "issues"')).length).toBeLessThanOrEqual(2);
expect(await deliver(hiddenRun.id, issueId)).toEqual([]);
expect(await deliver(run.id, hiddenIssueId)).toEqual([]);
expect(await deliver(run.id, randomUUID())).toEqual([]);
failNextIssueRead = true;
expect(await deliver(run.id, issueId)).toEqual([]);
expect(await deliver(run.id, issueId)).toHaveLength(1);
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
expect(await deliver(run.id, issueId)).toEqual([]);
expect(await deliver(run.id)).toEqual([]);
expect(await deliver(maintenance.id)).toHaveLength(1);
expect(await deliver(foreign.id)).toEqual([]);
@@ -101,7 +126,7 @@ describe("live-event run read boundaries", () => {
await db.delete(issues).where(eq(issues.id, issueId));
expect(await deliver(run.id)).toEqual([]);
expect(reads).toHaveLength(9);
expect(reads).toHaveLength(deliveries);
for (const query of reads) {
expect(query).toMatch(/^select "company_id", "scope_kind", "issue_id" from "heartbeat_runs"/);
expect(query).not.toMatch(/context_snapshot|runner_profile_json|stdout_excerpt|result_json/);
@@ -14,7 +14,10 @@ vi.mock("../realtime/task-toast-eligibility.js", () => ({ taskToastEligible: vi.
vi.mock("../services/authorization.js", () => ({
authorizationService: () => ({ decide: async ({ resource }: { resource: { issueId: string } }) => ({ allowed: resource.issueId !== "hidden" }) }),
}));
vi.mock("../services/heartbeat-run-privacy.js", () => ({ canActorReadHeartbeatRun: async () => false }));
vi.mock("../services/heartbeat-run-privacy.js", () => ({
getHeartbeatRunReadDecisions: async (_db: unknown, _access: unknown, _actor: unknown, runs: { issueId?: string }[]) =>
runs.map(run => run.issueId !== "hidden"),
}));
vi.mock("../services/case-access.js", () => ({
canActorReadCase: async (_db: unknown, _actor: unknown, record: { id: string }) => record.id !== "hidden-case",
}));
@@ -37,4 +37,15 @@ describe("native harness backup expiration config parsing", () => {
vi.stubEnv("PAPERCLIP_NATIVE_HARNESS_BACKUP_EXPIRATION_DAYS", "0");
expect(loadConfig()).toMatchObject({ nativeHarnessBackupExpirationDays: 1 });
});
it("keeps native runner evidence for seven days by default and at least one day", () => {
vi.stubEnv("PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS", undefined);
expect(loadConfig()).toMatchObject({ nativeRunnerEvidenceRetentionDays: 7 });
vi.stubEnv("PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS", "14");
expect(loadConfig()).toMatchObject({ nativeRunnerEvidenceRetentionDays: 14 });
vi.stubEnv("PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS", "0");
expect(loadConfig()).toMatchObject({ nativeRunnerEvidenceRetentionDays: 1 });
vi.stubEnv("PAPERCLIP_NATIVE_RUNNER_EVIDENCE_RETENTION_DAYS", "many");
expect(loadConfig()).toMatchObject({ nativeRunnerEvidenceRetentionDays: 7 });
});
});
@@ -301,4 +301,378 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
expect(await exists(leftover)).toBe(false);
expect(await exists(session.current)).toBe(true);
});
async function seedArchive(root: string, input: { stampedDaysAgo?: number; mtimeDaysAgo?: number; name?: string } = {}) {
const stampedAt = now.getTime() - (input.stampedDaysAgo ?? 10) * DAY_MS;
const archive = path.join(root, "continuity-breaks", input.name ?? `${stampedAt}-${randomUUID()}`);
await fs.mkdir(path.join(archive, "codex-home"), { recursive: true });
await fs.writeFile(path.join(archive, "codex-home", "history.jsonl"), "x".repeat(64 * 1024));
const mtime = new Date(now.getTime() - (input.mtimeDaysAgo ?? input.stampedDaysAgo ?? 10) * DAY_MS);
await fs.utimes(archive, mtime, mtime);
return archive;
}
async function seedQuarantine(
scope: string,
input: { stampedDaysAgo?: number; name?: string } = {},
) {
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
const stampedAt = now.getTime() - (input.stampedDaysAgo ?? 10) * DAY_MS;
const entry = path.join(
stateBase,
"quarantine",
input.name ?? `${scope}.identity_indeterminate.${stampedAt}.4242.${randomUUID()}`,
);
await fs.mkdir(path.join(entry, "control-plane"), { recursive: true });
await fs.writeFile(path.join(entry, "history.jsonl"), "x".repeat(64 * 1024));
return entry;
}
function evidenceService(overrides: Parameters<typeof nativeHarnessBackupExpirationService>[1] = {}) {
return nativeHarnessBackupExpirationService(db, {
stateBase,
now,
evidenceRetentionDays: 7,
isSessionBusy: () => false,
...overrides,
});
}
async function emptyStateBase() {
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
return createHash("sha256").update(randomUUID()).digest("hex");
}
it("removes continuity archives older than the retention window and keeps recent ones", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const old = await seedArchive(session.root, { stampedDaysAgo: 9 });
const recent = await seedArchive(session.root, { stampedDaysAgo: 2 });
const result = await evidenceService().tick();
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 1, failed: 0 });
expect(result.evidenceReclaimedBytes).toBeGreaterThan(0);
expect(result.skipped.recent_evidence).toBe(1);
expect(await exists(old)).toBe(false);
expect(await exists(recent)).toBe(true);
expect(await exists(session.current)).toBe(true);
});
it("removes the continuity-breaks folder once its archives are gone", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
await seedArchive(session.root);
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(path.join(session.root, "continuity-breaks"))).toBe(false);
});
it("ages a continuity archive by its name and falls back to mtime for a future stamp", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const stampedRecently = await seedArchive(session.root, { stampedDaysAgo: 1, mtimeDaysAgo: 30 });
const futureStamp = await seedArchive(session.root, {
name: `${now.getTime() + DAY_MS}-${randomUUID()}`,
mtimeDaysAgo: 30,
});
const futureRecent = await seedArchive(session.root, {
name: `${now.getTime() + 2 * DAY_MS}-${randomUUID()}`,
mtimeDaysAgo: 1,
});
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(stampedRecently)).toBe(true);
expect(await exists(futureStamp)).toBe(false);
expect(await exists(futureRecent)).toBe(true);
});
it("ignores folders that are not continuity archives", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const other = await seedArchive(session.root, { name: "notes", stampedDaysAgo: 30 });
expect(await evidenceService().tick()).toMatchObject({ evidenceCandidates: 0 });
expect(await exists(other)).toBe(true);
});
it("keeps the evidence of a session that is executing in this process", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const archive = await seedArchive(session.root);
const quarantine = await seedQuarantine(session.scope);
const result = await evidenceService({ isSessionBusy: (scope) => scope === session.scope }).tick();
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
expect(result.skipped.session_active).toBe(3);
expect(await exists(archive)).toBe(true);
expect(await exists(quarantine)).toBe(true);
});
it("keeps evidence when its session starts while the removal is being prepared", async () => {
const scope = await emptyStateBase();
const root = path.join(stateBase, scope);
const archive = await seedArchive(root);
let checks = 0;
const result = await evidenceService({ isSessionBusy: () => ++checks > 1 }).tick();
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 0 });
expect(result.skipped.session_active).toBe(1);
expect(await exists(archive)).toBe(true);
});
it("reports evidence without deleting it in dry run", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const archive = await seedArchive(session.root);
const quarantine = await seedQuarantine(session.scope);
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
await fs.mkdir(leftover);
const result = await evidenceService({ dryRun: true }).tick();
expect(result).toMatchObject({ evidenceCandidates: 2, evidenceRemoved: 0, evidenceReclaimedBytes: 0 });
expect(result.evidenceCandidateBytes).toBeGreaterThan(0);
expect(await exists(archive)).toBe(true);
expect(await exists(quarantine)).toBe(true);
expect(await exists(leftover)).toBe(true);
});
it("skips symlinked evidence without following it", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-evidence-outside-"));
try {
await fs.writeFile(path.join(outside, "keep.txt"), "keep");
const stampedAt = now.getTime() - 10 * DAY_MS;
await fs.mkdir(path.join(session.root, "continuity-breaks"), { recursive: true });
const archiveLink = path.join(session.root, "continuity-breaks", `${stampedAt}-${randomUUID()}`);
await fs.symlink(outside, archiveLink);
await fs.mkdir(path.join(stateBase, "quarantine"), { recursive: true });
const quarantineLink = path.join(
stateBase,
"quarantine",
`${session.scope}.identity_indeterminate.${stampedAt}.4242.${randomUUID()}`,
);
await fs.symlink(outside, quarantineLink);
const result = await evidenceService().tick();
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
expect(result.skipped.unsafe_path).toBe(2);
expect(await exists(archiveLink)).toBe(true);
expect(await exists(quarantineLink)).toBe(true);
expect(await exists(path.join(outside, "keep.txt"))).toBe(true);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
});
it("skips symlinked evidence folders without following them", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-evidence-outside-"));
try {
const stampedAt = now.getTime() - 10 * DAY_MS;
const inside = path.join(outside, `${stampedAt}-${randomUUID()}`);
await fs.mkdir(inside);
await fs.utimes(inside, new Date(stampedAt), new Date(stampedAt));
await fs.symlink(outside, path.join(session.root, "continuity-breaks"));
await fs.symlink(outside, path.join(stateBase, "quarantine"));
const result = await evidenceService().tick();
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
expect(result.skipped.unsafe_path).toBe(2);
expect(await exists(inside)).toBe(true);
} finally {
await fs.rm(outside, { recursive: true, force: true });
}
});
it("removes quarantine entries older than the retention window and keeps recent ones", async () => {
const scope = await emptyStateBase();
const old = await seedQuarantine(scope, { stampedDaysAgo: 9 });
const recent = await seedQuarantine(scope, { stampedDaysAgo: 2 });
const result = await evidenceService().tick();
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 1, failed: 0 });
expect(result.skipped.recent_evidence).toBe(1);
expect(await exists(old)).toBe(false);
expect(await exists(recent)).toBe(true);
expect(await fs.readdir(path.join(stateBase, "quarantine"))).toEqual([path.basename(recent)]);
});
it("keeps cleanup source archives in quarantine", async () => {
const scope = await emptyStateBase();
const entry = await seedQuarantine(scope, { name: `${scope}.identity_indeterminate.cleanup.${randomUUID()}` });
const old = new Date(now.getTime() - 30 * DAY_MS);
await fs.utimes(entry, old, old);
const result = await evidenceService().tick();
expect(result.evidenceCandidates).toBe(0);
expect(result.skipped.cleanup_archive).toBe(1);
expect(await exists(entry)).toBe(true);
});
it("finishes removing evidence left over from an interrupted removal", async () => {
const session = await seedSession({ issueStatus: "in_progress" });
const archive = await seedArchive(session.root, { stampedDaysAgo: 1 });
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
await fs.mkdir(path.join(leftover, "codex-home"), { recursive: true });
const quarantineLeftover = path.join(stateBase, "quarantine", `.expired-${randomUUID()}`);
await fs.mkdir(quarantineLeftover, { recursive: true });
await evidenceService().tick();
expect(await exists(leftover)).toBe(false);
expect(await exists(quarantineLeftover)).toBe(false);
expect(await exists(archive)).toBe(true);
});
it.each([false, true])("pages evidence removals across ticks (dry run %s)", async (dryRun) => {
const session = await seedSession({ issueStatus: "in_progress" });
const archives = [await seedArchive(session.root), await seedArchive(session.root), await seedArchive(session.root)];
const quarantine = await seedQuarantine(session.scope);
const retention = evidenceService({ dryRun, maxRemovals: 2 });
const results = [];
for (let i = 0; i < 4; i++) {
const result = await retention.tick();
results.push(result);
if (!result.truncated) break;
}
expect(results.map((result) => result.evidenceCandidates)).toEqual([2, 2]);
expect(results.map((result) => result.truncated)).toEqual([true, false]);
for (const archive of archives) expect(await exists(archive)).toBe(dryRun);
expect(await exists(quarantine)).toBe(dryRun);
});
it("does not recount a session's backup when its archives resume on the next tick", async () => {
const session = await seedSession();
await seedArchive(session.root);
await seedArchive(session.root);
const retention = evidenceService({ dryRun: true, maxRemovals: 2 });
const first = await retention.tick();
const second = await retention.tick();
expect(first).toMatchObject({ candidates: 1, evidenceCandidates: 1, truncated: true });
expect(second).toMatchObject({ candidates: 0, evidenceCandidates: 1, truncated: false });
});
it("removes old quarantine entries even when their session's issue is still open", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const entry = await seedQuarantine(session.scope);
await fs.cp(path.join(session.root, "control-plane"), path.join(entry, "control-plane"), { recursive: true });
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(entry)).toBe(false);
});
it("stays quiet until retained evidence is due and wakes when new evidence is written", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const recent = await seedArchive(session.root, { stampedDaysAgo: 2 });
const retention = evidenceService();
const at = (days: number) => ({ now: new Date(now.getTime() + days * DAY_MS) });
expect((await retention.tick()).skipped.recent_evidence).toBe(1);
expect((await retention.tick(at(4))).skipped.recent_evidence).toBeUndefined();
expect(await retention.tick(at(6))).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(recent)).toBe(false);
const written = await seedArchive(session.root, { stampedDaysAgo: 0 });
expect(await retention.tick(at(8))).toMatchObject({ evidenceCandidates: 0 });
retention.noteEvidence(now.getTime());
expect(await retention.tick(at(8))).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(written)).toBe(false);
});
it("retries evidence of a busy session on the next tick", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const archive = await seedArchive(session.root);
let busy = true;
const retention = evidenceService({ isSessionBusy: () => busy });
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 0 });
busy = false;
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(archive)).toBe(false);
});
it("reports dry-run evidence once until new evidence is written", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
await seedArchive(session.root);
const retention = evidenceService({ dryRun: true });
expect(await retention.tick()).toMatchObject({ evidenceCandidates: 1 });
expect(await retention.tick()).toMatchObject({ evidenceCandidates: 0 });
retention.noteEvidence(now.getTime());
expect(await retention.tick({ now: new Date(now.getTime() + 7 * DAY_MS) })).toMatchObject({ evidenceCandidates: 1 });
});
it("counts inspected archives and leftovers against the entry limit", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const archives = [
await seedArchive(session.root, { stampedDaysAgo: 1 }),
await seedArchive(session.root, { stampedDaysAgo: 2 }),
await seedArchive(session.root, { stampedDaysAgo: 3 }),
];
const leftovers = [randomUUID(), randomUUID()].map((id) => path.join(session.root, "continuity-breaks", `.expired-${id}`));
for (const leftover of leftovers) await fs.mkdir(leftover);
const retention = evidenceService({ maxEntries: 2 });
const first = await retention.tick();
expect(first).toMatchObject({ checked: 2, truncated: true });
expect(first.skipped.recent_evidence).toBeUndefined();
expect((await fs.readdir(path.join(session.root, "continuity-breaks"))).filter((name) => name.startsWith(".expired-"))).toHaveLength(1);
const results = [first];
for (let i = 0; i < 6 && results.at(-1)!.truncated; i++) results.push(await retention.tick());
expect(results.at(-1)!.truncated).toBe(false);
expect(results.every((result) => result.checked <= 2)).toBe(true);
expect(results.every((result) => (result.skipped.recent_evidence ?? 0) <= 2)).toBe(true);
expect(results.reduce((sum, result) => sum + (result.skipped.recent_evidence ?? 0), 0)).toBe(3);
for (const leftover of leftovers) expect(await exists(leftover)).toBe(false);
for (const archive of archives) expect(await exists(archive)).toBe(true);
});
it("stops removing leftovers once the sweep must stop", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
await fs.mkdir(path.join(session.root, "continuity-breaks"));
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
await fs.mkdir(leftover);
let calls = 0;
const retention = evidenceService({ shouldContinue: () => ++calls !== 2 });
await retention.tick();
expect(await exists(leftover)).toBe(true);
await retention.tick();
expect(await exists(leftover)).toBe(false);
});
it("keeps a due evidence pass pending when drain stops it before any session", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const archive = await seedArchive(session.root);
let draining = true;
const retention = evidenceService({ shouldContinue: () => !draining });
expect(await retention.tick()).toMatchObject({ checked: 0, truncated: true });
draining = false;
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 1 });
expect(await exists(archive)).toBe(false);
});
it("finishes one-entry batches without recounting a resumed session", async () => {
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
const archives = [await seedArchive(session.root), await seedArchive(session.root)];
const quarantine = await seedQuarantine(session.scope);
const retention = evidenceService({ maxEntries: 1 });
const results = [];
for (let i = 0; i < 8; i++) {
results.push(await retention.tick());
if (!results.at(-1)!.truncated) break;
}
expect(results.at(-1)!.truncated).toBe(false);
for (const archive of archives) expect(await exists(archive)).toBe(false);
expect(await exists(quarantine)).toBe(false);
});
});
@@ -1,5 +1,6 @@
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { spawn } from "node:child_process";
import os from "node:os";
import { and, eq } from "drizzle-orm";
@@ -207,6 +208,40 @@ suite("settled native run process identity", () => {
expect((await readRun(runId)).processPid).toBeNull();
});
it("releases the stopped identity without reading saved execution payloads", async () => {
const { runId } = await seed();
const bulk = randomBytes(128 * 1024).toString("base64");
const before = await readRun(runId);
await db.update(heartbeatRuns).set({ contextSnapshot: { ...before.contextSnapshot, savedTask: bulk },
runnerProfileJson: { ...before.runnerProfileJson, sessionCheckpoint: { providerSessionId: "thread", savedState: bulk },
nativeExecutionInput: { task: bulk } }, resultJson: { savedResult: bulk }, stdoutExcerpt: bulk,
}).where(eq(heartbeatRuns.id, runId));
const saved = await readRun(runId);
const events: Array<Record<string, unknown>> = [];
const now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { heartbeatRuns }, capture);
try { expect(await releaseSettledNativeRunProcess(db, runId)).toBe("released"); }
finally { capture.flush(); }
const summary = events.find(event => event.event === "paperclip.db_response_capture.summary.v1")!;
expect(summary.responseValueBytes).toBeLessThan(8192);
const after = await readRun(runId);
expect(after).toEqual({ ...saved, processPid: null, processGroupId: null, processStartedAt: null,
nextEventSeq: saved.nextEventSeq + 1, updatedAt: after.updatedAt });
expect(await hasNativeLocalProcessStop(db, companyId, runId)).toBe(true);
expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
});
it.each([null, false, 0, "", {}, []].map(checkpoint => ({ checkpoint })))(
"preserves checkpoint presence semantics when releasing an identity ($checkpoint)", async ({ checkpoint }) => {
const { runId, pid } = await seed({ checkpoint: false });
await db.update(heartbeatRuns).set({ runnerProfileJson: { sessionCheckpoint: checkpoint } })
.where(eq(heartbeatRuns.id, runId));
expect(await releaseSettledNativeRunProcess(db, runId)).toBe(checkpoint === null ? "skipped" : "released");
expect((await readRun(runId)).processPid).toBe(checkpoint === null ? pid : null);
},
);
it("keeps the identity while the process is still alive, then releases it after exit", async () => {
const child = startDetached();
children.push(child);
@@ -14,7 +14,7 @@ import { realizeExecutionWorkspace } from "../services/workspace-runtime.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { prepareProjectRepositoryWorkspaces } from "../services/heartbeat/workspaces.js";
import { expireReconstructableGitWorkspace } from "../services/git-workspace-expiration.js";
import { currentRestorePointSnapshot } from "../services/git-restore-point.js";
import { currentRestorePointSnapshot, restorePointHistoryDigest } from "../services/git-restore-point.js";
import { nestedRepositoryBaseCandidates, nestedWorktreeBranchPrefix, readLinkedNestedCheckout, withNestedRepositoryBaseLock } from "../services/nested-repository-worktrees.js";
import { listNestedCloneMigrationNames, migrateNestedRepositoryClone, nextNestedCloneMigrationBatch, type NestedCloneMigrationOptions } from "../services/nested-clone-migration.js";
import { getStorageService } from "../storage/index.js";
@@ -225,6 +225,41 @@ describeEmbeddedPostgres("nested repository clone migration", () => {
expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit");
});
it("expires a converted checkout whose only extra history is remote-tracking and restores that history", async () => {
const f = await fixture({ issueStatus: "done" });
git(f.nested.author, "checkout", "-q", "-b", "squashed-away");
await fs.writeFile(path.join(f.nested.author, "feature.txt"), "feature\n");
git(f.nested.author, "add", "."); git(f.nested.author, "commit", "-qm", "Feature");
const feature = git(f.nested.author, "rev-parse", "HEAD");
git(f.nested.author, "push", "-q", f.nested.remote, "squashed-away");
git(f.checkout, "fetch", "-q", "origin");
git(f.nested.remote, "branch", "-D", "squashed-away");
git(f.nested.remote, "reflog", "expire", "--expire=now", "--all");
git(f.nested.remote, "gc", "-q", "--prune=now");
expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
expect(await f.expire()).toMatchObject({ expired: true, restorePoint: true, skipReason: null });
for (const line of ownedRefs(f.base, f.prefix).split("\n").filter(Boolean)) git(f.base, "update-ref", "-d", line.split(" ")[0]!);
git(f.base, "reflog", "expire", "--expire=now", "--all");
git(f.base, "gc", "-q", "--prune=now");
expect(() => git(f.base, "cat-file", "-e", `${feature}^{commit}`)).toThrow();
expect(await f.reopen()).toMatchObject({ ok: true, reopened: true });
expect(git(f.checkout, "cat-file", "-t", feature)).toBe("commit");
});
it("fingerprints a converted checkout's own history rather than the shared clone's remote refs", async () => {
const f = await fixture();
expect(await f.migrate()).toMatchObject({ outcome: "migrated" });
const head = git(f.checkout, "rev-parse", "HEAD");
const before = await restorePointHistoryDigest(f.checkout);
git(f.base, "update-ref", "refs/remotes/origin/another-task", head);
expect(await restorePointHistoryDigest(f.checkout)).toBe(before);
git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/extra`, head);
expect(await restorePointHistoryDigest(f.checkout)).not.toBe(before);
for (let index = 0; index < 64; index++) git(f.base, "update-ref", `refs/heads/${f.prefix}remotes/origin/bulk-${index}`, head);
await expect(restorePointHistoryDigest(f.checkout)).rejects.toThrow("managed_checkout_local_tip_budget");
});
it("finishes an interrupted conversion after the workspace is archived and forgets one whose workspace is gone", async () => {
const f = await fixture();
const { unpushed } = await nestedWork(f.checkout);
@@ -21,6 +21,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-project-access.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"agent-profile-avatar.ts": "/api",
@@ -987,6 +988,21 @@ describe("openapi routes", () => {
});
});
it("documents additive project access as board-only with a strict request", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/agents/{id}/project-access"];
for (const operation of [path.get, path.post]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]);
}
expect(path.post.requestBody.content["application/json"].schema).toMatchObject({
additionalProperties: false, required: ["projectIds"],
properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } },
});
expect(path.post.responses["409"]).toBeDefined();
});
it("documents the authenticated personal primary-agent contract", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/primary-agent/me"];
+27 -8
View File
@@ -29,6 +29,23 @@ const support = await getEmbeddedPostgresTestSupport();
}
return { companyId, agentId, issueId, runId, messageIds };
}
it.each(["github_credentials", "request_authentication"] as const)("retains typed trust bindings for %s and keeps other identity reads complete", async (readScope) => {
const input = await seed();
await initializeRunIdentity(db, { ...input, responsibleUserId: "owner", cause: "queued" });
for (const value of [null, true, 42, " padded ", [], {}, { future: { opaque: true } }]) {
const contextSnapshot = { issueId: input.issueId, projectId: value, executionPolicy: value, savedInput: { retained: true } };
const runnerProfileJson = { nativeExecutionInput: { opaque: value }, savedInput: "retained" };
await db.update(heartbeatRuns).set({ contextSnapshot, runnerProfileJson }).where(eq(heartbeatRuns.id, input.runId));
const narrow = await captureRunIdentity(db, input, { readScope });
expect(narrow.run.contextSnapshot).toEqual({ issueId: input.issueId, taskId: null, projectId: value, executionPolicy: value });
expect(narrow.run.runnerProfileJson).toBeNull();
const full = await captureRunIdentity(db, input);
expect(full.run.contextSnapshot).toEqual(contextSnapshot);
expect(full.run.runnerProfileJson).toEqual(runnerProfileJson);
expect(narrow.context).toEqual(full.context);
}
});
it("retains mixed-author delivery order without rewriting task ownership", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, responsibleUserId: "owner", cause: "queued" });
@@ -106,24 +123,25 @@ const support = await getEmbeddedPostgresTestSupport();
},
);
it("holds acquisition during uncertain steering, preserves snapshots, and never rewinds on replay", async () => {
it.each([undefined, "github_credentials", "request_authentication"] as const)("holds acquisition during uncertain steering and never rewinds on replay with %s", async (readScope) => {
const options = readScope ? { readScope } : undefined;
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [input.messageIds[0]], responsibleUserId: "A", cause: "instruction" });
const startedUnderA = await captureRunIdentity(db, input);
const startedUnderA = await captureRunIdentity(db, input, options);
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
expect(b).not.toBeNull();
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await expect(captureRunIdentity(db, input, options)).rejects.toThrow(/reconciled/);
await expect(reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] })).rejects.toThrow(/reconciled/);
await db.transaction(async tx => {
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)).for("update");
await acceptSteeredIdentity(tx, b!);
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
expect((await captureRunIdentity(db, input, options)).context?.responsibleUserId).toBe("B");
expect(startedUnderA.context?.responsibleUserId).toBe("A");
const a = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] });
await acceptSteeredIdentity(db, a!);
await acceptSteeredIdentity(db, b!);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
expect((await captureRunIdentity(db, input, options)).context?.responsibleUserId).toBe("A");
});
it("keeps rejected steering unchanged and preserves the originating identity on retry", async () => {
const input = await seed();
@@ -158,16 +176,17 @@ const support = await getEmbeddedPostgresTestSupport();
expect(captured.run.responsibleUserId).toBeNull();
expect(captured.context?.parentContextId).toBe(origin.id);
});
it("recovers an uncertain acknowledgement from the authenticated native event journal", async () => {
it.each([undefined, "github_credentials", "request_authentication"] as const)("recovers an uncertain native acknowledgement with %s", async (readScope) => {
const options = readScope ? { readScope } : undefined;
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const pending = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await expect(captureRunIdentity(db, input, options)).rejects.toThrow(/reconciled/);
await db.insert(heartbeatRunEvents).values({ companyId: input.companyId, runId: input.runId,
agentId: input.agentId, seq: 1, eventType: "item.completed", sourceEventId: randomUUID(),
payload: { prpEvent: { turnId: "turn-1", itemId: `turn-1:steer:${pending!.messageId}`, payload: {kind: "steering_acknowledgement"} } },
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
expect((await captureRunIdentity(db, input, options)).context?.responsibleUserId).toBe("B");
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id, input.runId));
expect((await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] }))?.status).toBe("accepted");
});
@@ -0,0 +1,173 @@
import { randomBytes, randomUUID } from "node:crypto";
import { eq, sql, type SQL } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createDb, plugins, type Db } from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import type { PaperclipPluginManifestV1, PluginEnvironmentDriverDeclaration } from "@paperclipai/shared";
import { resolvePluginSandboxProviderConfigSchema, resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
import { resolveEnvironmentDriverConfigForRuntime, resolveSandboxProviderSecretRefPaths } from "../services/environment-config.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("sandbox provider schema reads", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
const clients: Db[] = [];
const key = "fixture-schema-provider";
const driver = (driverKey = key, configSchema: unknown = { type: "object" }) => ({
driverKey, kind: "sandbox_provider", displayName: "Schema fixture", configSchema,
}) as PluginEnvironmentDriverDeclaration;
const manifest = (id: string, declarations: PluginEnvironmentDriverDeclaration[]): PaperclipPluginManifestV1 => ({
id: `test.schema.${id}`, apiVersion: 1, version: "1.0.0", displayName: "Schema fixture",
description: "Fixture", author: "Test", categories: ["automation"],
capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" }, environmentDrivers: declarations,
});
async function seed(declarations = [driver()], installOrder = 0) {
const id = randomUUID();
await db.insert(plugins).values({
id, pluginKey: `test.schema.${id}`, packageName: "@test/schema", version: "1.0.0",
installOrder, status: "disabled", manifestJson: manifest(id, declarations),
});
return id;
}
function observer() {
const observed = createDb(temporary.connectionString);
clients.push(observed);
return observed;
}
const lookup = (client: Db, driverKey = key) => resolvePluginSandboxProviderConfigSchema({ db: client, driverKey });
async function measure<T>(client: Db, operation: () => Promise<T>) {
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event), () => now);
installDatabaseResponseCapture(client, { plugins }, capture);
try {
const value = await operation();
capture.flush();
const entries = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1");
return { value, bytes: entries.reduce((sum, entry) => sum + Number(entry.responseValueBytes), 0),
calls: entries.reduce((sum, entry) => sum + Number(entry.calls), 0) };
} finally { capture.flush(); }
}
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("sandbox-schema-reads-");
db = createDb(temporary.connectionString);
}, 120_000);
beforeEach(async () => { await db.delete(plugins); });
afterEach(async () => { await Promise.all(clients.splice(0).map(client => client.$client.end())); });
afterAll(async () => { await temporary?.cleanup(); });
it("keeps runtime secret paths current without retransferring an unchanged schema", async () => {
const schema = { type: "object", description: randomBytes(16 * 1024).toString("hex"), properties: {
credentials: { type: "object", properties: { token: { type: "string", format: "secret-ref" } } },
} };
const id = await seed([driver(key, schema)]), observed = observer();
const before = await db.select().from(plugins).where(eq(plugins.id, id));
const cold = await measure(observed, () => resolveSandboxProviderSecretRefPaths(observed, key));
const warm = await measure(observed, () => resolveSandboxProviderSecretRefPaths(observed, key));
expect(cold.value).toEqual(new Set(["credentials.token"]));
expect(warm.value).toEqual(cold.value);
expect(cold.calls).toBe(1);
expect(warm.calls).toBe(1);
expect(cold.bytes).toBeGreaterThan(32 * 1024);
expect(warm.bytes).toBeLessThan(256);
const changed = { type: "object", allOf: [{ properties: { apiKey: { type: "string", format: "secret-ref" } } }] };
await db.update(plugins).set({ manifestJson: manifest(id, [driver(key, changed)]) }).where(eq(plugins.id, id));
expect(await resolveSandboxProviderSecretRefPaths(observed, key)).toEqual(new Set(["apiKey"]));
const environment = { id: randomUUID(), driver: "sandbox" as const, config: { provider: key, apiKey: randomUUID() } };
await expect(resolveEnvironmentDriverConfigForRuntime(observed, null, environment)).rejects.toThrow("companyId");
await db.update(plugins).set({ manifestJson: before[0].manifestJson }).where(eq(plugins.id, id));
expect(await db.select().from(plugins).where(eq(plugins.id, id))).toEqual(before);
});
it("preserves install order, first matching declarations, and default readiness checks", async () => {
const a = await seed([driver(key, { title: "first" }), driver(key, { title: "second" })], 1);
const b = await seed([driver(key, { title: "other plugin" })], 2), observed = observer();
expect((await lookup(observed))).toEqual({ title: "first" });
const full = await resolvePluginSandboxProviderDriverByKey({ db: observed, driverKey: key });
expect(full?.plugin.id).toBe(a);
expect(full?.plugin.status).toBe("disabled");
expect(await resolvePluginSandboxProviderDriverByKey({ db: observed, driverKey: key, requireRunning: true })).toBeNull();
await db.update(plugins).set({ installOrder: 0 }).where(eq(plugins.id, b));
expect((await lookup(observed))).toEqual({ title: "other plugin" });
await db.delete(plugins).where(eq(plugins.id, b));
expect((await lookup(observed))).toEqual({ title: "first" });
await db.update(plugins).set({ manifestJson: manifest(a, [driver("replacement")]) }).where(eq(plugins.id, a));
expect(await lookup(observed)).toBeNull();
});
it.each([null, true, "invalid", [], undefined])("preserves non-object schema behavior: %s", async value => {
const first = driver(key, value);
if (value === undefined) delete (first as Partial<PluginEnvironmentDriverDeclaration>).configSchema;
await seed([first, driver(key, { properties: { secret: { format: "secret-ref" } } })]);
const observed = observer();
expect(await resolveSandboxProviderSecretRefPaths(observed, key)).toEqual(new Set());
expect(await resolveSandboxProviderSecretRefPaths(observed, key)).toEqual(new Set());
});
it("returns independent schema objects and keeps separate database clients cold", async () => {
await seed([driver(key, { description: "x".repeat(4096), properties: { token: { format: "secret-ref" } } })]);
const a = observer(), b = observer();
const first = await lookup(a);
first!.properties = {};
expect(await resolveSandboxProviderSecretRefPaths(a, key)).toEqual(new Set(["token"]));
const warm = await measure(a, () => lookup(a)), independent = await measure(b, () => lookup(b));
expect(warm.bytes).toBeLessThan(256);
expect(independent.bytes).toBeGreaterThan(4096);
warm.value!.properties = {};
expect(await resolveSandboxProviderSecretRefPaths(a, key)).toEqual(new Set(["token"]));
});
it("bounds retained schemas and does not retain oversized definitions", async () => {
const declarations = Array.from({ length: 33 }, (_, index) => driver(`provider-${index}`, { description: "x".repeat(2048) }));
declarations.push(driver("oversized", { description: "x".repeat(128 * 1024) }));
await seed(declarations);
const observed = observer();
for (let index = 0; index < 33; index++) await lookup(observed, `provider-${index}`);
expect((await measure(observed, () => lookup(observed, "provider-32"))).bytes).toBeLessThan(256);
expect((await measure(observed, () => lookup(observed, "provider-0"))).bytes).toBeGreaterThan(2048);
await lookup(observed, "oversized");
expect((await measure(observed, () => lookup(observed, "oversized"))).bytes).toBeGreaterThan(128 * 1024);
});
it("binds unusual keys and never serves retained schemas after a lookup error", async () => {
const unusual = "provider-\"quoted' OR true --";
await seed([driver(unusual, { title: "exact provider" })]);
const observed = observer();
let fail = false;
const guarded = { execute: async (query: SQL) => {
if (fail) throw new Error("fixture database unavailable");
return observed.execute(query);
} } as unknown as Db;
expect((await lookup(guarded, unusual))).toEqual({ title: "exact provider" });
fail = true;
await expect(lookup(guarded, unusual)).rejects.toThrow("fixture database unavailable");
fail = false;
expect((await lookup(guarded, unusual))).toEqual({ title: "exact provider" });
expect(await lookup(guarded, "unknown-provider")).toBeNull();
});
it("keeps each concurrent lookup tied to the database revision it observed", async () => {
const id = await seed([driver(key, { title: "old" })]), observed = observer();
let pause = false, release!: () => void, reached!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const paused = new Promise<void>(resolve => { reached = resolve; });
const raced = { execute: async (query: SQL) => {
const rows = await observed.execute(query);
if (pause) { pause = false; reached(); await gate; }
return rows;
} } as unknown as Db;
expect((await lookup(raced))).toEqual({ title: "old" });
pause = true;
const oldRead = lookup(raced);
await paused;
try {
await db.update(plugins).set({ manifestJson: manifest(id, [driver(key, { title: "new" })]) }).where(eq(plugins.id, id));
expect((await lookup(raced))).toEqual({ title: "new" });
} finally { release(); }
expect((await oldRead)).toEqual({ title: "old" });
expect((await lookup(raced))).toEqual({ title: "new" });
expect((await db.execute<{ title: string }>(sql`select ${plugins.manifestJson}->'environmentDrivers'->0->'configSchema'->>'title' as title from ${plugins} where ${plugins.id}=${id}`))[0].title).toBe("new");
});
});
+125
View File
@@ -0,0 +1,125 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
import { activityService } from "../services/activity.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
let fixture: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
beforeAll(async () => {
fixture = await startEmbeddedPostgresTestDatabase("task-cold-open-");
db = createDb(fixture.connectionString);
await db.insert(companies).values({ id: companyId, name: "Cold open", issuePrefix: `T${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Original instruction", description: "Read this first", identifier: `T${companyId.slice(0, 6)}-1`, issueNumber: 1 });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
}, 30_000);
afterAll(async () => { await fixture?.cleanup(); });
function app(allowedCompany = companyId) {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "board", userId: "local-board", source: allowedCompany === companyId ? "local_implicit" : "cloud_tenant", companyIds: [allowedCompany], isInstanceAdmin: allowedCompany === companyId };
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
it("returns the core instruction without related detail and keeps company checks", async () => {
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toMatchObject({ id: issueId, description: "Read this first" });
expect(response.body).not.toHaveProperty("ancestors");
expect(response.body).not.toHaveProperty("workProducts");
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
expect([403, 404]).toContain(denied.status);
});
it("returns a bounded recent exchange plus the latest human instruction", async () => {
const humanId = randomUUID();
await db.insert(issueComments).values([
{ id: humanId, companyId, issueId, body: "Human direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(0) },
...Array.from({ length: 20 }, (_, i) => ({ companyId, issueId, authorType: "agent" as const, authorAgentId: agentId, body: `Reply ${i}`, createdAt: new Date(1000 + i) })),
]);
const response = await request(app()).get(`/api/issues/${issueId}/recent-comments`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.hasMore).toBe(true);
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual(["Human direction", "Reply 19", "Reply 18", "Reply 17", "Reply 16"]);
expect(response.body.latestCommentId).toBe(response.body.comments[1].id);
expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${issueId}/recent-comments`)).status);
});
it.each(["user", null] as const)("preserves initial and latest human instructions without a description (authorType=%s)", async (authorType) => {
const bareIssueId = randomUUID();
await db.insert(issues).values({ id: bareIssueId, companyId, title: "No description" });
await db.insert(issueComments).values([
{ companyId, issueId: bareIssueId, body: "Human direction", authorType, authorUserId: "local-board", createdAt: new Date(0) },
{ companyId, issueId: bareIssueId, body: "Newest direction", authorType, authorUserId: "local-board", createdAt: new Date(50_000) },
...Array.from({ length: 6 }, (_, i) => ({ companyId, issueId: bareIssueId, body: `New reply ${i}`, authorType: "agent" as const, authorAgentId: agentId, createdAt: new Date(60_000 + i) })),
]);
const response = await request(app()).get(`/api/issues/${bareIssueId}/recent-comments`);
expect(response.status).toBe(200);
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual([
"Human direction", "Newest direction", "New reply 5", "New reply 4", "New reply 3", "New reply 2",
]);
});
it("loads the original input receipts outside the recent activity window and ignores replay receipts", async () => {
const commentId = randomUUID();
const receiptIssueId = randomUUID();
await db.insert(issues).values({ id: receiptIssueId, companyId, title: "Steering receipts" });
await db.insert(activityLog).values([
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(1), details: { commentId, targetRunId: runId } },
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(2), details: { commentId, targetRunId: runId, duplicate: true } },
...Array.from({ length: 51 }, () => ({ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.updated" })),
]);
const activity = activityService(db);
expect((await activity.forIssue(receiptIssueId, 50)).some(row => row.action === "issue.queued_comment_steered")).toBe(false);
const facts = await activity.inputFactsForIssue(companyId, receiptIssueId, [commentId]);
expect(facts).toHaveLength(1);
expect(facts[0].createdAt).toEqual(new Date(1));
expect(await activity.inputFactsForIssue(companyId, receiptIssueId, [randomUUID()])).toEqual([]);
expect(await activity.inputFactsForIssue(randomUUID(), receiptIssueId, [commentId])).toEqual([]);
expect(await activity.inputFactsForIssue(companyId, randomUUID(), [commentId])).toEqual([]);
});
it("pages conversation events without tools, including identity-less deltas and requests", async () => {
const event = (seq: number, eventType: string, payload: Record<string, unknown>, itemId?: string) => ({
companyId, runId, agentId, seq, eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType, itemId, payload } },
});
await db.insert(heartbeatRunEvents).values([
event(1, "item.started", { kind: "toolCall", arguments: "large tool arguments" }, "tool"),
event(2, "item.completed", { kind: "toolCall", output: "x".repeat(100_000) }, "tool"),
event(3, "item.started", { kind: "agentMessage" }, "message"),
event(4, "item.delta", { text: "Current progress" }, "message"),
event(5, "runtime_request.opened", { requestId: "request", prompt: "Which option?" }),
event(6, "runtime_request.resolved", { requestId: "request" }),
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
event(8, "turn.completed", {}),
]);
const service = createHeartbeatRunState(db);
const first = await service.listEvents(runId, 0, 2, true);
expect(first.map(e => e.seq)).toEqual([3, 4]);
const rest = await service.listEvents(runId, 4, 1000, true);
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]);
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
expect(await service.listEvents(runId)).toHaveLength(8);
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
const activity = activityService(db);
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
companyId, actorId: "test", action: "issue.updated", entityType: "issue", entityId: issueId,
})));
expect(await activity.forIssue(issueId, 50)).toHaveLength(50);
expect(await activity.forIssue(issueId)).toHaveLength(55);
expect(await activity.runsForIssue(companyId, issueId, null, 1)).toHaveLength(1);
expect(await activity.runsForIssue(companyId, issueId)).toHaveLength(2);
});
});
@@ -1,10 +1,11 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
agents, companies, createDb, toolApplications, toolCatalogEntries,
toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db,
} from "@paperclipai/db";
import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture";
import { toolAccessService } from "../services/tool-access.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
@@ -49,6 +50,11 @@ describeDatabase("effective tool assignments", () => {
expect(assignments).toEqual({ ...full, allowedTools: full.allowedTools.map(
({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }),
) });
const connections = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
expect(connections).toEqual({
installedConnections: full.installedConnections,
allowedConnectionIds: [...new Set(full.allowedTools.filter(tool => tool.connectionId === f.connection.id).map(tool => tool.connectionId))],
});
return { service, full, assignments };
}
@@ -130,4 +136,161 @@ describeDatabase("effective tool assignments", () => {
expect(observed[1].bytes).toBeLessThan(observed[0].bytes / 100);
expect(full.allowedTools[0].inputSchema).toEqual(f.read.inputSchema);
});
it("scopes catalog reads, omits rule payloads, and keeps current full definitions", async () => {
const f = await fixture();
const bulk = randomBytes(128 * 1024).toString("base64");
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
selectorType: "application", effect: "include", applicationId: f.application.id, conditions: { savedRuleMetadata: bulk } }).returning();
const [unrelated] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id,
name: "Other connection", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true }).returning();
await db.insert(toolCatalogEntries).values(Array.from({ length: 100 }, (_, index) => ({ companyId: f.company.id,
applicationId: f.application.id, connectionId: unrelated.id, name: `other_${index}`, toolName: `other_${index}`, versionHash: `v${index}` })));
const service = toolAccessService(db);
const before = await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id);
expect(before.allowedTools).toHaveLength(102);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { toolCatalogEntries, toolProfileEntries }, capture);
let scoped: Awaited<ReturnType<typeof service.getConnectionAssignmentsForAgent>>;
try { scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]); }
finally { capture.flush(); }
expect(Number(events.at(-1)?.completedQueries)).toBeGreaterThan(0);
expect(Number(events.at(-1)?.responseValueBytes)).toBeLessThan(16 * 1024);
const catalogQueries = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1"
&& event.table === "tool_catalog_entries" && event.operation === "select");
expect(catalogQueries).toHaveLength(1);
expect(catalogQueries[0]).toMatchObject({ calls: 1, rows: 2 });
expect(scoped!).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.installedConnections });
expect(JSON.stringify(events)).not.toContain(bulk.slice(0, 64));
expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id)))[0]).toEqual(rule);
expect(await service.getEffectiveToolAssignmentsForAgent(f.company.id, f.agent.id)).toEqual(before);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [])).allowedConnectionIds).toEqual([]);
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.profile.id));
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
});
it("omits unrelated rules and bindings while retaining current connection decisions", async () => {
const f = await fixture();
const [otherApplication] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Other app", type: "mcp_http" }).returning();
const [otherConnection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: otherApplication.id,
name: "Other connection", uid: randomUUID(), transport: "mcp_remote" }).returning();
const [otherTool] = await db.insert(toolCatalogEntries).values({ companyId: f.company.id, applicationId: otherApplication.id,
connectionId: otherConnection.id, name: "other", toolName: "other", versionHash: "other", riskLevel: "destructive" }).returning();
await db.insert(toolProfileEntries).values(Array.from({ length: 200 }, (_, index) => [
{ selectorType: "application" as const, applicationId: otherApplication.id },
{ selectorType: "connection" as const, connectionId: otherConnection.id },
{ selectorType: "catalog_entry" as const, catalogEntryId: otherTool.id },
{ selectorType: "tool_name" as const, toolName: `unrelated_${index}` },
{ selectorType: "risk_level" as const, riskLevel: "destructive" as const },
].map(entry => ({ companyId: f.company.id, profileId: f.profile.id, effect: index % 2 ? "include" as const : "exclude" as const, ...entry }))).flat());
await db.insert(toolProfileBindings).values(Array.from({ length: 200 }, () => ({ companyId: f.company.id,
profileId: f.profile.id, targetType: "agent" as const, targetId: randomUUID() })));
const [include, exclude] = await db.insert(toolProfileEntries).values([
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "connection", effect: "include", connectionId: f.connection.id },
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.write.id },
]).returning();
const service = toolAccessService(db), before = await compare(f);
const savedRules = await db.select().from(toolProfileEntries);
const events: Array<Record<string, unknown>> = [], now = Date.now();
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
installDatabaseResponseCapture(db, { toolProfileEntries, toolProfileBindings }, capture);
try {
const scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
capture.flush();
const rules = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1" && event.table === "tool_profile_entries");
const bindings = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1" && event.table === "tool_profile_bindings");
expect(rules).toHaveLength(1);
expect(Number(rules[0].responseValueBytes)).toBeLessThan(4096);
expect(rules[0]).toMatchObject({ calls: 1, rows: 2 });
expect(bindings).toHaveLength(1);
expect(bindings[0]).toMatchObject({ calls: 1, rows: 1 });
expect(scoped).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.full.installedConnections });
expect(await db.select().from(toolProfileEntries)).toEqual(savedRules);
await db.update(toolProfileEntries).set({ effect: "exclude" }).where(eq(toolProfileEntries.id, include.id));
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, include.id));
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, exclude.id)))[0]).toEqual(exclude);
} finally { capture.flush(); }
});
it("preserves null application matches and exact special-character tool names", async () => {
const f = await fixture(), toolName = "quoted' path\\tool_🙂";
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
await db.update(toolCatalogEntries).set({ applicationId: null, toolName }).where(eq(toolCatalogEntries.id, f.read.id));
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
selectorType: "application", applicationId: null, effect: "exclude" }).returning();
const service = toolAccessService(db);
expect((await compare(f)).assignments.allowedTools).toEqual([]);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
await db.update(toolProfileEntries).set({ selectorType: "tool_name", toolName }).where(eq(toolProfileEntries.id, rule.id));
expect((await compare(f)).assignments.allowedTools).toEqual([]);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
await db.update(toolProfileEntries).set({ toolName: `${toolName}different` }).where(eq(toolProfileEntries.id, rule.id));
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id]);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
});
it.each(["application", "connection", "catalog_entry", "tool_name", "risk_level"] as const)("denies the connection when a %s exclusion blocks its only active tool", async selectorType => {
const f = await fixture();
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, selectorType, effect: "exclude",
...(selectorType === "application" ? { applicationId: f.application.id } : {}),
...(selectorType === "connection" ? { connectionId: f.connection.id } : {}),
...(selectorType === "catalog_entry" ? { catalogEntryId: f.read.id } : {}),
...(selectorType === "tool_name" ? { toolName: f.read.toolName } : {}),
...(selectorType === "risk_level" ? { riskLevel: "read" as const } : {}),
}).returning();
const service = toolAccessService(db);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
expect((await compare(f)).assignments.allowedTools).toEqual([]);
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id));
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
});
it("filters rules against the catalog rows being evaluated when the catalog changes during the read", async () => {
const f = await fixture();
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
selectorType: "tool_name", toolName: f.read.toolName, effect: "exclude" });
let changed = false;
const changingDb = new Proxy(db, { get(target, key) {
if (key !== "select") return Reflect.get(target, key);
return (...fields: Parameters<Db["select"]>) => {
const builder = target.select(...fields), from = builder.from.bind(builder);
builder.from = ((...args: Parameters<typeof from>) => {
const selection = from(...args);
if (args[0] === toolCatalogEntries) {
const execute = selection.execute.bind(selection);
selection.execute = async () => {
const rows = await execute();
if (!changed) {
changed = true;
await db.update(toolCatalogEntries).set({ toolName: "changed-after-read" }).where(eq(toolCatalogEntries.id, f.read.id));
}
return rows;
};
}
return selection;
}) as typeof builder.from;
return builder;
};
} });
const service = toolAccessService(changingDb);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
expect(changed).toBe(true);
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
});
it("keeps other companies' catalog rows out of scoped assignments", async () => {
const f = await fixture();
const other = await fixture();
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
expect((await toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [other.connection.id])).allowedConnectionIds).toEqual([]);
await expect(toolAccessService(db).getConnectionAssignmentsForAgent(f.company.id, other.agent.id, [f.connection.id])).rejects.toThrow("same company");
});
});