mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Fix pipeline health automation failure warnings
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user