Merge public/master into pap-1239-server-test-isolation

This commit is contained in:
dotta
2026-04-09 09:40:44 -05:00
114 changed files with 22931 additions and 2057 deletions
+4 -5
View File
@@ -32,16 +32,15 @@
"skills"
],
"scripts": {
"preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts",
"dev": "pnpm run preflight:workspace-links && tsx src/index.ts",
"dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
"prepack": "pnpm run prepare:ui-dist",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
"start": "node dist/index.js",
"typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
@@ -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);
});
@@ -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,
});
});
});
@@ -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"],
}),
}),
}),
);
});
});
@@ -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", () => {
+2
View File
@@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
@@ -166,6 +167,7 @@ export async function createApp(
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
api.use(sidebarBadgeRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
const hostServicesDisposers = new Map<string, () => void>();
const workerManager = createPluginWorkerManager();
+17 -1
View File
@@ -449,11 +449,25 @@ export function agentRoutes(db: Db) {
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
return {
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
enabled: parseBooleanLike(heartbeat.enabled) ?? false,
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
};
}
function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record<string, unknown> {
const parsedRuntimeConfig = asRecord(runtimeConfig);
const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {};
const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat);
const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {};
if (parseBooleanLike(heartbeat.enabled) == null) {
heartbeat.enabled = false;
}
normalizedRuntimeConfig.heartbeat = heartbeat;
return normalizedRuntimeConfig;
}
function generateEd25519PrivateKeyPem(): string {
const { privateKey } = generateKeyPairSync("ed25519");
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
@@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) {
const normalizedHireInput = {
...hireInput,
adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
};
const company = await db
@@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) {
const createdAgent = await svc.create(companyId, {
...createInput,
adapterConfig: normalizedAdapterConfig,
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
+69
View File
@@ -0,0 +1,69 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { validate } from "../middleware/validate.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { inboxDismissalService, logActivity } from "../services/index.js";
const inboxDismissalSchema = z.object({
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
});
export function inboxDismissalRoutes(db: Db) {
const router = Router();
const svc = inboxDismissalService(db);
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissals = await svc.list(companyId, req.actor.userId);
res.json(dismissals);
});
router.post(
"/companies/:companyId/inbox-dismissals",
validate(inboxDismissalSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "inbox.dismissed",
entityType: "company",
entityId: companyId,
details: {
userId: req.actor.userId,
itemKey: dismissal.itemKey,
dismissedAt: dismissal.dismissedAt,
},
});
res.status(201).json(dismissal);
},
);
return router;
}
+1
View File
@@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
+326 -27
View File
@@ -56,13 +56,219 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
parseIssueExecutionState,
} from "../services/issue-execution-policy.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type ActivityIssueRelationSummary = {
id: string;
identifier: string | null;
title: string;
};
type ActivityExecutionParticipant = Pick<
NormalizedExecutionPolicy["stages"][number]["participants"][number],
"type" | "agentId" | "userId"
>;
type ExecutionStageWakeContext = {
wakeRole: "reviewer" | "approver" | "executor";
stageId: string | null;
stageType: ParsedExecutionState["currentStageType"];
currentParticipant: ParsedExecutionState["currentParticipant"];
returnAssignee: ParsedExecutionState["returnAssignee"];
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
) {
if (!left || !right || left.type !== right.type) return false;
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
}
function executionParticipantMatchesAgent(
participant: ParsedExecutionState["currentParticipant"] | null,
agentId: string | null | undefined,
) {
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
}
function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"];
allowedActions: string[];
}): ExecutionStageWakeContext {
return {
wakeRole: input.wakeRole,
stageId: input.state.currentStageId,
stageType: input.state.currentStageType,
currentParticipant: input.state.currentParticipant,
returnAssignee: input.state.returnAssignee,
lastDecisionOutcome: input.state.lastDecisionOutcome,
allowedActions: input.allowedActions,
};
}
function summarizeIssueRelationForActivity(relation: {
id: string;
identifier: string | null;
title: string;
}): ActivityIssueRelationSummary {
return {
id: relation.id,
identifier: relation.identifier,
title: relation.title,
};
}
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
}
function summarizeExecutionParticipants(
policy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
): ActivityExecutionParticipant[] {
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
return (
stage?.participants.map((participant) => ({
type: participant.type,
agentId: participant.agentId ?? null,
userId: participant.userId ?? null,
})) ?? []
);
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
) {
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
const previousByKey = new Map(previousParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
const nextByKey = new Map(nextParticipants.map((participant) => [
activityExecutionParticipantKey(participant),
participant,
]));
return {
participants: nextParticipants,
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
};
}
function buildExecutionStageWakeup(input: {
issueId: string;
previousState: ParsedExecutionState | null;
nextState: ParsedExecutionState | null;
interruptedRunId: string | null;
requestedByActorType: "user" | "agent";
requestedByActorId: string;
}) {
const { issueId, previousState, nextState, interruptedRunId } = input;
if (!nextState) return null;
if (nextState.status === "pending") {
const agentId =
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
const stageChanged =
previousState?.status !== "pending" ||
previousState?.currentStageId !== nextState.currentStageId ||
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
if (!agentId || !stageChanged) return null;
const reason =
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
allowedActions: ["approve", "request_changes"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason,
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: reason,
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
if (nextState.status === "changes_requested") {
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
const becameChangesRequested =
previousState?.status !== "changes_requested" ||
previousState?.lastDecisionId !== nextState.lastDecisionId ||
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
if (!agentId || !becameChangesRequested) return null;
const executionStage = buildExecutionStageWakeContext({
state: nextState,
wakeRole: "executor",
allowedActions: ["address_changes", "resubmit"],
});
return {
agentId,
wakeup: {
source: "assignment" as const,
triggerDetail: "system" as const,
reason: "execution_changes_requested",
payload: {
issueId,
mutation: "update",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: input.requestedByActorType,
requestedByActorId: input.requestedByActorId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "execution_changes_requested",
source: "issue.execution_stage",
executionStage,
...(interruptedRunId ? { interruptedRunId } : {}),
},
},
};
}
return null;
}
export function issueRoutes(
db: Db,
storage: StorageService,
@@ -1066,9 +1272,10 @@ export function issueRoutes(
}
const actor = getActorInfo(req);
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
const issue = await svc.create(companyId, {
...req.body,
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
executionPolicy,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
@@ -1110,24 +1317,6 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, existing.companyId);
const assigneeWillChange =
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
req.body.assigneeAgentId === null &&
typeof req.body.assigneeUserId === "string" &&
!!existing.createdByUserId &&
req.body.assigneeUserId === existing.createdByUserId;
if (assigneeWillChange) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
const actor = getActorInfo(req);
@@ -1191,14 +1380,20 @@ export function issueRoutes(
if (req.body.executionPolicy !== undefined) {
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
}
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
const nextExecutionPolicy =
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy;
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
const requestedAssigneePatchProvided =
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
policy:
updateFields.executionPolicy !== undefined
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
policy: nextExecutionPolicy,
requestedStatus,
requestedAssigneePatch: {
assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
@@ -1224,6 +1419,48 @@ export function issueRoutes(
}
Object.assign(updateFields, transition.patch);
const effectiveExecutionState = parseIssueExecutionState(
transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState,
);
const isUnauthorizedAgentStageMutation =
req.actor.type === "agent" &&
req.actor.agentId &&
existing.status === "in_review" &&
transition.workflowControlledAssignment &&
!transition.decision &&
effectiveExecutionState?.status === "pending" &&
(
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
requestedAssigneePatchProvided
) &&
!executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId);
if (isUnauthorizedAgentStageMutation) {
const stageLabel = effectiveExecutionState.currentStageType ?? "execution";
res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` });
return;
}
const nextAssigneeAgentId =
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
const nextAssigneeUserId =
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
const assigneeWillChange =
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
const isAgentReturningIssueToCreator =
req.actor.type === "agent" &&
!!req.actor.agentId &&
existing.assigneeAgentId === req.actor.agentId &&
nextAssigneeAgentId === null &&
typeof nextAssigneeUserId === "string" &&
!!existing.createdByUserId &&
nextAssigneeUserId === existing.createdByUserId;
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId);
}
}
let issue;
try {
if (transition.decision && decisionId) {
@@ -1291,8 +1528,9 @@ export function issueRoutes(
return;
}
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
const updatedRelations = await svc.getRelationSummaries(issue.id);
updatedRelations = await svc.getRelationSummaries(issue.id);
issueResponse = {
...issue,
blockedBy: updatedRelations.blockedBy,
@@ -1349,6 +1587,8 @@ export function issueRoutes(
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
const nextBlockedByRelations = updatedRelations?.blockedBy ?? [];
const previousBlockedByRelations = existingRelations?.blockedBy ?? [];
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
@@ -1364,11 +1604,58 @@ export function issueRoutes(
blockedByIssueIds: req.body.blockedByIssueIds,
addedBlockedByIssueIds,
removedBlockedByIssueIds,
blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity),
addedBlockedByIssues: nextBlockedByRelations
.filter((relation) => addedBlockedByIssueIds.includes(relation.id))
.map(summarizeIssueRelationForActivity),
removedBlockedByIssues: previousBlockedByRelations
.filter((relation) => removedBlockedByIssueIds.includes(relation.id))
.map(summarizeIssueRelationForActivity),
},
});
}
}
const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review");
if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.reviewers_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
participants: reviewerChanges.participants,
addedParticipants: reviewerChanges.addedParticipants,
removedParticipants: reviewerChanges.removedParticipants,
},
});
}
const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval");
if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) {
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.approvers_updated",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
participants: approverChanges.participants,
addedParticipants: approverChanges.addedParticipants,
removedParticipants: approverChanges.removedParticipants,
},
});
}
if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient();
if (tc && actor.agentId) {
@@ -1414,6 +1701,16 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const previousExecutionState = parseIssueExecutionState(existing.executionState);
const nextExecutionState = parseIssueExecutionState(issue.executionState);
const executionStageWakeup = buildExecutionStageWakeup({
issueId: issue.id,
previousState: previousExecutionState,
nextState: nextExecutionState,
interruptedRunId,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
});
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
void (async () => {
@@ -1427,7 +1724,9 @@ export function issueRoutes(
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
};
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
if (executionStageWakeup) {
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
addWakeup(issue.assigneeAgentId, {
source: "assignment",
triggerDetail: "system",
+29 -8
View File
@@ -1,12 +1,20 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { joinRequests } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { inboxDismissals, joinRequests } from "@paperclipai/db";
import { sidebarBadgeService } from "../services/sidebar-badges.js";
import { accessService } from "../services/access.js";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
function buildDismissedAtByKey(
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
);
}
export function sidebarBadgeRoutes(db: Db) {
const router = Router();
const svc = sidebarBadgeService(db);
@@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
}
const joinRequestCount = canApproveJoins
const visibleJoinRequests = canApproveJoins
? await db
.select({ count: sql<number>`count(*)` })
.select({
id: joinRequests.id,
updatedAt: joinRequests.updatedAt,
createdAt: joinRequests.createdAt,
})
.from(joinRequests)
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
.then((rows) => Number(rows[0]?.count ?? 0))
: 0;
: [];
const dismissedAtByKey =
req.actor.type === "board" && req.actor.userId
? await db
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
.then(buildDismissedAtByKey)
: new Map<string, number>();
const badges = await svc.get(companyId, {
joinRequests: joinRequestCount,
dismissals: dismissedAtByKey,
joinRequests: visibleJoinRequests,
});
const summary = await dashboard.summary(companyId);
const hasFailedRuns = badges.failedRuns > 0;
const alertsCount =
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
res.json(badges);
});
+34 -20
View File
@@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
if (contextSnapshot?.forceFreshSession === true) return true;
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return true;
if (
wakeReason === "issue_assigned" ||
wakeReason === "execution_review_requested" ||
wakeReason === "execution_approval_requested" ||
wakeReason === "execution_changes_requested"
) {
return true;
}
return false;
}
@@ -714,6 +721,9 @@ function describeSessionResetReason(
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
return null;
}
@@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
}
| null;
}) {
const executionStage = parseObject(input.contextSnapshot.executionStage);
const commentIds = extractWakeCommentIds(input.contextSnapshot);
if (commentIds.length === 0) return null;
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const issueSummary =
input.issueSummary ??
@@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
.then((rows) => rows[0] ?? null)
: null);
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
const commentRows = await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentRows =
commentIds.length === 0
? []
: await input.db
.select({
id: issueComments.id,
issueId: issueComments.issueId,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
inArray(issueComments.id, commentIds),
),
);
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
const comments: Array<Record<string, unknown>> = [];
@@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
priority: issueSummary.priority,
}
: null,
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
commentIds,
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,
@@ -2159,7 +2173,7 @@ export function heartbeatService(db: Db) {
const heartbeat = parseObject(runtimeConfig.heartbeat);
return {
enabled: asBoolean(heartbeat.enabled, true),
enabled: asBoolean(heartbeat.enabled, false),
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
+41
View File
@@ -0,0 +1,41 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { inboxDismissals } from "@paperclipai/db";
export function inboxDismissalService(db: Db) {
return {
list: async (companyId: string, userId: string) =>
db
.select()
.from(inboxDismissals)
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
.orderBy(desc(inboxDismissals.updatedAt)),
dismiss: async (
companyId: string,
userId: string,
itemKey: string,
dismissedAt: Date = new Date(),
) => {
const now = new Date();
const [row] = await db
.insert(inboxDismissals)
.values({
companyId,
userId,
itemKey,
dismissedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
set: {
dismissedAt,
updatedAt: now,
},
})
.returning();
return row;
},
};
}
+1
View File
@@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
+177 -65
View File
@@ -36,6 +36,7 @@ type TransitionInput = {
type TransitionResult = {
patch: Record<string, unknown>;
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
workflowControlledAssignment?: boolean;
};
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
@@ -144,6 +145,11 @@ function selectStageParticipant(
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
}
function stageHasParticipant(stage: IssueExecutionStage, participant: IssueExecutionStagePrincipal | null): boolean {
if (!participant) return false;
return stage.participants.some((candidate) => principalsEqual(candidate, participant));
}
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
if (!principal) {
return { assigneeAgentId: null, assigneeUserId: null };
@@ -198,14 +204,49 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
};
}
function buildPendingStagePatch(input: {
patch: Record<string, unknown>;
previous: IssueExecutionState | null;
policy: IssueExecutionPolicy;
stage: IssueExecutionStage;
participant: IssueExecutionStagePrincipal;
returnAssignee: IssueExecutionStagePrincipal | null;
}) {
input.patch.status = "in_review";
Object.assign(input.patch, patchForPrincipal(input.participant));
input.patch.executionState = buildPendingState({
previous: input.previous,
stage: input.stage,
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
participant: input.participant,
returnAssignee: input.returnAssignee,
});
}
function clearExecutionStatePatch(input: {
patch: Record<string, unknown>;
issueStatus: string;
requestedStatus?: string;
returnAssignee: IssueExecutionStagePrincipal | null;
}) {
input.patch.executionState = null;
if (input.requestedStatus === undefined && input.issueStatus === "in_review" && input.returnAssignee) {
input.patch.status = "in_progress";
Object.assign(input.patch, patchForPrincipal(input.returnAssignee));
}
}
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
const patch: Record<string, unknown> = {};
const existingState = parseIssueExecutionState(input.issue.executionState);
const currentAssignee = assigneePrincipal(input.issue);
const actor = actorPrincipal(input.actor);
const requestedAssigneePatchProvided =
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
const requestedStatus = input.requestedStatus;
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
if (!input.policy) {
if (existingState) {
@@ -228,90 +269,159 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
return { patch };
}
if (currentStage && input.issue.status === "in_review") {
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
if (requestedStatus && requestedStatus !== "in_review") {
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}
return { patch };
if (existingState?.currentStageId && !currentStage) {
clearExecutionStatePatch({
patch,
issueStatus: input.issue.status,
requestedStatus,
returnAssignee: existingState.returnAssignee,
});
return { patch };
}
if (activeStage) {
const currentParticipant =
existingState?.currentParticipant ??
selectStageParticipant(activeStage, {
exclude: existingState?.returnAssignee ?? null,
});
if (!currentParticipant) {
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
}
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, currentStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
if (!stageHasParticipant(activeStage, currentParticipant)) {
const participant = selectStageParticipant(activeStage, {
preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
clearExecutionStatePatch({
patch,
issueStatus: input.issue.status,
requestedStatus,
returnAssignee: existingState?.returnAssignee ?? null,
});
return { patch };
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
previous: approvedState,
stage: nextStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
if (principalsEqual(currentParticipant, actor)) {
if (requestedStatus === "done") {
if (!input.commentBody?.trim()) {
throw unprocessable("Approving a review or approval stage requires a comment");
}
const approvedState = buildCompletedState(existingState, activeStage);
const nextStage = nextPendingStage(
input.policy,
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
);
if (!nextStage) {
patch.executionState = approvedState;
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
};
}
const participant = selectStageParticipant(nextStage, {
preferred: explicitAssignee,
exclude: existingState?.returnAssignee ?? null,
});
if (!participant) {
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
}
buildPendingStagePatch({
patch,
previous: approvedState,
policy: input.policy,
stage: nextStage,
participant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "approved",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
if (requestedStatus && requestedStatus !== "in_review") {
if (!input.commentBody?.trim()) {
throw unprocessable("Requesting changes requires a comment");
}
if (!existingState?.returnAssignee) {
throw unprocessable("This execution stage has no return assignee");
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, activeStage);
return {
patch,
decision: {
stageId: activeStage.id,
stageType: activeStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
patch.status = "in_progress";
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
patch.executionState = buildChangesRequestedState(existingState, currentStage);
}
if (
input.issue.status !== "in_review" ||
!principalsEqual(currentAssignee, currentParticipant) ||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
) {
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: activeStage,
participant: currentParticipant,
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
});
return {
patch,
decision: {
stageId: currentStage.id,
stageType: currentStage.type,
outcome: "changes_requested",
body: input.commentBody.trim(),
},
workflowControlledAssignment: true,
};
}
return { patch };
}
if (requestedStatus !== "done") {
const shouldStartWorkflow =
requestedStatus === "done" ||
requestedStatus === "in_review";
if (!shouldStartWorkflow) {
return { patch };
}
@@ -333,14 +443,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
}
patch.status = "in_review";
Object.assign(patch, patchForPrincipal(participant));
patch.executionState = buildPendingState({
buildPendingStagePatch({
patch,
previous: existingState,
policy: input.policy,
stage: pendingStage,
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
participant,
returnAssignee,
});
return { patch };
return {
patch,
workflowControlledAssignment: true,
};
}
+6 -4
View File
@@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
executionWorkspaceSettings?: Record<string, unknown> | null;
}) {
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => {
@@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: input.routine.projectId,
goalId: input.routine.goalId,
parentId: input.routine.parentIssueId,
title: input.routine.title,
title,
description,
status: "todo",
priority: input.routine.priority,
@@ -996,7 +997,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate(
input.description,
[input.title, input.description],
sanitizeRoutineVariableInputs(input.variables),
);
assertRoutineVariableDefinitions(variables);
@@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextTitle = patch.title ?? existing.title;
const nextDescription = patch.description === undefined ? existing.description : patch.description;
const nextVariables = syncRoutineVariablesWithTemplate(
nextDescription,
[nextTitle, nextDescription],
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
);
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
@@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
projectId: nextProjectId,
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
title: patch.title ?? existing.title,
title: nextTitle,
description: nextDescription,
assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority,
+37 -6
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { and, desc, eq, inArray, not } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
import type { SidebarBadges } from "@paperclipai/shared";
@@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
function normalizeTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
}
function isDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: Date | string | null | undefined,
) {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function sidebarBadgeService(db: Db) {
return {
get: async (
companyId: string,
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
extra?: {
dismissals?: ReadonlyMap<string, number>;
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
unreadTouchedIssues?: number;
},
): Promise<SidebarBadges> => {
const actionableApprovals = await db
.select({ count: sql<number>`count(*)` })
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
.from(approvals)
.where(
and(
@@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
.then((rows) =>
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
);
const latestRunByAgent = await db
.selectDistinctOn([heartbeatRuns.agentId], {
id: heartbeatRuns.id,
runStatus: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
@@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
const failedRuns = latestRunByAgent.filter((row) =>
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
).length;
const joinRequests = extra?.joinRequests ?? 0;
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
!isDismissed(
extra?.dismissals ?? new Map(),
`join:${row.id}`,
row.updatedAt ?? row.createdAt,
)
).length;
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
return {
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
+12 -1
View File
@@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
import fs from "node:fs/promises";
import net from "node:net";
import { createHash, randomUUID } from "node:crypto";
@@ -157,6 +157,16 @@ function findWorkspaceRoot(startCwd: string) {
}
}
function isLinkedGitWorktreeCheckout(rootDir: string) {
const gitMetadataPath = path.join(rootDir, ".git");
if (!existsSync(gitMetadataPath)) return false;
const stat = lstatSync(gitMetadataPath);
if (!stat.isFile()) return false;
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
}
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
const packagePaths = new Map<string, string>();
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
@@ -228,6 +238,7 @@ export async function ensureServerWorkspaceLinksCurrent(
) {
const workspaceRoot = findWorkspaceRoot(startCwd);
if (!workspaceRoot) return;
if (!isLinkedGitWorktreeCheckout(workspaceRoot)) return;
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
if (mismatches.length === 0) return;