mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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"];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user