fix: close remaining routine merge blockers

This commit is contained in:
dotta
2026-03-20 16:40:27 -05:00
parent 9093cfbe4f
commit 8dc98db717
7 changed files with 182 additions and 13 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
@@ -61,8 +61,21 @@ describe("boardMutationGuard", () => {
});
it("does not block authenticated agent mutations", async () => {
const app = createApp("agent");
const res = await request(app).post("/mutate").send({ ok: true });
expect(res.status).toBe(204);
const middleware = boardMutationGuard();
const req = {
method: "POST",
actor: { type: "agent", agentId: "agent-1" },
header: () => undefined,
} as any;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
} as any;
const next = vi.fn();
middleware(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
@@ -44,7 +42,9 @@ vi.mock("../services/index.js", () => ({
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown>) {
async function createApp(actor: Record<string, unknown>) {
const { companyRoutes } = await import("../routes/companies.js");
const { errorHandler } = await import("../middleware/index.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
@@ -58,6 +58,7 @@ function createApp(actor: Record<string, unknown>) {
describe("company portability routes", () => {
beforeEach(() => {
vi.resetModules();
mockAgentService.getById.mockReset();
mockCompanyPortabilityService.exportBundle.mockReset();
mockCompanyPortabilityService.previewExport.mockReset();
@@ -72,7 +73,7 @@ describe("company portability routes", () => {
companyId: "11111111-1111-4111-8111-111111111111",
role: "engineer",
});
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
@@ -104,7 +105,7 @@ describe("company portability routes", () => {
warnings: [],
paperclipExtensionPath: ".paperclip.yaml",
});
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
@@ -128,7 +129,7 @@ describe("company portability routes", () => {
companyId: "11111111-1111-4111-8111-111111111111",
role: "ceo",
});
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",
@@ -151,7 +152,7 @@ describe("company portability routes", () => {
});
it("keeps global import preview routes board-only", async () => {
const app = createApp({
const app = await createApp({
type: "agent",
agentId: "agent-1",
companyId: "11111111-1111-4111-8111-111111111111",

View File

@@ -32,6 +32,34 @@ const routine = {
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
};
const pausedRoutine = {
...routine,
status: "paused",
};
const trigger = {
id: "66666666-6666-4666-8666-666666666666",
companyId,
routineId,
kind: "schedule",
label: "weekday",
enabled: false,
cronExpression: "0 10 * * 1-5",
timezone: "UTC",
nextRunAt: null,
lastFiredAt: null,
publicId: null,
secretId: null,
signingMode: null,
replayWindowSec: null,
lastRotatedAt: null,
lastResult: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: 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(),
@@ -78,7 +106,13 @@ describe("routine routes", () => {
vi.clearAllMocks();
mockRoutineService.create.mockResolvedValue(routine);
mockRoutineService.get.mockResolvedValue(routine);
mockRoutineService.getTrigger.mockResolvedValue(trigger);
mockRoutineService.update.mockResolvedValue({ ...routine, assigneeAgentId: otherAgentId });
mockRoutineService.runRoutine.mockResolvedValue({
id: "run-1",
source: "manual",
status: "issue_created",
});
mockAccessService.canUser.mockResolvedValue(false);
mockLogActivity.mockResolvedValue(undefined);
});
@@ -125,6 +159,87 @@ describe("routine routes", () => {
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to reactivate a routine", async () => {
mockRoutineService.get.mockResolvedValue(pausedRoutine);
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routines/${routineId}`)
.send({
status: "active",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.update).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to create a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/triggers`)
.send({
kind: "schedule",
cronExpression: "0 10 * * *",
timezone: "UTC",
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.createTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to update a trigger", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.patch(`/api/routine-triggers/${trigger.id}`)
.send({
enabled: true,
});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.updateTrigger).not.toHaveBeenCalled();
});
it("requires tasks:assign permission to manually run a routine", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const res = await request(app)
.post(`/api/routines/${routineId}/run`)
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("tasks:assign");
expect(mockRoutineService.runRoutine).not.toHaveBeenCalled();
});
it("allows routine creation when the board user has tasks:assign", async () => {
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp({

View File

@@ -430,6 +430,27 @@ describe("routine service live-execution coalescing", () => {
expect(routineIssues).toHaveLength(1);
});
it("fails the run and cleans up the execution issue when wakeup queueing fails", async () => {
const { routine, svc } = await seedFixture({
wakeup: async () => {
throw new Error("queue unavailable");
},
});
const run = await svc.runRoutine(routine.id, { source: "manual" });
expect(run.status).toBe("failed");
expect(run.failureReason).toContain("queue unavailable");
expect(run.linkedIssueId).toBeNull();
const routineIssues = await db
.select({ id: issues.id })
.from(issues)
.where(eq(issues.originId, routine.id));
expect(routineIssues).toHaveLength(0);
});
it("accepts standard second-precision webhook timestamps for HMAC triggers", async () => {
const { routine, svc } = await seedFixture();
const { trigger, secretMaterial } = await svc.createTrigger(

View File

@@ -101,6 +101,13 @@ export function routineRoutes(db: Db) {
if (assigneeWillChange) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
const statusWillActivate =
req.body.status !== undefined &&
req.body.status === "active" &&
routine.status !== "active";
if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only assign routines to themselves");
}
@@ -141,6 +148,7 @@ export function routineRoutes(db: Db) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const created = await svc.createTrigger(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
@@ -171,6 +179,7 @@ export function routineRoutes(db: Db) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const updated = await svc.updateTrigger(trigger.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
@@ -257,6 +266,7 @@ export function routineRoutes(db: Db) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const run = await svc.runRoutine(routine.id, req.body);
const actor = getActorInfo(req);
await logActivity(db, {

View File

@@ -26,6 +26,7 @@ export function queueIssueAssignmentWakeup(input: {
contextSource: string;
requestedByActorType?: "user" | "agent" | "system";
requestedByActorId?: string | null;
rethrowOnError?: boolean;
}) {
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
@@ -39,5 +40,9 @@ export function queueIssueAssignmentWakeup(input: {
requestedByActorId: input.requestedByActorId ?? null,
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
})
.catch((err) => logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment"));
.catch((err) => {
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
if (input.rethrowOnError) throw err;
return null;
});
}

View File

@@ -561,6 +561,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
? nextCronTickInTimeZone(input.trigger.cronExpression, input.trigger.timezone, triggeredAt)
: undefined;
let createdIssue: Awaited<ReturnType<typeof issueSvc.create>> | null = null;
try {
const activeIssue = await findLiveExecutionIssue(input.routine, txDb);
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
@@ -582,7 +583,6 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
return updated ?? createdRun;
}
let createdIssue;
try {
createdIssue = await issueSvc.create(input.routine.companyId, {
projectId: input.routine.projectId,
@@ -637,6 +637,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
mutation: "create",
contextSource: "routine.dispatch",
requestedByActorType: input.source === "schedule" ? "system" : undefined,
rethrowOnError: true,
});
const updated = await finalizeRun(createdRun.id, {
status: "issue_created",
@@ -652,6 +653,9 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
}, txDb);
return updated ?? createdRun;
} catch (error) {
if (createdIssue) {
await txDb.delete(issues).where(eq(issues.id, createdIssue.id));
}
const failureReason = error instanceof Error ? error.message : String(error);
const failed = await finalizeRun(createdRun.id, {
status: "failed",