From 19cc090f70c92eeebb76804f5fc0066800735201 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 16:56:28 +0000 Subject: [PATCH] Clear stale pipeline automation health failures Co-Authored-By: Paperclip --- server/src/__tests__/pipelines-routes.test.ts | 94 +++++++++++++++++++ server/src/routes/pipelines.ts | 23 ++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/pipelines-routes.test.ts b/server/src/__tests__/pipelines-routes.test.ts index 5ec32ef262b..8845f65519c 100644 --- a/server/src/__tests__/pipelines-routes.test.ts +++ b/server/src/__tests__/pipelines-routes.test.ts @@ -175,11 +175,14 @@ describeEmbeddedPostgres("pipeline routes", () => { const company = await seedCompany(); const agent = await seedAutomationAgent(company.id); const http = request(app(boardActor)); + const stageConfiguredAt = new Date("2026-06-30T17:00:00.000Z"); const [routine] = await db.insert(routines).values({ companyId: company.id, title: "Assembly automation", description: "Assemble the item.", assigneeAgentId: agent.id, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, }).returning(); const [pipeline] = await db.insert(pipelines).values({ @@ -197,6 +200,8 @@ describeEmbeddedPostgres("pipeline routes", () => { kind: "working", position: 100, config: { onEnter: { type: "run_routine", routineId: routine!.id } }, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, }, { pipelineId, @@ -204,6 +209,8 @@ describeEmbeddedPostgres("pipeline routes", () => { name: "Content Review", kind: "review", position: 200, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, }, ]).returning(); const automationId = `${assemblyStage.id}:on_enter`; @@ -283,6 +290,93 @@ describeEmbeddedPostgres("pipeline routes", () => { ]); }); + it("clears stale automation failures after the automation setup is edited", async () => { + const company = await seedCompany(); + const agent = await seedAutomationAgent(company.id); + const http = request(app(boardActor)); + const stageConfiguredAt = new Date("2026-06-30T17:00:00.000Z"); + const [routine] = await db.insert(routines).values({ + companyId: company.id, + title: "Draft automation", + description: "Draft the item.", + assigneeAgentId: agent.id, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, + }).returning(); + + const [pipeline] = await db.insert(pipelines).values({ + companyId: company.id, + key: "content", + name: "Content", + createdByUserId: "board-user", + }).returning(); + const pipelineId = pipeline!.id; + const [draftStage, reviewStage] = await db.insert(pipelineStages).values([ + { + pipelineId, + key: "draft", + name: "Draft", + kind: "working", + position: 100, + config: { onEnter: { type: "run_routine", routineId: routine!.id } }, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, + }, + { + pipelineId, + key: "review", + name: "Review", + kind: "review", + position: 200, + createdAt: stageConfiguredAt, + updatedAt: stageConfiguredAt, + }, + ]).returning(); + const automationId = `${draftStage!.id}:on_enter`; + const [pipelineCase] = await db.insert(pipelineCases).values({ + companyId: company.id, + pipelineId, + stageId: reviewStage!.id, + caseKey: "fixed-config", + title: "Config was fixed", + }).returning(); + + await db.insert(pipelineAutomationExecutions).values({ + companyId: company.id, + caseId: pipelineCase!.id, + automationId, + triggeringEventId: randomUUID(), + routineId: routine!.id, + status: "failed", + error: "automation_not_configured", + createdAt: new Date("2026-06-30T18:00:00.000Z"), + updatedAt: new Date("2026-06-30T18:00:00.000Z"), + }); + + await http.get(`/api/pipelines/${pipelineId}/health`) + .expect(200) + .expect((res) => { + expect(res.body.warnings).toEqual([ + expect.objectContaining({ + code: "automation_failed", + stageId: draftStage!.id, + href: `/pipelines/${pipelineId}/items/${pipelineCase!.id}`, + }), + ]); + }); + + await db.update(pipelineStages) + .set({ + config: { onEnter: { type: "run_routine", routineId: routine!.id } }, + updatedAt: new Date("2026-06-30T19:00:00.000Z"), + }) + .where(eq(pipelineStages.id, draftStage!.id)); + + const health = await http.get(`/api/pipelines/${pipelineId}/health`).expect(200); + + expect(health.body.warnings.filter((warning: { code: string }) => warning.code === "automation_failed")).toEqual([]); + }); + it("exposes the pipeline and case route surface", async () => { const company = await seedCompany(); const http = request(app(boardActor)); diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts index ed3a78e9743..f5e2f46bc2e 100644 --- a/server/src/routes/pipelines.ts +++ b/server/src/routes/pipelines.ts @@ -1056,6 +1056,7 @@ export function pipelineRoutes(db: Db, options: Parameters [row.id, row.updatedAt])); const bodyByStageId = new Map(); for (const doc of instructionDocs) { @@ -1143,6 +1147,11 @@ export function pipelineRoutes(db: Db, options: Parameters [stage.id, stage])); + const stageByAutomationId = new Map(); + for (const stage of stages) { + const automationId = stageAutomationId(stage); + if (automationId) stageByAutomationId.set(automationId, stage); + } const latestExecutionByCaseAutomation = new Map(); for (const row of automationExecutionRows) { const key = `${row.caseId}:${row.automationId}`; @@ -1152,10 +1161,20 @@ export function pipelineRoutes(db: Db, options: Parameters row.status === "failed") + .filter((row) => { + if (row.status !== "failed") return false; + const automationStage = stageByAutomationId.get(row.automationId); + if (!automationStage) return false; + const routineUpdatedAt = routineUpdatedAtById.get(row.routineId); + const configurationUpdatedAt = Math.max( + automationStage.updatedAt.getTime(), + routineUpdatedAt?.getTime() ?? 0, + ); + return row.updatedAt.getTime() >= configurationUpdatedAt; + }) .map((row) => { const automationStageId = row.automationId.split(":")[0] ?? ""; - const automationStage = stageById.get(automationStageId); + const automationStage = stageByAutomationId.get(row.automationId) ?? stageById.get(automationStageId); return { stageId: automationStage?.id ?? row.currentStageId, stageKey: automationStage?.key ?? row.currentStageKey,