mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge public/master into pap-1239-server-test-isolation
This commit is contained in:
commit
da251e5eab
114 changed files with 22931 additions and 2057 deletions
|
|
@ -230,6 +230,80 @@ describe("agent permission routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agents`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/agent-hires`)
|
||||
.send({
|
||||
name: "Builder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
expect.objectContaining({
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 3600,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes explicit task assignment access on agent detail", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -369,6 +369,252 @@ describe("codex execute", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("renders execution-stage wake instructions for reviewer and executor roles", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-stage-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-stage-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_review_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_review_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "reviewer",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: null,
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.prompt).toContain("execution wake role: reviewer");
|
||||
expect(capture.prompt).toContain("You are waking as the active reviewer for this issue.");
|
||||
expect(capture.prompt).toContain("Do not execute the task itself or continue executor work.");
|
||||
expect(capture.prompt).toContain("allowed actions: approve, request_changes");
|
||||
|
||||
const executorCapturePath = path.join(root, "capture-executor.json");
|
||||
const executorResult = await execute({
|
||||
runId: "run-stage-wake-executor",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: executorCapturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "execution_changes_requested",
|
||||
paperclipWake: {
|
||||
reason: "execution_changes_requested",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1207",
|
||||
title: "implement the plan of PAP-1200",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
executionStage: {
|
||||
wakeRole: "executor",
|
||||
stageId: "stage-1",
|
||||
stageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(executorResult.exitCode).toBe(0);
|
||||
const executorCapture = JSON.parse(await fs.readFile(executorCapturePath, "utf8")) as CapturePayload;
|
||||
expect(executorCapture.prompt).toContain("execution wake role: executor");
|
||||
expect(executorCapture.prompt).toContain("You are waking because changes were requested in the execution workflow.");
|
||||
expect(executorCapture.prompt).toContain("allowed actions: address_changes, resubmit");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("renders an issue-scoped wake prompt even when the wake has no comments yet", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-issue-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-issue-wake",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Codex Coder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_assigned",
|
||||
paperclipWake: {
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
commentWindow: {
|
||||
requestedCount: 0,
|
||||
includedCount: 0,
|
||||
missingCount: 0,
|
||||
},
|
||||
truncated: false,
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||
expect(capture.prompt).toContain("- pending comments: 0/0");
|
||||
expect(capture.prompt).toContain("- issue status: todo");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
|
|
|
|||
|
|
@ -488,6 +488,23 @@ describe("heartbeat comment wake batching", () => {
|
|||
|
||||
expect(firstRun).not.toBeNull();
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||
const firstPayload = gateway.getAgentPayloads()[0] ?? {};
|
||||
expect(firstPayload.paperclip).toMatchObject({
|
||||
wake: {
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
},
|
||||
commentIds: [],
|
||||
},
|
||||
});
|
||||
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
|
|
|
|||
|
|
@ -272,6 +272,18 @@ describe("shouldResetTaskSessionForWake", () => {
|
|||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution review wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution approval wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("resets session context on execution changes-requested wakes", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves session context on timer heartbeats", () => {
|
||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
||||
});
|
||||
|
|
|
|||
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
joinRequests,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
|
||||
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
dismissalsSvc = inboxDismissalService(db);
|
||||
badgesSvc = sidebarBadgeService(db);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("upserts a single dismissal record per user and inbox item key", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
|
||||
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
|
||||
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
|
||||
|
||||
const dismissals = await dismissalsSvc.list(companyId, userId);
|
||||
|
||||
expect(dismissals).toHaveLength(1);
|
||||
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
|
||||
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
|
||||
});
|
||||
|
||||
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const primaryAgentId = randomUUID();
|
||||
const secondaryAgentId = randomUUID();
|
||||
const hiddenApprovalId = randomUUID();
|
||||
const resurfacedApprovalId = randomUUID();
|
||||
const inviteId = randomUUID();
|
||||
const hiddenJoinRequestId = randomUUID();
|
||||
const hiddenRunId = randomUUID();
|
||||
const visibleRunId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: primaryAgentId,
|
||||
companyId,
|
||||
name: "Primary",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: secondaryAgentId,
|
||||
companyId,
|
||||
name: "Secondary",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(approvals).values([
|
||||
{
|
||||
id: hiddenApprovalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: {},
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: resurfacedApprovalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "revision_requested",
|
||||
payload: {},
|
||||
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
companyId,
|
||||
inviteType: "company_join",
|
||||
tokenHash: "hash-1",
|
||||
allowedJoinTypes: "both",
|
||||
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
await db.insert(joinRequests).values({
|
||||
id: hiddenJoinRequestId,
|
||||
inviteId,
|
||||
companyId,
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
requestIp: "127.0.0.1",
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: hiddenRunId,
|
||||
companyId,
|
||||
agentId: primaryAgentId,
|
||||
invocationSource: "assignment",
|
||||
status: "failed",
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: visibleRunId,
|
||||
companyId,
|
||||
agentId: secondaryAgentId,
|
||||
invocationSource: "assignment",
|
||||
status: "timed_out",
|
||||
createdAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||
|
||||
const dismissedAtByKey = new Map(
|
||||
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
|
||||
dismissal.itemKey,
|
||||
new Date(dismissal.dismissedAt).getTime(),
|
||||
]),
|
||||
);
|
||||
|
||||
const badges = await badgesSvc.get(companyId, {
|
||||
dismissals: dismissedAtByKey,
|
||||
joinRequests: [{
|
||||
id: hiddenJoinRequestId,
|
||||
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||
}],
|
||||
unreadTouchedIssues: 1,
|
||||
});
|
||||
|
||||
expect(badges).toEqual({
|
||||
inbox: 3,
|
||||
approvals: 1,
|
||||
failedRuns: 1,
|
||||
joinRequests: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal file
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue() {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-580",
|
||||
title: "Activity event issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue activity event routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("logs blocker activity with added and removed issue summaries", async () => {
|
||||
const issue = makeIssue();
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.getRelationSummaries
|
||||
.mockResolvedValueOnce({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
blockedBy: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
});
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.blockers_updated",
|
||||
details: expect.objectContaining({
|
||||
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
|
||||
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
|
||||
addedBlockedByIssues: [
|
||||
{
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
identifier: "PAP-11",
|
||||
title: "New blocker",
|
||||
},
|
||||
],
|
||||
removedBlockedByIssues: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
identifier: "PAP-10",
|
||||
title: "Old blocker",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }],
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
type: "approval",
|
||||
participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const nextPolicy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }],
|
||||
},
|
||||
{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
type: "approval",
|
||||
participants: [{ type: "user", userId: "local-board" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue(),
|
||||
executionPolicy: existingPolicy,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
executionPolicy: patch.executionPolicy,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ executionPolicy: nextPolicy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.reviewers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.approvers_updated",
|
||||
details: expect.objectContaining({
|
||||
participants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -93,7 +93,7 @@ async function installActor(app: express.Express, actor?: Record<string, unknown
|
|||
import("../middleware/index.js"),
|
||||
]);
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
|
|
@ -176,6 +176,10 @@ describe("issue comment reopen routes", () => {
|
|||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||
|
|
@ -343,4 +347,146 @@ describe("issue comment reopen routes", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
expect.objectContaining({
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
executionState: expect.objectContaining({
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: expect.objectContaining({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
}),
|
||||
returnAssignee: expect.objectContaining({
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"33333333-3333-4333-8333-333333333333",
|
||||
expect.objectContaining({
|
||||
reason: "execution_review_requested",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
executionStage: expect.objectContaining({
|
||||
wakeRole: "reviewer",
|
||||
stageType: "review",
|
||||
allowedActions: ["approve", "request_changes"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the return assignee with execution_changes_requested", async () => {
|
||||
const policy = await normalizePolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
...makeIssue("todo"),
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(
|
||||
await installActor(createApp(), {
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-2",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({
|
||||
status: "in_progress",
|
||||
comment: "Needs another pass",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
"22222222-2222-4222-8222-222222222222",
|
||||
expect.objectContaining({
|
||||
reason: "execution_changes_requested",
|
||||
payload: expect.objectContaining({
|
||||
issueId: "11111111-1111-4111-8111-111111111111",
|
||||
executionStage: expect.objectContaining({
|
||||
wakeRole: "executor",
|
||||
stageType: "review",
|
||||
lastDecisionOutcome: "changes_requested",
|
||||
allowedActions: ["address_changes", "resubmit"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal file
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue execution policy routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-999",
|
||||
title: "Execution policy edit",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...issue,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ executionPolicy: policy });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
executionPolicy: policy,
|
||||
actorAgentId: null,
|
||||
actorUserId: "local-board",
|
||||
}),
|
||||
);
|
||||
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(updatePatch.status).toBeUndefined();
|
||||
expect(updatePatch.assigneeAgentId).toBeUndefined();
|
||||
expect(updatePatch.assigneeUserId).toBeUndefined();
|
||||
expect(updatePatch.executionState).toBeUndefined();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent stage advances from non-participants", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const approverAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const executorAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
type: "approval",
|
||||
participants: [{ type: "agent", agentId: approverAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1000",
|
||||
title: "Execution policy guard",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: executorAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(
|
||||
createApp({
|
||||
type: "agent",
|
||||
agentId: approverAgentId,
|
||||
companyId: "company-1",
|
||||
source: "api_key",
|
||||
runId: "run-1",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "done", comment: "Skipping review." });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("active review participant");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -413,33 +413,45 @@ describe("issue execution policy transitions", () => {
|
|||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("non-participant cannot advance stage via status change", () => {
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
it("non-participant stage updates are coerced back to the active stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Trying to bypass review",
|
||||
}),
|
||||
).toThrow("Only the active reviewer or approver can advance");
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||
actor: { agentId: coderAgentId },
|
||||
commentBody: "Trying to bypass review",
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
expect(result.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it("non-participant can still post non-advancing updates", () => {
|
||||
|
|
@ -663,6 +675,7 @@ describe("issue execution policy transitions", () => {
|
|||
|
||||
describe("no-op transitions", () => {
|
||||
const policy = twoStagePolicy();
|
||||
const reviewStageId = policy.stages[0].id;
|
||||
|
||||
it("non-done status change without review context is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
|
|
@ -682,6 +695,72 @@ describe("issue execution policy transitions", () => {
|
|||
expect(result.patch).toEqual({});
|
||||
});
|
||||
|
||||
it("coerces a malformed executor in_review patch into the first policy stage", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_review",
|
||||
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reasserts the active stage when issue status drifted out of in_review", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy,
|
||||
requestedStatus: "in_progress",
|
||||
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewStageId,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("no policy and no state is a no-op", () => {
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
|
|
@ -699,6 +778,25 @@ describe("issue execution policy transitions", () => {
|
|||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
|
||||
it("does not auto-start workflow when policy is added to an already in_review issue", () => {
|
||||
const reviewOnly = reviewOnlyPolicy();
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: boardUserId,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
},
|
||||
policy: reviewOnly,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-participant stages", () => {
|
||||
|
|
@ -895,4 +993,100 @@ describe("issue execution policy transitions", () => {
|
|||
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy edits while a stage is active", () => {
|
||||
it("clears the active execution state when its stage is removed from the policy", () => {
|
||||
const reviewAndApproval = twoStagePolicy();
|
||||
const approvalOnly = approvalOnlyPolicy();
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: reviewAndApproval,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: reviewAndApproval.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: approvalOnly,
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reassigns the active stage when the current participant is removed", () => {
|
||||
const policy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [
|
||||
{ type: "agent", agentId: qaAgentId },
|
||||
{ type: "agent", agentId: ctoAgentId },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const updatedPolicy = makePolicy([
|
||||
{
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: ctoAgentId }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: qaAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
...updatedPolicy,
|
||||
stages: [{ ...updatedPolicy.stages[0], id: policy.stages[0].id }],
|
||||
},
|
||||
requestedStatus: undefined,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
});
|
||||
|
||||
expect(result.patch).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: ctoAgentId,
|
||||
assigneeUserId: null,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: policy.stages[0].id,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "repo triage",
|
||||
title: "repo triage for {{repo}}",
|
||||
description: "Review {{repo}} for {{priority}} bugs",
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
|
|
@ -346,6 +346,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
},
|
||||
{},
|
||||
);
|
||||
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
|
||||
|
||||
const run = await svc.runRoutine(variableRoutine.id, {
|
||||
source: "manual",
|
||||
|
|
@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
});
|
||||
|
||||
const storedIssue = await db
|
||||
.select({ description: issues.description })
|
||||
.select({ title: issues.title, description: issues.description })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
|
@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
.where(eq(routineRuns.id, run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(storedIssue?.title).toBe("repo triage for paperclip");
|
||||
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
||||
expect(storedRun?.triggerPayload).toEqual({
|
||||
variables: {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
|||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
|
|
@ -235,6 +236,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
|||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links-current\n", "utf8");
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
|
|
@ -255,6 +257,45 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
|||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
});
|
||||
|
||||
it("skips relinking outside linked git worktrees", async () => {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-"));
|
||||
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-stale-"));
|
||||
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
|
||||
const expectedPackageDir = path.join(repoRoot, "packages", "db");
|
||||
const stalePackageDir = path.join(staleRoot, "db");
|
||||
|
||||
await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true });
|
||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "server", "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@paperclipai/server",
|
||||
dependencies: {
|
||||
"@paperclipai/db": "workspace:*",
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(expectedPackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stalePackageDir, "package.json"),
|
||||
JSON.stringify({ name: "@paperclipai/db" }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
|
||||
|
||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(stalePackageDir));
|
||||
});
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue