mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
test: align workspace fixtures with retained source authorization
Seed active memberships for reused task workspaces, respect retained-source deletion order, and remove a provider-speed race from the E2E oracle while preserving deferred-preparation checks. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
449d66daa3
commit
4244cdb238
14 files changed
+90
-32
No files matched your search
@@ -246,7 +246,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/);
|
||||
});
|
||||
|
||||
it("authorizes workspace-derived projects before creating children of a projectless root", async () => {
|
||||
it("authorizes workspace sources without assigning projects to children of a projectless root", async () => {
|
||||
const f = await server.fixture({ disableWakeOnDemand: true });
|
||||
const settings = instanceSettingsService(server.db);
|
||||
const previous = await settings.getExperimental();
|
||||
@@ -287,7 +287,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
|
||||
const response = await create(path, selection);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
expect(response.body.projectId).toBe(f.projectId);
|
||||
expect(response.body.projectId).toBe(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null);
|
||||
expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
agentWakeupRequests,
|
||||
@@ -87,7 +89,7 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
testedAt: new Date(0).toISOString(),
|
||||
}),
|
||||
});
|
||||
}, 30_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterEach(() => {
|
||||
execute.mockClear();
|
||||
for (const unregister of unregisterAuthorities.splice(0)) unregister();
|
||||
@@ -111,6 +113,7 @@ describe("durable inbound chat scheduler receipts", () => {
|
||||
defaultResponsibleUserId: "board-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "board-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -217,6 +217,14 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
runningProcesses.clear();
|
||||
});
|
||||
|
||||
async function grantFixtureWorkspaceAccess(companyId: string, userIds: string[]) {
|
||||
// Later admissions reauthorize the retained workspace for the responsible
|
||||
// user, so synthetic human commenters need real company membership too.
|
||||
await db.insert(companyMemberships).values(userIds.map(principalId => ({
|
||||
companyId, principalId, principalType: "user", status: "active", membershipRole: "member",
|
||||
})));
|
||||
}
|
||||
|
||||
async function readGatewayWakePayload(
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
@@ -504,6 +512,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -1042,6 +1051,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -1252,6 +1262,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
@@ -1690,6 +1701,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "external-user"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -2390,6 +2402,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -2983,6 +2996,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "local-cli-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -3148,6 +3162,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user"]);
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
@@ -3313,6 +3328,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await grantFixtureWorkspaceAccess(companyId, ["responsible-user", "user-1"]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -93,7 +94,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-issue-liveness-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 30_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterEach(async () => {
|
||||
// Dependency reconciliation heals missing wakes by enqueuing an
|
||||
@@ -355,6 +356,7 @@ describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", ()
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
|
||||
@@ -19,7 +20,7 @@ suite("native terminal-run cleanup admission", () => {
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("native-cleanup-admission-");
|
||||
db = createDb(temporary.connectionString);
|
||||
}, 60_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
@@ -28,6 +29,7 @@ suite("native terminal-run cleanup admission", () => {
|
||||
it("holds the same task until its native executor settles while allowing unrelated tasks", async () => {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Cleanup admission", issuePrefix: "CLN", defaultResponsibleUserId: "owner" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "owner", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", status: "idle", adapterType: "process",
|
||||
adapterConfig: {}, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 20 } } });
|
||||
await db.insert(issues).values([issueId, otherIssueId].map(id => ({ id, companyId, title: "Recovery task", status: "todo", assigneeAgentId: agentId, responsibleUserId: "owner" })));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import * as conversationContinuation from "../services/conversation-continuation.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import * as workspaceRuntime from "../services/workspace-runtime.js";
|
||||
@@ -515,7 +516,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}, 20_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
@@ -682,6 +683,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecrets);
|
||||
try {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
break;
|
||||
} catch (error) {
|
||||
@@ -1176,6 +1178,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
@@ -1462,6 +1465,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
@@ -1611,7 +1615,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
it.each(["missing_default", "credential_not_shared", "database_error", "unmarked_http_error", "unmarked_forbidden", "provider_error"] as const)(
|
||||
"preserves AI configuration recovery and reports only unexpected causes: %s", async (cause) => {
|
||||
const { companyId, agentId, issueId, runId, wakeupRequestId } = await seedQueuedIssueRunFixture();
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" }).onConflictDoNothing();
|
||||
await db.update(agents).set({ runtimeConfig: {
|
||||
heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 },
|
||||
aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" },
|
||||
@@ -3378,9 +3382,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(input.workspace.cwd).toBe(
|
||||
path.join(
|
||||
await fs.realpath(resolvePaperclipInstanceRoot()),
|
||||
"chat-workspaces",
|
||||
"isolated-workspaces",
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
),
|
||||
);
|
||||
@@ -5803,7 +5806,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
});
|
||||
|
||||
it("blocks a git-sensitive local adapter before launch when a project-workspace-linked issue is missing its project id", async () => {
|
||||
it("blocks a projectless task before launch when its configured source directory is unavailable", async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
const { companyId, agentId, runId, issueId } =
|
||||
await seedQueuedIssueRunFixture();
|
||||
@@ -5853,7 +5856,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
errorCode: "workspace_validation_failed",
|
||||
});
|
||||
expect(failedRun?.error).toContain(
|
||||
"linked to a project workspace but has no project id",
|
||||
"Configured project workspace is unavailable",
|
||||
);
|
||||
// The adapter process never started, so no agent could post an issue
|
||||
// comment. The comment policy is not_applicable and no missing-comment
|
||||
@@ -5872,8 +5875,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(missingCommentWakeups).toHaveLength(0);
|
||||
expect(failedRun?.resultJson).toMatchObject({
|
||||
workspaceValidation: {
|
||||
reason: "missing_project_id",
|
||||
adapterType: "codex_local",
|
||||
reason: "configured_workspace_unavailable",
|
||||
issueId,
|
||||
issueProjectId: null,
|
||||
issueProjectWorkspaceId: projectWorkspaceId,
|
||||
@@ -6358,7 +6360,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
legacyDispositionEpisode: { id: runId, attempt: 1, maxAttempts: 2 },
|
||||
dispositionRepairInstruction: LEGACY_DISPOSITION_REPAIR_INSTRUCTION,
|
||||
});
|
||||
expect(repair?.status).toBe("succeeded");
|
||||
expect({ status: repair?.status, errorCode: repair?.errorCode, error: repair?.error })
|
||||
.toEqual({ status: "succeeded", errorCode: null, error: null });
|
||||
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("done");
|
||||
expect(mockAdapterExecute).toHaveBeenCalledTimes(2);
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, issueId))).not.toEqual(expect.arrayContaining([expect.objectContaining({body: SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY})]));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -97,7 +98,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
||||
}
|
||||
return baseExecute();
|
||||
});
|
||||
}, 20_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterEach(async () => {
|
||||
mockAdapterExecute.mockClear();
|
||||
@@ -299,6 +300,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const issueResponsibleUserId = `issue-owner-${randomUUID()}`;
|
||||
const commenterUserId = `commenter-${randomUUID()}`;
|
||||
await db.insert(companyMemberships).values([issueResponsibleUserId, commenterUserId].map(principalId => ({ companyId, principalType: "user" as const, principalId, status: "active", membershipRole: "member" })));
|
||||
const issueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
@@ -355,6 +357,7 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
|
||||
const { companyId, agentId } = await seedCompany();
|
||||
const issueResponsibleUserId = `issue-owner-${randomUUID()}`;
|
||||
const commenterUserId = `commenter-${randomUUID()}`;
|
||||
await db.insert(companyMemberships).values([issueResponsibleUserId, commenterUserId].map(principalId => ({ companyId, principalType: "user" as const, principalId, status: "active", membershipRole: "member" })));
|
||||
const issueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
|
||||
@@ -105,7 +105,8 @@ describePostgres("saved Slack invitation command in heartbeat prompts", () => {
|
||||
const f = await seed();
|
||||
for (let index = 0; index < 2; index++) {
|
||||
const input = await turn(f);
|
||||
expect(input.runtime.sessionId).toBe(index === 0 ? null : "invitation-session");
|
||||
const completed = await heartbeat.getRun(input.runId);
|
||||
expect(input.runtime.sessionId, JSON.stringify(completed?.resultJson?.configFreshness)).toBe(index === 0 ? null : "invitation-session");
|
||||
const context = input.context;
|
||||
for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownAssignmentCompact"]) {
|
||||
const prompt = String(context[key]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
createDb,
|
||||
closeRegisteredClients,
|
||||
@@ -342,6 +344,7 @@ async function seedBranchContainmentRun(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
@@ -880,7 +883,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-branch-containment-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}
|
||||
}, 20_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterEach(async () => {
|
||||
// Await every in-flight background heartbeat run to quiescence before the
|
||||
@@ -918,15 +921,16 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
await deleteHeartbeatRunsForCleanup(db);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(workspaceOperations);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(environments);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -972,6 +976,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
};
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
await db.insert(companies).values({ id: companyId, name: "Retained source", issuePrefix, status: "active", defaultResponsibleUserId: "responsible-user" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Repository replacement", status: "active",
|
||||
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", baseRef: "HEAD" } } });
|
||||
await db.insert(projectWorkspaces).values([
|
||||
@@ -1039,19 +1044,23 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
const run = await waitForRunToFinish(heartbeat, seeded.runId);
|
||||
await heartbeat.waitForRunExecutionDrain(seeded.runId);
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" });
|
||||
expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict",
|
||||
reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict"
|
||||
: kind === "unproven" ? "source_registration_unproven" : "source_scope_mismatch" } });
|
||||
if (kind === "foreign_company") {
|
||||
// Company access is checked before inspecting any retained source.
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "setup_failed", error: "Task workspace access is no longer available" });
|
||||
} else {
|
||||
expect(run).toMatchObject({ status: "failed", errorCode: "workspace_validation_failed" });
|
||||
expect(run?.resultJson).toMatchObject({ workspaceValidation: { reason: "persisted_workspace_source_conflict",
|
||||
reasonCode: kind === "explicit_conflict" ? "explicit_project_workspace_conflict" : "source_registration_unproven" } });
|
||||
}
|
||||
expect(adapterExecute).not.toHaveBeenCalled();
|
||||
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId));
|
||||
expect(workspace).toMatchObject({ projectWorkspaceId: null, cwd: seeded.worktree, providerRef: seeded.worktree, branchName: seeded.branch, metadata: seeded.metadata });
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
|
||||
expect(issue).toMatchObject({ executionWorkspaceId: seeded.executionWorkspaceId,
|
||||
projectWorkspaceId: kind === "explicit_conflict" ? seeded.newWorkspaceId : null });
|
||||
// Intentionally malformed cross-company/project FKs also prevent the
|
||||
// existing issue-update service from changing status. The source gate
|
||||
// still must fail before dispatch and may never silently repair that FK.
|
||||
// A different organizational project alone is legal; an unproven source
|
||||
// registration or a cross-company binding must still fail before dispatch
|
||||
// without silently replacing the retained files or binding.
|
||||
if (kind === "explicit_conflict" || kind === "unproven") expect(issue.status).toBe("blocked");
|
||||
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
|
||||
expect(await readFile(path.join(seeded.replacement, "replacement.txt"), "utf8")).toBe("replacement source must stay untouched\n");
|
||||
@@ -1073,6 +1082,7 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
|
||||
status: "active",
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySkills,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
@@ -166,6 +168,7 @@ async function seedRunTarget(db: Db, repoRoot: string) {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
@@ -265,7 +268,7 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () =>
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-finalize-branch-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterEach(async () => {
|
||||
// Await every in-flight background heartbeat run to quiescence before the
|
||||
@@ -299,14 +302,15 @@ describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () =>
|
||||
await deleteHeartbeatRowsAfterActivityLogDrains(db);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issues);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(environments);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
|
||||
@@ -2468,6 +2468,7 @@ describe("effective run session config freshness", () => {
|
||||
workspaceConfig: {
|
||||
requestedMode: "shared_workspace",
|
||||
effectiveMode: "shared_workspace",
|
||||
issueSettings: null,
|
||||
reusableExecutionWorkspaceConfig: null,
|
||||
existingExecutionWorkspace: null,
|
||||
},
|
||||
@@ -2476,6 +2477,7 @@ describe("effective run session config freshness", () => {
|
||||
workspaceConfig: {
|
||||
requestedMode: "shared_workspace",
|
||||
effectiveMode: "shared_workspace",
|
||||
issueSettings: { mode: "shared_workspace" },
|
||||
reusableExecutionWorkspaceConfig: {
|
||||
strategyType: "project_primary",
|
||||
workspaceGeneration: 1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companyMemberships,
|
||||
completionContracts,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
@@ -260,7 +262,7 @@ describe("P6-25 pre-result native session recovery", () => {
|
||||
leaseExpiresAt: new Date(0),
|
||||
})),
|
||||
]);
|
||||
}, 30_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => temporary?.cleanup());
|
||||
|
||||
@@ -795,6 +797,7 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
status: "active",
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "member" });
|
||||
await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
@@ -952,7 +955,7 @@ describe.each(["unchanged", "newer_active", "stale_idle"] as const)(
|
||||
authorUserId: "responsible-user",
|
||||
body: "Verify the newest user instruction before completing this task.",
|
||||
});
|
||||
}, 30_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
|
||||
afterAll(async () => {
|
||||
if (
|
||||
|
||||
@@ -37,9 +37,11 @@ export function gradeTaskWorkspaces(value: TaskWorkspaceObservation) {
|
||||
check("projectless-task-owned-root", complete && value.projectCount === 0 && value.checkpoints.every(p =>
|
||||
p.issue.projectId === null && p.workspace.projectId === null && p.confinedTaskRoot && p.issue.executionWorkspaceId === p.workspace.id),
|
||||
"Task and execution workspace remain projectless, with a confined company/task directory rather than agent home.");
|
||||
check("immutable-root-and-binding", complete && value.initial !== null && value.initial.repositories === 0 &&
|
||||
// The provider may enqueue its receipt before the first browser observation.
|
||||
// Deferred acquisition is checked at the settled first-turn checkpoint below.
|
||||
check("immutable-root-and-binding", complete && value.initial !== null &&
|
||||
value.checkpoints.every(p => p.workspace.id === value.initial!.workspaceId && p.workspace.cwd === value.initial!.cwd),
|
||||
"The independently observed pre-preparation root and binding survive all three admissions.");
|
||||
"The independently observed initial root and binding survive all three admissions.");
|
||||
check("task-file-continuity", complete && value.checkpoints.every(p => p.note === `task-files-turn-${p.turn}-${value.nonce}\n`),
|
||||
"Public execution-workspace file reads match the exact expected bytes after every turn.");
|
||||
const a = first?.repositories[0], b = second?.repositories[0], c = third?.repositories[0];
|
||||
|
||||
@@ -49,6 +49,13 @@ describe("task workspace product E2E", () => {
|
||||
legacy.checkpoints.forEach(point => { point.run.runtimeMode = "legacy"; delete point.prepareCalls; });
|
||||
expect(gradeTaskWorkspaces(legacy).filter(check => !check.passed)).toEqual([]);
|
||||
});
|
||||
it("accepts a pending receipt queued before the first browser observation", () => {
|
||||
const observation = valid();
|
||||
observation.initial!.repositories = 1;
|
||||
expect(gradeTaskWorkspaces(observation).filter(check => !check.passed)).toEqual([]);
|
||||
observation.checkpoints[0]!.repositories[0]!.state = "ready";
|
||||
expect(gradeTaskWorkspaces(observation).find(check => check.id === "deferred-idempotent-acquisition")?.passed).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
["missing evidence", (v: TaskWorkspaceObservation) => { v.checkpoints = []; }],
|
||||
["agent home", (v: TaskWorkspaceObservation) => { v.checkpoints[0]!.confinedTaskRoot = false; }],
|
||||
|
||||
Reference in new issue
Block a user