fix: harden routine dispatch and permissions

This commit is contained in:
dotta
2026-03-20 16:15:32 -05:00
parent e3c92a20f1
commit 99eb317600
4 changed files with 417 additions and 139 deletions

View File

@@ -0,0 +1,156 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { routineRoutes } from "../routes/routines.js";
import { errorHandler } from "../middleware/index.js";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "11111111-1111-4111-8111-111111111111";
const routineId = "33333333-3333-4333-8333-333333333333";
const projectId = "44444444-4444-4444-8444-444444444444";
const otherAgentId = "55555555-5555-4555-8555-555555555555";
const routine = {
id: routineId,
companyId,
projectId,
goalId: null,
parentIssueId: null,
title: "Daily routine",
description: null,
assigneeAgentId: agentId,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const mockRoutineService = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
getDetail: vi.fn(),
update: vi.fn(),
create: vi.fn(),
listRuns: vi.fn(),
createTrigger: vi.fn(),
getTrigger: vi.fn(),
updateTrigger: vi.fn(),
deleteTrigger: vi.fn(),
rotateTriggerSecret: vi.fn(),
runRoutine: vi.fn(),
firePublicTrigger: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
logActivity: mockLogActivity,
routineService: () => mockRoutineService,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", routineRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("routine routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRoutineService.create.mockResolvedValue(routine);
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
mockAccessService.canUser.mockResolvedValue(false);
mockLogActivity.mockResolvedValue(undefined);
});
it("requires tasks:assign permission for non-admin board routine creation", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.create).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to retarget a routine assignee", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
assigneeAgentId: otherAgentId,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
});
expect(res.status).toBe(201);
expect(mockRoutineService.create).toHaveBeenCalledWith(companyId, expect.objectContaining({
projectId,
title: "Daily routine",
assigneeAgentId: agentId,
}), {
agentId: null,
userId: "board-user",
});
});
});

View File

@@ -176,7 +176,30 @@ describe("routine service live-execution coalescing", () => {
heartbeat: { heartbeat: {
wakeup: async (wakeupAgentId, wakeupOpts) => { wakeup: async (wakeupAgentId, wakeupOpts) => {
wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts }); wakeups.push({ agentId: wakeupAgentId, opts: wakeupOpts });
return opts?.wakeup ? opts.wakeup(wakeupAgentId, wakeupOpts) : null; if (opts?.wakeup) return opts.wakeup(wakeupAgentId, wakeupOpts);
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
}, },
}, },
}); });
@@ -350,4 +373,52 @@ describe("routine service live-execution coalescing", () => {
expect(routineIssues).toHaveLength(1); expect(routineIssues).toHaveLength(1);
expect(routineIssues[0]?.id).toBe(previousIssue.id); expect(routineIssues[0]?.id).toBe(previousIssue.id);
}); });
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {
const issueId =
(typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) ||
(typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) ||
null;
await new Promise((resolve) => setTimeout(resolve, 25));
if (!issueId) return null;
const queuedRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: queuedRunId,
companyId: routine.companyId,
agentId: wakeupAgentId,
invocationSource: wakeupOpts.source ?? "assignment",
triggerDetail: wakeupOpts.triggerDetail ?? null,
status: "queued",
contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId },
});
await db
.update(issues)
.set({
executionRunId: queuedRunId,
executionLockedAt: new Date(),
})
.where(eq(issues.id, issueId));
return { id: queuedRunId };
},
});
const [first, second] = await Promise.all([
svc.runRoutine(routine.id, { source: "manual" }),
svc.runRoutine(routine.id, { source: "manual" }),
]);
expect([first.status, second.status].sort()).toEqual(["coalesced", "issue_created"]);
expect(first.linkedIssueId).toBeTruthy();
expect(second.linkedIssueId).toBeTruthy();
expect(first.linkedIssueId).toBe(second.linkedIssueId);
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(1);
});
}); });

View File

@@ -9,13 +9,24 @@ import {
updateRoutineTriggerSchema, updateRoutineTriggerSchema,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { logActivity, routineService } from "../services/index.js"; import { accessService, logActivity, routineService } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, unauthorized } from "../errors.js"; import { forbidden, unauthorized } from "../errors.js";
export function routineRoutes(db: Db) { export function routineRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = routineService(db); const svc = routineService(db);
const access = accessService(db);
async function assertBoardCanAssignTasks(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") return;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) { function assertCanManageCompanyRoutine(req: Request, companyId: string, assigneeAgentId?: string | null) {
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
@@ -47,6 +58,7 @@ export function routineRoutes(db: Db) {
router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => { router.post("/companies/:companyId/routines", validate(createRoutineSchema), async (req, res) => {
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
await assertBoardCanAssignTasks(req, companyId);
assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId); assertCanManageCompanyRoutine(req, companyId, req.body.assigneeAgentId);
const created = await svc.create(companyId, req.body, { const created = await svc.create(companyId, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null, agentId: req.actor.type === "agent" ? req.actor.agentId : null,
@@ -83,6 +95,12 @@ export function routineRoutes(db: Db) {
res.status(404).json({ error: "Routine not found" }); res.status(404).json({ error: "Routine not found" });
return; return;
} }
const assigneeWillChange =
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== routine.assigneeAgentId;
if (assigneeWillChange) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) { if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only assign routines to themselves"); throw forbidden("Agents can only assign routines to themselves");
} }

View File

@@ -26,11 +26,13 @@ import type {
UpdateRoutineTrigger, UpdateRoutineTrigger,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { issueService } from "./issues.js"; import { issueService } from "./issues.js";
import { secretService } from "./secrets.js"; import { secretService } from "./secrets.js";
import { parseCron, validateCron } from "./cron.js"; import { parseCron, validateCron } from "./cron.js";
import { heartbeatService } from "./heartbeat.js"; import { heartbeatService } from "./heartbeat.js";
import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js"; import { queueIssueAssignmentWakeup, type IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
import { logActivity } from "./activity-log.js";
const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"]; const OPEN_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"];
const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"]; const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"];
@@ -386,8 +388,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status: string; status: string;
issueId?: string | null; issueId?: string | null;
nextRunAt?: Date | null; nextRunAt?: Date | null;
}) { }, executor: Db = db) {
await db await executor
.update(routines) .update(routines)
.set({ .set({
lastTriggeredAt: input.triggeredAt, lastTriggeredAt: input.triggeredAt,
@@ -397,7 +399,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
.where(eq(routines.id, input.routineId)); .where(eq(routines.id, input.routineId));
if (input.triggerId) { if (input.triggerId) {
await db await executor
.update(routineTriggers) .update(routineTriggers)
.set({ .set({
lastFiredAt: input.triggeredAt, lastFiredAt: input.triggeredAt,
@@ -409,8 +411,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
} }
} }
async function findLiveExecutionIssue(routine: typeof routines.$inferSelect) { async function findLiveExecutionIssue(routine: typeof routines.$inferSelect, executor: Db = db) {
const executionBoundIssue = await db const executionBoundIssue = await executor
.select() .select()
.from(issues) .from(issues)
.innerJoin( .innerJoin(
@@ -434,7 +436,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
.then((rows) => rows[0]?.issues ?? null); .then((rows) => rows[0]?.issues ?? null);
if (executionBoundIssue) return executionBoundIssue; if (executionBoundIssue) return executionBoundIssue;
return db return executor
.select() .select()
.from(issues) .from(issues)
.innerJoin( .innerJoin(
@@ -459,8 +461,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
.then((rows) => rows[0]?.issues ?? null); .then((rows) => rows[0]?.issues ?? null);
} }
async function finalizeRun(runId: string, patch: Partial<typeof routineRuns.$inferInsert>) { async function finalizeRun(runId: string, patch: Partial<typeof routineRuns.$inferInsert>, executor: Db = db) {
return db return executor
.update(routineRuns) .update(routineRuns)
.set({ .set({
...patch, ...patch,
@@ -509,8 +511,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
payload?: Record<string, unknown> | null; payload?: Record<string, unknown> | null;
idempotencyKey?: string | null; idempotencyKey?: string | null;
}) { }) {
const run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
await tx.execute(
sql`select id from ${routines} where ${routines.id} = ${input.routine.id} and ${routines.companyId} = ${input.routine.companyId} for update`,
);
if (input.idempotencyKey) { if (input.idempotencyKey) {
const existing = await db const existing = await txDb
.select() .select()
.from(routineRuns) .from(routineRuns)
.where( .where(
@@ -529,7 +537,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
} }
const triggeredAt = new Date(); const triggeredAt = new Date();
const [run] = await db const [createdRun] = await txDb
.insert(routineRuns) .insert(routineRuns)
.values({ .values({
companyId: input.routine.companyId, companyId: input.routine.companyId,
@@ -548,15 +556,15 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
: undefined; : undefined;
try { try {
const activeIssue = await findLiveExecutionIssue(input.routine); const activeIssue = await findLiveExecutionIssue(input.routine, txDb);
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") { if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
const updated = await finalizeRun(run.id, { const updated = await finalizeRun(createdRun.id, {
status, status,
linkedIssueId: activeIssue.id, linkedIssueId: activeIssue.id,
coalescedIntoRunId: activeIssue.originRunId, coalescedIntoRunId: activeIssue.originRunId,
completedAt: triggeredAt, completedAt: triggeredAt,
}); }, txDb);
await updateRoutineTouchedState({ await updateRoutineTouchedState({
routineId: input.routine.id, routineId: input.routine.id,
triggerId: input.trigger?.id ?? null, triggerId: input.trigger?.id ?? null,
@@ -564,8 +572,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status, status,
issueId: activeIssue.id, issueId: activeIssue.id,
nextRunAt, nextRunAt,
}); }, txDb);
return updated ?? run; return updated ?? createdRun;
} }
let createdIssue; let createdIssue;
@@ -581,7 +589,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
assigneeAgentId: input.routine.assigneeAgentId, assigneeAgentId: input.routine.assigneeAgentId,
originKind: "routine_execution", originKind: "routine_execution",
originId: input.routine.id, originId: input.routine.id,
originRunId: run.id, originRunId: createdRun.id,
}); });
} catch (error) { } catch (error) {
const isOpenExecutionConflict = const isOpenExecutionConflict =
@@ -595,15 +603,15 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
throw error; throw error;
} }
const existingIssue = await findLiveExecutionIssue(input.routine); const existingIssue = await findLiveExecutionIssue(input.routine, txDb);
if (!existingIssue) throw error; if (!existingIssue) throw error;
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
const updated = await finalizeRun(run.id, { const updated = await finalizeRun(createdRun.id, {
status, status,
linkedIssueId: existingIssue.id, linkedIssueId: existingIssue.id,
coalescedIntoRunId: existingIssue.originRunId, coalescedIntoRunId: existingIssue.originRunId,
completedAt: triggeredAt, completedAt: triggeredAt,
}); }, txDb);
await updateRoutineTouchedState({ await updateRoutineTouchedState({
routineId: input.routine.id, routineId: input.routine.id,
triggerId: input.trigger?.id ?? null, triggerId: input.trigger?.id ?? null,
@@ -611,15 +619,11 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status, status,
issueId: existingIssue.id, issueId: existingIssue.id,
nextRunAt, nextRunAt,
}); }, txDb);
return updated ?? run; return updated ?? createdRun;
} }
const updated = await finalizeRun(run.id, { // Keep the dispatch lock until the issue is linked to a queued heartbeat run.
status: "issue_created",
linkedIssueId: createdIssue.id,
});
// Ensure the wake request is durably queued before reporting the routine run as created.
await queueIssueAssignmentWakeup({ await queueIssueAssignmentWakeup({
heartbeat, heartbeat,
issue: createdIssue, issue: createdIssue,
@@ -628,6 +632,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
contextSource: "routine.dispatch", contextSource: "routine.dispatch",
requestedByActorType: input.source === "schedule" ? "system" : undefined, requestedByActorType: input.source === "schedule" ? "system" : undefined,
}); });
const updated = await finalizeRun(createdRun.id, {
status: "issue_created",
linkedIssueId: createdIssue.id,
}, txDb);
await updateRoutineTouchedState({ await updateRoutineTouchedState({
routineId: input.routine.id, routineId: input.routine.id,
triggerId: input.trigger?.id ?? null, triggerId: input.trigger?.id ?? null,
@@ -635,24 +643,49 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
status: "issue_created", status: "issue_created",
issueId: createdIssue.id, issueId: createdIssue.id,
nextRunAt, nextRunAt,
}); }, txDb);
return updated ?? run; return updated ?? createdRun;
} catch (error) { } catch (error) {
const failureReason = error instanceof Error ? error.message : String(error); const failureReason = error instanceof Error ? error.message : String(error);
const failed = await finalizeRun(run.id, { const failed = await finalizeRun(createdRun.id, {
status: "failed", status: "failed",
failureReason, failureReason,
completedAt: new Date(), completedAt: new Date(),
}); }, txDb);
await updateRoutineTouchedState({ await updateRoutineTouchedState({
routineId: input.routine.id, routineId: input.routine.id,
triggerId: input.trigger?.id ?? null, triggerId: input.trigger?.id ?? null,
triggeredAt, triggeredAt,
status: "failed", status: "failed",
nextRunAt, nextRunAt,
}); }, txDb);
return failed ?? run; return failed ?? createdRun;
} }
});
if (input.source === "schedule" || input.source === "webhook") {
const actorId = input.source === "schedule" ? "routine-scheduler" : "routine-webhook";
try {
await logActivity(db, {
companyId: input.routine.companyId,
actorType: "system",
actorId,
action: "routine.run_triggered",
entityType: "routine_run",
entityId: run.id,
details: {
routineId: input.routine.id,
triggerId: input.trigger?.id ?? null,
source: run.source,
status: run.status,
},
});
} catch (err) {
logger.warn({ err, routineId: input.routine.id, runId: run.id }, "failed to log automated routine run");
}
}
return run;
} }
return { return {