From adbbf124035134e265ac90048ecf86b6a9bb2634 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 16:32:09 +0000 Subject: [PATCH] Fix pipeline health automation failure warnings Co-Authored-By: Paperclip --- packages/shared/src/pipeline-health.test.ts | 40 +++++++ packages/shared/src/pipeline-health.ts | 4 + server/src/__tests__/pipelines-routes.test.ts | 112 ++++++++++++++++++ server/src/routes/pipelines.ts | 47 +++++--- 4 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/pipeline-health.test.ts diff --git a/packages/shared/src/pipeline-health.test.ts b/packages/shared/src/pipeline-health.test.ts new file mode 100644 index 00000000000..992b3cac845 --- /dev/null +++ b/packages/shared/src/pipeline-health.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { computePipelineHealth } from "./pipeline-health.js"; + +describe("computePipelineHealth", () => { + it("dedupes repeated failed automation warnings for the same item and stage", () => { + const report = computePipelineHealth({ + pipelineId: "pipeline-1", + stages: [], + agentsById: {}, + pipelinesById: {}, + failedAutomations: [ + { + stageId: "stage-1", + stageKey: "assembly", + stageName: "Assembly", + caseId: "case-1", + caseTitle: "Watchdog blog", + error: "automation_not_configured", + }, + { + stageId: "stage-1", + stageKey: "assembly", + stageName: "Assembly", + caseId: "case-1", + caseTitle: "Watchdog blog", + error: "automation_not_configured", + }, + ], + }); + + expect(report.warnings).toEqual([ + expect.objectContaining({ + code: "automation_failed", + stageId: "stage-1", + stageName: "Assembly", + href: "/pipelines/pipeline-1/items/case-1", + }), + ]); + }); +}); diff --git a/packages/shared/src/pipeline-health.ts b/packages/shared/src/pipeline-health.ts index c2b8a947536..6f56bd2ff9e 100644 --- a/packages/shared/src/pipeline-health.ts +++ b/packages/shared/src/pipeline-health.ts @@ -339,7 +339,11 @@ export function computePipelineHealth(input: PipelineHealthInput): PipelineHealt // the pipeline; a blank default in settings is a normal configuration. } + const seenFailedAutomationWarnings = new Set(); for (const failure of input.failedAutomations ?? []) { + const dedupeKey = `${failure.stageId}:${failure.caseId}`; + if (seenFailedAutomationWarnings.has(dedupeKey)) continue; + seenFailedAutomationWarnings.add(dedupeKey); warnings.push({ code: "automation_failed", stageId: failure.stageId, diff --git a/server/src/__tests__/pipelines-routes.test.ts b/server/src/__tests__/pipelines-routes.test.ts index cfdc6c1fb4e..5ec32ef262b 100644 --- a/server/src/__tests__/pipelines-routes.test.ts +++ b/server/src/__tests__/pipelines-routes.test.ts @@ -171,6 +171,118 @@ describeEmbeddedPostgres("pipeline routes", () => { isInstanceAdmin: true, }; + it("reports only latest unresolved automation failures from the automation stage", async () => { + const company = await seedCompany(); + const agent = await seedAutomationAgent(company.id); + const http = request(app(boardActor)); + const [routine] = await db.insert(routines).values({ + companyId: company.id, + title: "Assembly automation", + description: "Assemble the item.", + assigneeAgentId: agent.id, + }).returning(); + + const [pipeline] = await db.insert(pipelines).values({ + companyId: company.id, + key: "content", + name: "Content", + createdByUserId: "board-user", + }).returning(); + const pipelineId = pipeline!.id; + const [assemblyStage, reviewStage] = await db.insert(pipelineStages).values([ + { + pipelineId, + key: "assembly", + name: "Assembly", + kind: "working", + position: 100, + config: { onEnter: { type: "run_routine", routineId: routine!.id } }, + }, + { + pipelineId, + key: "content-review", + name: "Content Review", + kind: "review", + position: 200, + }, + ]).returning(); + const automationId = `${assemblyStage.id}:on_enter`; + const [resolvedCase, unresolvedCase] = await db.insert(pipelineCases).values([ + { + companyId: company.id, + pipelineId, + stageId: reviewStage.id, + caseKey: "resolved", + title: "Resolved automation", + }, + { + companyId: company.id, + pipelineId, + stageId: reviewStage.id, + caseKey: "unresolved", + title: "Unresolved automation", + }, + ]).returning(); + + await db.insert(pipelineAutomationExecutions).values([ + { + companyId: company.id, + caseId: resolvedCase!.id, + automationId, + triggeringEventId: randomUUID(), + routineId: routine!.id, + status: "failed", + error: "old failure", + createdAt: new Date("2026-06-30T18:00:00.000Z"), + updatedAt: new Date("2026-06-30T18:00:00.000Z"), + }, + { + companyId: company.id, + caseId: resolvedCase!.id, + automationId, + triggeringEventId: randomUUID(), + routineId: routine!.id, + status: "succeeded", + createdAt: new Date("2026-06-30T22:00:00.000Z"), + updatedAt: new Date("2026-06-30T22:00:00.000Z"), + }, + { + companyId: company.id, + caseId: unresolvedCase!.id, + automationId, + triggeringEventId: randomUUID(), + routineId: routine!.id, + status: "failed", + error: "first failure", + createdAt: new Date("2026-06-30T20:00:00.000Z"), + updatedAt: new Date("2026-06-30T20:00:00.000Z"), + }, + { + companyId: company.id, + caseId: unresolvedCase!.id, + automationId, + triggeringEventId: randomUUID(), + routineId: routine!.id, + status: "failed", + error: "latest failure", + createdAt: new Date("2026-06-30T21:00:00.000Z"), + updatedAt: new Date("2026-06-30T21:00:00.000Z"), + }, + ]); + + const health = await http.get(`/api/pipelines/${pipelineId}/health`).expect(200); + + expect(health.body.warnings).toEqual([ + expect.objectContaining({ + code: "automation_failed", + stageId: assemblyStage.id, + stageKey: "assembly", + stageName: "Assembly", + href: `/pipelines/${pipelineId}/items/${unresolvedCase!.id}`, + }), + ]); + }); + 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 6f4f2047a8d..ed3a78e9743 100644 --- a/server/src/routes/pipelines.ts +++ b/server/src/routes/pipelines.ts @@ -1018,7 +1018,7 @@ export function pipelineRoutes(db: Db, options: Parameters { const pipelineId = req.params.pipelineId as string; const companyId = await assertPipelineAccess(db, req, pipelineId); - const [pipeline, stages, instructionDocs, companyAgents, companyPipelines, companyStages, failedAutomationRows] = await Promise.all([ + const [pipeline, stages, instructionDocs, companyAgents, companyPipelines, companyStages, automationExecutionRows] = await Promise.all([ db.select().from(pipelines) .where(and(eq(pipelines.id, pipelineId), eq(pipelines.companyId, companyId))) .then((rows) => rows[0] ?? null), @@ -1052,10 +1052,14 @@ export function pipelineRoutes(db: Db, options: Parameters ({ - stageId: row.stageId, - stageKey: row.stageKey, - stageName: row.stageName, - caseId: row.caseId, - caseTitle: row.caseTitle, - error: row.error, - })); + const stageById = new Map(stages.map((stage) => [stage.id, stage])); + const latestExecutionByCaseAutomation = new Map(); + for (const row of automationExecutionRows) { + const key = `${row.caseId}:${row.automationId}`; + const current = latestExecutionByCaseAutomation.get(key); + if (!current || row.updatedAt > current.updatedAt || (row.updatedAt.getTime() === current.updatedAt.getTime() && row.createdAt > current.createdAt)) { + latestExecutionByCaseAutomation.set(key, row); + } + } + const failedAutomations: PipelineHealthFailedAutomationInput[] = [...latestExecutionByCaseAutomation.values()] + .filter((row) => row.status === "failed") + .map((row) => { + const automationStageId = row.automationId.split(":")[0] ?? ""; + const automationStage = stageById.get(automationStageId); + return { + stageId: automationStage?.id ?? row.currentStageId, + stageKey: automationStage?.key ?? row.currentStageKey, + stageName: automationStage?.name ?? row.currentStageName, + caseId: row.caseId, + caseTitle: row.caseTitle, + error: row.error, + }; + }); res.json(computePipelineHealth({ pipelineId, stages: healthStages, agentsById, pipelinesById, failedAutomations })); });