Fix pipeline health automation failure warnings

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 16:32:09 +00:00
1 parent a8f0ebaa80
commit adbbf12403
4 files changed
+189 -14

No files matched your search

@@ -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",
}),
]);
});
});
+4
View File
@@ -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<string>();
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,
@@ -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));
+33 -14
View File
@@ -1018,7 +1018,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
router.get("/pipelines/:pipelineId/health", async (req, res) => {
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<typeof pipelineServic
db.select({
caseId: pipelineCases.id,
caseTitle: pipelineCases.title,
stageId: pipelineStages.id,
stageKey: pipelineStages.key,
stageName: pipelineStages.name,
currentStageId: pipelineStages.id,
currentStageKey: pipelineStages.key,
currentStageName: pipelineStages.name,
automationId: pipelineAutomationExecutions.automationId,
status: pipelineAutomationExecutions.status,
error: pipelineAutomationExecutions.error,
updatedAt: pipelineAutomationExecutions.updatedAt,
createdAt: pipelineAutomationExecutions.createdAt,
})
.from(pipelineAutomationExecutions)
.innerJoin(pipelineCases, eq(pipelineAutomationExecutions.caseId, pipelineCases.id))
@@ -1063,11 +1067,11 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
.where(and(
eq(pipelineAutomationExecutions.companyId, companyId),
eq(pipelineCases.pipelineId, pipelineId),
eq(pipelineAutomationExecutions.status, "failed"),
inArray(pipelineAutomationExecutions.status, ["failed", "succeeded"]),
isNull(pipelineCases.terminalKind),
))
.orderBy(desc(pipelineAutomationExecutions.updatedAt))
.limit(50),
.limit(200),
]);
if (!pipeline) throw notFound("Pipeline not found");
@@ -1138,14 +1142,29 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
instructionsBody: automation?.instructionsBody ?? bodyByStageId.get(stage.id) ?? "",
};
});
const failedAutomations: PipelineHealthFailedAutomationInput[] = failedAutomationRows.map((row) => ({
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<string, typeof automationExecutionRows[number]>();
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 }));
});