Rescue pipeline coverage from PR 7907

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-06 20:21:48 +00:00
1 parent 903886bc79
commit bf1d1d0b75
19 files changed
+4695

No files matched your search

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { caseTypeMatchesPipeline, deriveCaseType } from "./pipeline-case-type.js";
describe("deriveCaseType", () => {
it("uses the pipeline key as the type", () => {
expect(deriveCaseType({ id: "p1", key: "content_production" })).toBe("content_production");
});
it("trims whitespace", () => {
expect(deriveCaseType({ id: "p1", key: " release_coverage " })).toBe("release_coverage");
});
it("falls back to the pipeline id when there is no key", () => {
expect(deriveCaseType({ id: "p1", key: null })).toBe("p1");
expect(deriveCaseType({ id: "p1" })).toBe("p1");
});
});
describe("caseTypeMatchesPipeline", () => {
const pipeline = { id: "p1", key: "content" };
it("passes when nothing is declared", () => {
expect(caseTypeMatchesPipeline(undefined, pipeline)).toBe(true);
expect(caseTypeMatchesPipeline(null, pipeline)).toBe(true);
expect(caseTypeMatchesPipeline("", pipeline)).toBe(true);
});
it("passes when the declared type agrees with the pipeline", () => {
expect(caseTypeMatchesPipeline("content", pipeline)).toBe(true);
});
it("fails when the declared type disagrees", () => {
expect(caseTypeMatchesPipeline("release", pipeline)).toBe(false);
});
});
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { pipelineStageConfigSchema } from "./pipeline.js";
describe("pipeline stage variable schema", () => {
it("validates select variables require options", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [{ key: "status", label: "Status", type: "select", options: ["open", "done"] }],
}).success,
).toBe(true);
expect(
pipelineStageConfigSchema.safeParse({
variables: [{ key: "status", label: "Status", type: "select", options: [] }],
}).success,
).toBe(false);
});
it("enforces unique variable keys in stage config", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [
{ key: "repo", label: "Repo", type: "text" },
{ key: "repo", label: "Repo", type: "text" },
],
}).success,
).toBe(false);
});
it("accepts disabled and approval settings stored in stage config", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [
{
key: "customer",
label: "Customer",
type: "text",
options: [],
required: true,
showInAddForm: true,
},
],
disabled: true,
disabledReason: "Pause intake while the team clears the queue.",
requireApproval: true,
approver: { kind: "agent", id: "agent-1" },
whatHappensHere: "Triage every incoming item before work starts.",
}).success,
).toBe(true);
});
it("requires an id when the approver is a specific user or agent", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
requireApproval: true,
approver: { kind: "user" },
}).success,
).toBe(false);
});
it("accepts a run_routine onEnter action", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
onEnter: {
type: "run_routine",
routineId: "11111111-1111-4111-8111-111111111111",
},
}).success,
).toBe(true);
});
it("accepts versioned breakdown carry-over policy", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
breakdown: {
targetPipelineId: "11111111-1111-4111-8111-111111111111",
targetStageKey: "intake",
carryOverPolicy: {
version: 1,
mode: "all_except",
excludeFields: ["title", "internalNote"],
},
},
}).success,
).toBe(true);
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
breakdown: {
targetPipelineId: "11111111-1111-4111-8111-111111111111",
targetStageKey: "intake",
carryOverPolicy: {
version: 2,
mode: "all_except",
},
},
}).success,
).toBe(false);
});
it("rejects an onEnter run_routine action without a valid routine id", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
onEnter: {
type: "run_routine",
routineId: "not-a-uuid",
},
}).success,
).toBe(false);
});
it("still accepts the legacy reviewerKind input", () => {
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
reviewerKind: "human",
}).success,
).toBe(true);
expect(
pipelineStageConfigSchema.safeParse({
variables: [],
reviewerKind: "robot",
}).success,
).toBe(false);
});
});
@@ -0,0 +1,502 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
createDb,
issueComments,
issues,
pipelineAutomationExecutions,
pipelineCaseBlockers,
pipelineCaseEvents,
pipelineCaseIssueLinks,
pipelineCases,
pipelineStages,
pipelineTransitions,
pipelines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/error-handler.js";
import { pipelineRoutes } from "../routes/pipelines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres pipeline aggregation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("pipeline aggregation routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const noopHeartbeat = { wakeup: async () => null };
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-pipelines-aggregation-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(pipelineAutomationExecutions);
await db.delete(pipelineCaseBlockers);
await db.delete(pipelineCaseIssueLinks);
await db.delete(pipelineCaseEvents);
await db.delete(pipelineCases);
await db.delete(pipelineTransitions);
await db.delete(pipelineStages);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(pipelines);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function app(actor: Express.Request["actor"]) {
const instance = express();
instance.use(express.json());
instance.use((req, _res, next) => {
req.actor = actor;
next();
});
instance.use("/api", pipelineRoutes(db, { heartbeat: noopHeartbeat }));
instance.use(errorHandler);
return instance;
}
async function seedCompany(name = "Aggregation Co") {
const [company] = await db.insert(companies).values({
name,
issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`,
}).returning();
return company!;
}
async function seedAgent(companyId: string, name = "Pipeline Agent") {
const [agent] = await db.insert(agents).values({
companyId,
name,
role: "engineer",
adapterType: "codex_local",
}).returning();
return agent!;
}
const boardActor: Express.Request["actor"] = {
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
};
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
return {
type: "agent",
agentId,
companyId,
runId: randomUUID(),
source: "agent_key",
};
}
const reviewStages = [
{ key: "intake", name: "Intake", kind: "open", position: 100 },
{ key: "drafting", name: "Drafting", kind: "working", position: 200 },
{
key: "review_human",
name: "Final review",
kind: "review",
position: 300,
config: { approveToStageKey: "done", rejectToStageKey: "cancelled", requestChangesToStageKey: "drafting", requireRejectReason: true, reviewerKind: "human" },
},
{
key: "review_any",
name: "Open review",
kind: "review",
position: 400,
config: { approveToStageKey: "done", rejectToStageKey: "cancelled", requireRejectReason: false, reviewerKind: "any" },
},
{ key: "done", name: "Done", kind: "done", position: 900 },
{ key: "cancelled", name: "Cancelled", kind: "cancelled", position: 1000 },
];
async function seedPipeline(http: ReturnType<typeof request>, companyId: string, key: string, name: string) {
const created = await http
.post(`/api/companies/${companyId}/pipelines`)
.send({ key, name, stages: reviewStages })
.expect(201);
return created.body as { id: string; stages: Array<{ id: string; key: string }> };
}
it("groups attention into suggestions, reviews, and heads-up with approver filtering", async () => {
const company = await seedCompany();
const agent = await seedAgent(company.id, "Drafting Agent");
const board = request(app(boardActor));
const asAgent = request(app(agentActor(company.id, agent.id)));
const pipeline = await seedPipeline(board, company.id, "content", "Content production");
const suggested = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "suggested", title: "Launch blog post" })
.expect(201);
await asAgent
.post(`/api/cases/${suggested.body.case.id}/suggest-transition`)
.send({ toStageKey: "review_human", rationale: "Draft is ready for review", confidence: 0.9 })
.expect(200);
const humanReview = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "human-review", title: "Needs a human decision" })
.expect(201);
await board
.post(`/api/cases/${humanReview.body.case.id}/transition`)
.send({ toStageKey: "review_human", expectedVersion: 1 })
.expect(200);
const anyReview = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "any-review", title: "Anyone can decide" })
.expect(201);
await board
.post(`/api/cases/${anyReview.body.case.id}/transition`)
.send({ toStageKey: "review_any", expectedVersion: 1 })
.expect(200);
const upstream = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "upstream", title: "Release plan" })
.expect(201);
const dependent = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "dependent", title: "Dependent draft", blockedByCaseIds: [upstream.body.case.id] })
.expect(201);
const [workIssue] = await db.insert(issues).values({
companyId: company.id,
title: "Write dependent draft",
status: "in_progress",
priority: "medium",
assigneeAgentId: agent.id,
}).returning();
await board
.post(`/api/cases/${dependent.body.case.id}/issue-links`)
.send({ issueId: workIssue!.id, role: "work" })
.expect(201);
await board
.patch(`/api/cases/${upstream.body.case.id}`)
.send({ fields: { releaseNotes: "Scope changed" }, expectedVersion: 1 })
.expect(200);
const boardFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200);
expect(boardFeed.body.counts).toEqual({ suggestions: 1, reviews: 2, headsUp: 1 });
const suggestion = boardFeed.body.suggestions[0];
expect(suggestion.case.id).toBe(suggested.body.case.id);
expect(suggestion.case.pipeline.name).toBe("Content production");
expect(suggestion.suggestion.toStageKey).toBe("review_human");
expect(suggestion.suggestion.toStageName).toBe("Final review");
expect(suggestion.suggestion.rationale).toBe("Draft is ready for review");
expect(suggestion.suggestion.suggestedBy).toEqual({ agentId: agent.id, agentName: "Drafting Agent" });
const reviewCaseIds = boardFeed.body.reviews.map((entry: { case: { id: string } }) => entry.case.id);
expect(reviewCaseIds).toEqual(expect.arrayContaining([humanReview.body.case.id, anyReview.body.case.id]));
const humanEntry = boardFeed.body.reviews.find((entry: { case: { id: string } }) => entry.case.id === humanReview.body.case.id);
expect(humanEntry.review).toMatchObject({
reviewerKind: "human",
approveToStageKey: "done",
rejectToStageKey: "cancelled",
requestChangesToStageKey: "drafting",
requireRejectReason: true,
expectedVersion: 2,
});
const headsUp = boardFeed.body.headsUp[0];
expect(headsUp.case.id).toBe(dependent.body.case.id);
expect(headsUp.drift.upstream).toMatchObject({
caseId: upstream.body.case.id,
caseKey: "upstream",
title: "Release plan",
pipelineId: pipeline.id,
pipelineName: "Content production",
});
expect(headsUp.drift.previousVersion).toBe(1);
expect(headsUp.drift.version).toBe(2);
expect(headsUp.workIssue).toMatchObject({ issueId: workIssue!.id, status: "in_progress" });
expect(headsUp.activeWork).toMatchObject({ issueId: workIssue!.id, agentId: agent.id, agentName: "Drafting Agent" });
// Agent callers only await review stages with reviewerKind "any".
const agentFeed = await asAgent.get(`/api/companies/${company.id}/pipelines-attention`).expect(200);
expect(agentFeed.body.reviews.map((entry: { case: { id: string } }) => entry.case.id)).toEqual([anyReview.body.case.id]);
// Touching the dependent case does not resolve the drift notice; it must be acknowledged.
await board
.patch(`/api/cases/${dependent.body.case.id}`)
.send({ title: "Dependent draft v2", expectedVersion: 1 })
.expect(200);
const stillDriftedFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200);
expect(stillDriftedFeed.body.headsUp).toHaveLength(1);
await board
.post(`/api/cases/${dependent.body.case.id}/acknowledge-drift`)
.send({ expectedVersion: 2 })
.expect(200);
const resolvedFeed = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200);
expect(resolvedFeed.body.headsUp).toEqual([]);
// Terminal cases drop out of the suggestions group.
await board
.post(`/api/cases/${anyReview.body.case.id}/review`)
.send({ decision: "approve", expectedVersion: 2 })
.expect(200);
const afterApprove = await board.get(`/api/companies/${company.id}/pipelines-attention`).expect(200);
expect(afterApprove.body.reviews.map((entry: { case: { id: string } }) => entry.case.id)).toEqual([humanReview.body.case.id]);
});
it("serves the company-wide learnings feed with joins, filtering, and pagination", async () => {
const company = await seedCompany();
const board = request(app(boardActor));
const pipeline = await seedPipeline(board, company.id, "content", "Content production");
const first = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "first", title: "First item" })
.expect(201);
await board
.post(`/api/cases/${first.body.case.id}/transition`)
.send({ toStageKey: "review_human", expectedVersion: 1 })
.expect(200);
await board
.post(`/api/cases/${first.body.case.id}/review`)
.send({ decision: "reject", reason: "Wrong audience", expectedVersion: 2 })
.expect(200);
const second = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "second", title: "Second item" })
.expect(201);
await board
.post(`/api/cases/${second.body.case.id}/transition`)
.send({ toStageKey: "review_human", expectedVersion: 1 })
.expect(200);
await board
.post(`/api/cases/${second.body.case.id}/review`)
.send({ decision: "request_changes", reason: "Tighten the intro", expectedVersion: 2 })
.expect(200);
const feed = await board
.get(`/api/companies/${company.id}/case-events?types=review_decided`)
.expect(200);
expect(feed.body.items).toHaveLength(2);
expect(feed.body.items.every((item: { type: string }) => item.type === "review_decided")).toBe(true);
// Newest first.
expect(feed.body.items[0].case.caseKey).toBe("second");
expect(feed.body.items[0].payload).toMatchObject({ decision: "request_changes", reason: "Tighten the intro" });
expect(feed.body.items[0].pipeline).toMatchObject({ id: pipeline.id, name: "Content production" });
expect(feed.body.items[0].fromStage).toMatchObject({ key: "review_human" });
expect(feed.body.items[0].toStage).toMatchObject({ key: "drafting" });
expect(feed.body.items[1].case.caseKey).toBe("first");
expect(feed.body.items[1].payload).toMatchObject({ decision: "reject", reason: "Wrong audience" });
const page = await board
.get(`/api/companies/${company.id}/case-events?types=review_decided,transition_forced&limit=1`)
.expect(200);
expect(page.body.items).toHaveLength(1);
expect(page.body.pagination).toMatchObject({ limit: 1, offset: 0, nextOffset: 1, hasMore: true });
const nextPage = await board
.get(`/api/companies/${company.id}/case-events?types=review_decided&limit=1&offset=1`)
.expect(200);
expect(nextPage.body.items[0].case.caseKey).toBe("first");
expect(nextPage.body.pagination.hasMore).toBe(false);
await board.get(`/api/companies/${company.id}/case-events?types=Bad%20Type!`).expect(400);
await board.get(`/api/companies/${company.id}/case-events?limit=0`).expect(400);
});
it("returns the recursive children tree with per-node rollups and a flat summary", async () => {
const company = await seedCompany();
const board = request(app(boardActor));
const release = await seedPipeline(board, company.id, "release", "Release train");
const content = await seedPipeline(board, company.id, "content", "Content production");
const parent = await board
.post(`/api/pipelines/${release.id}/cases`)
.send({ caseKey: "launch", title: "Launch v2" })
.expect(201);
const doneChild = await board
.post(`/api/pipelines/${content.id}/cases`)
.send({ caseKey: "blog", title: "Blog post", parentCaseId: parent.body.case.id })
.expect(201);
await board
.post(`/api/cases/${doneChild.body.case.id}/transition`)
.send({ toStageKey: "done", expectedVersion: 1 })
.expect(200);
const openChild = await board
.post(`/api/pipelines/${content.id}/cases`)
.send({ caseKey: "video", title: "Video", parentCaseId: parent.body.case.id })
.expect(201);
const droppedGrandchild = await board
.post(`/api/pipelines/${content.id}/cases`)
.send({ caseKey: "short", title: "Short clip", parentCaseId: openChild.body.case.id })
.expect(201);
await board
.post(`/api/cases/${droppedGrandchild.body.case.id}/transition`)
.send({ toStageKey: "cancelled", expectedVersion: 1 })
.expect(200);
const tree = await board.get(`/api/cases/${parent.body.case.id}/children/tree`).expect(200);
expect(tree.body.rollup).toEqual({ total: 3, done: 1, dropped: 1, inMotion: 1 });
expect(tree.body.truncated).toBe(false);
expect(tree.body.totalNodes).toBe(4);
expect(tree.body.case.pipeline).toMatchObject({ id: release.id, name: "Release train" });
expect(tree.body.childGroups).toHaveLength(1);
const group = tree.body.childGroups[0];
expect(group.pipeline).toMatchObject({ id: content.id, name: "Content production" });
expect(group.cases.map((node: { caseKey: string }) => node.caseKey)).toEqual(["blog", "video"]);
const blogNode = group.cases[0];
expect(blogNode.terminalKind).toBe("done");
expect(blogNode.stage).toMatchObject({ key: "done", kind: "done" });
expect(blogNode.rollup).toEqual({ total: 0, done: 0, dropped: 0, inMotion: 0 });
const videoNode = group.cases[1];
expect(videoNode.rollup).toEqual({ total: 1, done: 0, dropped: 1, inMotion: 0 });
expect(videoNode.childGroups[0].cases[0]).toMatchObject({ caseKey: "short", terminalKind: "cancelled" });
const detail = await board.get(`/api/cases/${parent.body.case.id}`).expect(200);
expect(detail.body.childrenSummary).toMatchObject({ total: 2, done: 1, dropped: 0, inMotion: 1 });
await board.get(`/api/cases/${randomUUID()}/children`).expect(404);
});
it("enriches case detail and pipeline case lists with activeWork", async () => {
const company = await seedCompany();
const agent = await seedAgent(company.id, "Working Agent");
const board = request(app(boardActor));
const pipeline = await seedPipeline(board, company.id, "content", "Content production");
const idleCase = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "idle", title: "Idle item" })
.expect(201);
const workedCase = await board
.post(`/api/pipelines/${pipeline.id}/cases`)
.send({ caseKey: "worked", title: "Worked item" })
.expect(201);
const [todoIssue] = await db.insert(issues).values({
companyId: company.id,
title: "Not started yet",
status: "todo",
priority: "medium",
assigneeAgentId: agent.id,
}).returning();
const [activeIssue] = await db.insert(issues).values({
companyId: company.id,
title: "Write the draft",
identifier: "AGG-1",
status: "in_progress",
priority: "medium",
assigneeAgentId: agent.id,
startedAt: new Date("2026-06-10T12:00:00Z"),
}).returning();
await board
.post(`/api/cases/${workedCase.body.case.id}/issue-links`)
.send({ issueId: todoIssue!.id, role: "work" })
.expect(201);
await board
.post(`/api/cases/${workedCase.body.case.id}/issue-links`)
.send({ issueId: activeIssue!.id, role: "work" })
.expect(201);
const detail = await board.get(`/api/cases/${workedCase.body.case.id}`).expect(200);
expect(detail.body.activeWork).toMatchObject({
issueId: activeIssue!.id,
issueIdentifier: "AGG-1",
issueTitle: "Write the draft",
agentId: agent.id,
agentName: "Working Agent",
});
expect(detail.body.activeWork.startedAt).toBeTruthy();
const idleDetail = await board.get(`/api/cases/${idleCase.body.case.id}`).expect(200);
expect(idleDetail.body.activeWork).toBeNull();
const list = await board.get(`/api/pipelines/${pipeline.id}/cases`).expect(200);
const listedWorked = list.body.find((row: { case: { id: string } }) => row.case.id === workedCase.body.case.id);
const listedIdle = list.body.find((row: { case: { id: string } }) => row.case.id === idleCase.body.case.id);
expect(listedWorked.activeWork).toMatchObject({ issueId: activeIssue!.id, agentName: "Working Agent" });
expect(listedIdle.activeWork).toBeNull();
});
it("derives pipeline connections from observed cross-pipeline case parentage", async () => {
const company = await seedCompany();
const board = request(app(boardActor));
const release = await seedPipeline(board, company.id, "release", "Release train");
const content = await seedPipeline(board, company.id, "content", "Content production");
const assets = await seedPipeline(board, company.id, "assets", "Asset production");
const lonely = await seedPipeline(board, company.id, "lonely", "Unconnected");
const releaseCase = await board
.post(`/api/pipelines/${release.id}/cases`)
.send({ caseKey: "launch", title: "Launch" })
.expect(201);
const contentCase = await board
.post(`/api/pipelines/${content.id}/cases`)
.send({ caseKey: "blog", title: "Blog", parentCaseId: releaseCase.body.case.id })
.expect(201);
await board
.post(`/api/pipelines/${assets.id}/cases`)
.send({ caseKey: "thumb", title: "Thumbnail", parentCaseId: contentCase.body.case.id })
.expect(201);
// Same-pipeline parentage must not create a self connection.
await board
.post(`/api/pipelines/${content.id}/cases`)
.send({ caseKey: "blog-child", title: "Blog child", parentCaseId: contentCase.body.case.id })
.expect(201);
// Move one case into a working stage and one into a review stage so the
// list response exposes board-facing activity counts.
await board
.post(`/api/cases/${releaseCase.body.case.id}/transition`)
.send({ toStageKey: "drafting", expectedVersion: releaseCase.body.case.version })
.expect(200);
await board
.post(`/api/cases/${contentCase.body.case.id}/transition`)
.send({ toStageKey: "review_human", expectedVersion: contentCase.body.case.version })
.expect(200);
const listed = await board.get(`/api/companies/${company.id}/pipelines`).expect(200);
const byId = new Map(listed.body.map((row: { id: string }) => [row.id, row]));
expect(byId.get(release.id)).toMatchObject({ openCaseCount: 1, attentionCount: 0, inMotionCount: 1 });
expect(byId.get(content.id)).toMatchObject({ openCaseCount: 2, attentionCount: 1, inMotionCount: 1 });
expect(byId.get(assets.id)).toMatchObject({ openCaseCount: 1, attentionCount: 0, inMotionCount: 1 });
expect(byId.get(lonely.id)).toMatchObject({ openCaseCount: 0, attentionCount: 0, inMotionCount: 0, lastActivityAt: null });
expect((byId.get(content.id) as { lastActivityAt: unknown }).lastActivityAt).toBeTruthy();
expect((byId.get(release.id) as { connections: unknown }).connections).toEqual({
upstreamPipelineIds: [],
downstreamPipelineIds: [content.id],
});
expect((byId.get(content.id) as { connections: unknown }).connections).toEqual({
upstreamPipelineIds: [release.id],
downstreamPipelineIds: [assets.id],
});
expect((byId.get(assets.id) as { connections: unknown }).connections).toEqual({
upstreamPipelineIds: [content.id],
downstreamPipelineIds: [],
});
expect((byId.get(lonely.id) as { connections: unknown }).connections).toEqual({
upstreamPipelineIds: [],
downstreamPipelineIds: [],
});
});
});
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PipelineCaseLiveness } from "@paperclipai/shared";
import { PipelineLivenessBanner } from "./PipelineLivenessBanner";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act<T>(callback: () => T): T {
let result: T | undefined;
flushSync(() => {
result = callback();
});
return result as T;
}
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function liveness(overrides: Partial<PipelineCaseLiveness>): PipelineCaseLiveness {
return { state: "attention", reason: "no_action_path", message: "stub", ...overrides } as PipelineCaseLiveness;
}
describe("PipelineLivenessBanner", () => {
it("renders nothing for a running item", () => {
const node = render(<PipelineLivenessBanner liveness={liveness({ reason: "linked_issue_active" })} />);
expect(node.querySelector("section")).toBeNull();
});
it("renders a blocked banner with a blocking-issue link and no retry", () => {
const node = render(
<PipelineLivenessBanner
liveness={liveness({
state: "blocked",
reason: "linked_issue_blocked",
message: "Linked automation issue is blocked.",
issue: { id: "auto-1", identifier: "PAP-900", title: "Build", status: "blocked" },
blocker: { issueId: "blk-1", title: "Waiting on legal", status: "in_progress" },
})}
onRetry={() => {}}
/>,
);
const section = node.querySelector("section");
expect(section?.getAttribute("aria-label")).toMatch(/Automation paused/);
const links = Array.from(node.querySelectorAll("a")).map((a) => a.getAttribute("href"));
expect(links).toContain("/issues/blk-1");
expect(links).toContain("/issues/PAP-900");
expect(node.querySelector("button")).toBeNull();
});
it("invokes onRetry with the automation kind for restored permission", () => {
const onRetry = vi.fn();
const node = render(
<PipelineLivenessBanner
liveness={liveness({
reason: "automation_failed",
message: "Pipeline automation permission has been restored; retry the failed automation ledger.",
automation: { automationId: "auto-3" },
})}
onRetry={onRetry}
/>,
);
const button = node.querySelector("button");
expect(button?.textContent).toMatch(/Retry now/);
click(button);
expect(onRetry).toHaveBeenCalledWith("automation");
});
it("surfaces a retry error inline via role=alert", () => {
const node = render(
<PipelineLivenessBanner
liveness={liveness({ reason: "automation_failed", message: "Automation failed.", automation: { automationId: null } })}
onRetry={() => {}}
retryError="Forbidden: missing pipelines:write"
/>,
);
const alert = node.querySelector('[role="alert"]');
expect(alert?.textContent).toMatch(/Forbidden/);
});
it("disables the retry button while pending", () => {
const node = render(
<PipelineLivenessBanner
liveness={liveness({ reason: "no_action_path" })}
onRetry={() => {}}
retryPending
/>,
);
const button = node.querySelector("button") as HTMLButtonElement | null;
expect(button?.disabled).toBe(true);
expect(button?.textContent).toMatch(/Retrying/);
});
});
@@ -0,0 +1,193 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanySecret, RoutineEnvConfig } from "@paperclipai/shared";
import { StageSecretsPanel } from "./StageSecretsPanel";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function secret(partial: Partial<CompanySecret> & { id: string; name: string }): CompanySecret {
return {
companyId: "company-1",
key: partial.name.toLowerCase(),
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
...partial,
} as CompanySecret;
}
const SECRETS: CompanySecret[] = [
secret({ id: "secret-gh", name: "GH_TOKEN", latestVersion: 2 }),
secret({ id: "secret-disabled", name: "OLD_KEY", status: "disabled" }),
];
const noop = () => {};
const asyncNoop = async () => SECRETS[0]!;
describe("StageSecretsPanel", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
function render(node: React.ReactNode) {
const root = createRoot(container);
flushSync(() => {
root.render(node as React.ReactElement);
});
return root;
}
it("shows the no-automation empty state with a jump-to-automation action", () => {
const onSetup = vi.fn();
render(
<StageSecretsPanel
hasAutomation={false}
secrets={SECRETS}
secretsLoading={false}
value={{}}
onChange={noop}
onCreateSecret={asyncNoop}
onSetupAutomation={onSetup}
onSave={noop}
saving={false}
dirty={false}
/>,
);
expect(container.textContent).toContain("Secrets are available only to step automation");
const button = container.querySelector("button");
expect(button?.textContent).toContain("Set up automation");
flushSync(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSetup).toHaveBeenCalledTimes(1);
// It must NOT render the env editor when there is no automation.
expect(container.querySelector('input[placeholder="KEY"]')).toBeNull();
});
it("renders the configured state with the agent name, precedence copy, and env editor", () => {
const value: RoutineEnvConfig = {
GH_TOKEN: { type: "secret_ref", secretId: "secret-gh", version: "latest" },
};
render(
<StageSecretsPanel
hasAutomation
agentName="Releasebot"
agentIcon="bot"
secrets={SECRETS}
secretsLoading={false}
value={value}
onChange={noop}
onCreateSecret={asyncNoop}
onSetupAutomation={noop}
onSave={noop}
saving={false}
dirty={false}
/>,
);
expect(container.textContent).toContain("Releasebot");
expect(container.textContent).toContain("override matching project and agent env");
// EnvVarEditor mounts a KEY input.
expect(container.querySelector('input[placeholder="KEY"]')).not.toBeNull();
// Save disabled while not dirty.
const saveButton = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Save secrets"),
);
expect(saveButton).toBeTruthy();
expect((saveButton as HTMLButtonElement).disabled).toBe(true);
});
it("surfaces a warning for a disabled/missing secret binding", () => {
const value: RoutineEnvConfig = {
OLD_KEY: { type: "secret_ref", secretId: "secret-disabled", version: "latest" },
GONE: { type: "secret_ref", secretId: "does-not-exist", version: "latest" },
};
render(
<StageSecretsPanel
hasAutomation
agentName="Releasebot"
secrets={SECRETS}
secretsLoading={false}
value={value}
onChange={noop}
onCreateSecret={asyncNoop}
onSetupAutomation={noop}
onSave={noop}
saving={false}
dirty={false}
/>,
);
expect(container.textContent).toContain("need attention");
expect(container.textContent).toContain("disabled");
expect(container.textContent).toContain("Missing secret");
});
it("enables Save when dirty and fires onSave", () => {
const onSave = vi.fn();
render(
<StageSecretsPanel
hasAutomation
agentName="Releasebot"
secrets={SECRETS}
secretsLoading={false}
value={{ GH_TOKEN: { type: "secret_ref", secretId: "secret-gh", version: "latest" } }}
onChange={noop}
onCreateSecret={asyncNoop}
onSetupAutomation={noop}
onSave={onSave}
saving={false}
dirty
/>,
);
const saveButton = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Save secrets"),
) as HTMLButtonElement;
expect(saveButton.disabled).toBe(false);
expect(container.textContent).toContain("Unsaved changes");
flushSync(() => {
saveButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSave).toHaveBeenCalledTimes(1);
});
it("shows a loading message instead of the editor while secrets load", () => {
render(
<StageSecretsPanel
hasAutomation
agentName="Releasebot"
secrets={[]}
secretsLoading
value={{}}
onChange={noop}
onCreateSecret={asyncNoop}
onSetupAutomation={noop}
onSave={noop}
saving={false}
dirty={false}
/>,
);
expect(container.textContent).toContain("Loading secrets");
expect(container.querySelector('input[placeholder="KEY"]')).toBeNull();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import {
breakdownMechanicsBullets,
breakdownSummarySentence,
joinWithAnd,
pieceNounPlural,
readStageBreakdown,
type BreakdownCopyNames,
type StageBreakdownConfig,
} from "./pipeline-breakdown";
describe("readStageBreakdown", () => {
it("returns null when there is no breakdown block", () => {
expect(readStageBreakdown({ config: {} })).toBeNull();
expect(readStageBreakdown({ config: null })).toBeNull();
expect(readStageBreakdown(null)).toBeNull();
});
it("reads a full breakdown config and trims/defaults fields", () => {
const config = readStageBreakdown({
config: {
breakdown: {
targetPipelineId: " pipe-1 ",
targetStageKey: " intake ",
pieceNoun: " feature ",
inheritFields: ["release", "", " ", "owner"],
advanceTo: "review",
waitForPieces: true,
whenFinishedMoveTo: "ship",
},
},
});
expect(config).toEqual({
targetPipelineId: "pipe-1",
targetStageKey: "intake",
pieceNoun: "feature",
inheritFields: ["release", "owner"],
carryOverPolicy: {
version: 1,
mode: "only",
includeFields: ["release", "owner"],
excludeFields: [],
},
advanceTo: "review",
waitForPieces: true,
whenFinishedMoveTo: "ship",
} satisfies StageBreakdownConfig);
});
it("reads versioned all-except carry-over policy", () => {
const config = readStageBreakdown({
config: {
breakdown: {
targetPipelineId: "pipe-1",
targetStageKey: "intake",
carryOverPolicy: { version: 1, mode: "all_except", excludeFields: ["owner"] },
},
},
});
expect(config?.carryOverPolicy).toEqual({
version: 1,
mode: "all_except",
includeFields: [],
excludeFields: ["owner"],
});
});
it("defaults the noun to 'piece' when absent", () => {
const config = readStageBreakdown({ config: { breakdown: { targetPipelineId: "p", targetStageKey: "s" } } });
expect(config?.pieceNoun).toBe("piece");
expect(config?.waitForPieces).toBe(false);
expect(config?.carryOverPolicy).toEqual({
version: 1,
mode: "only",
includeFields: [],
excludeFields: [],
});
});
});
describe("pieceNounPlural", () => {
it("appends s and falls back to piece", () => {
expect(pieceNounPlural("feature")).toBe("features");
expect(pieceNounPlural(" ")).toBe("pieces");
});
});
describe("joinWithAnd", () => {
it("joins with commas and a trailing and", () => {
expect(joinWithAnd(["a"])).toBe("a");
expect(joinWithAnd(["a", "b"])).toBe("a and b");
expect(joinWithAnd(["a", "b", "c"])).toBe("a, b and c");
expect(joinWithAnd([])).toBe("");
});
});
const baseConfig: StageBreakdownConfig = {
targetPipelineId: "pipe-1",
targetStageKey: "intake",
pieceNoun: "feature",
inheritFields: ["release", "owner"],
carryOverPolicy: {
version: 1,
mode: "only",
includeFields: ["release", "owner"],
excludeFields: [],
},
advanceTo: "review",
waitForPieces: true,
whenFinishedMoveTo: "ship",
};
const names: BreakdownCopyNames = {
targetPipelineName: "Features",
entryStageName: "Intake",
advanceToName: "Review",
whenFinishedName: "Ship",
inheritedFieldLabels: ["Release", "Owner"],
};
describe("breakdownSummarySentence", () => {
it("composes the full sentence including the wait clause", () => {
expect(breakdownSummarySentence(baseConfig, names)).toBe(
"Paperclip will create one feature per item in Features → Intake, carry over Release and Owner, move this case to Review, then wait until every feature is finished before moving it to Ship.",
);
});
it("omits the wait clause when waiting is off", () => {
const sentence = breakdownSummarySentence({ ...baseConfig, waitForPieces: false }, names);
expect(sentence).toBe(
"Paperclip will create one feature per item in Features → Intake, carry over Release and Owner, move this case to Review.",
);
});
it("returns null when the target is not picked yet", () => {
expect(breakdownSummarySentence({ ...baseConfig, targetPipelineId: "" }, names)).toBeNull();
expect(breakdownSummarySentence(baseConfig, { ...names, targetPipelineName: "" })).toBeNull();
});
});
describe("breakdownMechanicsBullets", () => {
it("includes the empty-list bullet only when waiting is on", () => {
const waiting = breakdownMechanicsBullets(baseConfig, names);
expect(waiting.some((b) => b.includes("empty list"))).toBe(true);
expect(waiting.some((b) => b.startsWith("Waits until every feature"))).toBe(true);
const noWait = breakdownMechanicsBullets({ ...baseConfig, waitForPieces: false }, names);
expect(noWait.some((b) => b.includes("empty list"))).toBe(false);
});
it("drops the carry-over bullet when there are no inherited fields", () => {
const bullets = breakdownMechanicsBullets(
{ ...baseConfig, inheritFields: [] },
{ ...names, inheritedFieldLabels: [] },
);
expect(bullets.some((b) => b.startsWith("Carries over"))).toBe(false);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { describe, expect, it } from "vitest";
import type { PipelineCase, PipelineCaseEvent, PipelineStage } from "../api/pipelines";
import {
changedNoticeFromEvents,
displayPipelineItemFields,
formatPipelineItemEvent,
getPendingTransitionBannerState,
humanizePipelineItemStatus,
INTERNAL_FIELD_KEYS,
itemHasChangedNotice,
normalizePipelineChildRows,
splitPipelineItemFields,
} from "./pipeline-item-detail";
const stages: PipelineStage[] = [
{ id: "stage-intake", pipelineId: "pipeline-1", key: "intake", name: "Intake", kind: "working", position: 100 },
{ id: "stage-review", pipelineId: "pipeline-1", key: "review", name: "Review", kind: "review", position: 200 },
{ id: "stage-done", pipelineId: "pipeline-1", key: "done", name: "Done", kind: "done", position: 900 },
];
function item(overrides: Partial<PipelineCase>): PipelineCase {
return {
id: "item-1",
pipelineId: "pipeline-1",
stageId: "stage-intake",
title: "Draft launch post",
fields: {},
...overrides,
};
}
function event(type: string, payload: Record<string, unknown> = {}, overrides: Partial<PipelineCaseEvent> = {}): PipelineCaseEvent {
return {
id: `${type}-event`,
companyId: "company-1",
caseId: "item-1",
type,
actorType: "system",
payload,
createdAt: "2026-06-10T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
...overrides,
};
}
describe("pipeline item detail helpers", () => {
it("shows and hides the pending transition banner from item state", () => {
expect(getPendingTransitionBannerState(item({
pendingSuggestion: {
id: "suggestion-1",
toStageKey: "review",
rationale: "Ready for review",
createdAt: "2026-06-10T12:00:00.000Z",
},
}), stages)).toMatchObject({
visible: true,
suggestionId: "suggestion-1",
stageName: "Review",
});
expect(getPendingTransitionBannerState(item({ fields: { suggestionResolution: "dismiss" } }), stages)).toEqual({
visible: false,
reason: "resolved",
});
expect(getPendingTransitionBannerState(item({ fields: {} }), stages)).toEqual({
visible: false,
reason: "no_next_stage",
});
expect(getPendingTransitionBannerState(item({ fields: { nextSuggestedStageId: "stage-done" } }), stages)).toMatchObject({
visible: true,
stageName: "Done",
});
expect(getPendingTransitionBannerState(item({ fields: { nextSuggestedStageId: "missing-stage" } }), stages)).toMatchObject({
visible: true,
stageName: "the next stage",
});
});
it("detects changed items until acknowledged", () => {
expect(itemHasChangedNotice(item({ fields: { changeAcknowledgedAt: "2026-06-10T12:00:00.000Z", upstreamDrift: true } }))).toBeNull();
expect(itemHasChangedNotice({ ...item({ fields: {} }), thisChanged: true })).toMatchObject({ title: "This changed" });
expect(itemHasChangedNotice(item({ fields: { upstreamChanged: true } }))).toMatchObject({ title: "This changed" });
expect(itemHasChangedNotice(item({ fields: { upstreamDrift: true } }))).toMatchObject({ title: "This changed" });
expect(itemHasChangedNotice(item({ fields: {} }))).toBeNull();
expect(changedNoticeFromEvents([
event("upstream_drift", {}, { createdAt: "2026-06-10T12:00:00.000Z" }),
])).toMatchObject({ title: "This changed" });
expect(changedNoticeFromEvents([
event("upstream_drift", {}, { createdAt: "2026-06-10T12:00:00.000Z" }),
event("drift_acknowledged", {}, { createdAt: "2026-06-10T12:01:00.000Z" }),
])).toBeNull();
});
it("formats every supported activity kind as prose", () => {
const cases: Array<[PipelineCaseEvent, string]> = [
[event("case.ingested"), "Item added."],
[event("case.updated"), "Item details updated."],
[event("case.transitioned", { reason: "Start content intake", transitionClass: "manual" }, {
fromStageId: "stage-intake",
toStageId: "stage-review",
actorType: "agent",
actorAgent: { id: "agent-1", name: "Dotta" },
}), "Moved from Intake to Review — Dotta: 'Start content intake'."],
[event("case.transitioned", { reason: "children_terminal", transitionClass: "auto" }, {
toStageId: "stage-done",
actorType: "system",
}), "Moved to Done — automatic (all child items done)."],
[event("case.transitioned", { reason: "children_terminal", transitionClass: "manual" }, {
toStageId: "stage-done",
actorType: "system",
}), "Moved to Done — automatic (all child items done)."],
[event("case.suggested", { suggestion: { toStageKey: "review" } }), "Suggested moving to Review."],
[event("case.suggestion_resolved", { decision: "accept" }), "Suggestion approved."],
[event("case.suggestion_resolved", { decision: "dismiss" }), "Suggestion dismissed."],
[event("case.reviewed", { decision: "request_changes" }), "Review requested changes."],
[event("case.reviewed", { decision: "drop" }), "Review removed this item."],
[event("case.reviewed", { decision: "approve" }), "Review approved this item."],
[event("upstream_drift", { upstreamCaseKey: "BLOG-12" }), "Upstream change detected from BLOG-12."],
[event("upstream_drift"), "Upstream change detected."],
[event("drift_acknowledged"), "Upstream change acknowledged."],
[event("automation_executed", {}, {
automation: {
routine: { id: "routine-1", title: "Draft announcement" },
issue: { id: "issue-1", identifier: "PAP-42", title: "Draft the announcement", status: "todo" },
routineRunId: "run-1",
},
}), "Automation completed — ran Draft announcement -> PAP-42."],
[event("automation_failed", { error: "automation_not_configured" }), "Automation needs attention — automation not configured."],
[event("case.unknown_kind"), "Activity recorded."],
];
for (const [input, expected] of cases) {
expect(formatPipelineItemEvent(input, stages)).toBe(expected);
}
});
it("humanizes status values", () => {
expect(humanizePipelineItemStatus(null)).toBe("Open");
expect(humanizePipelineItemStatus("open")).toBe("Open");
expect(humanizePipelineItemStatus("done")).toBe("Done");
expect(humanizePipelineItemStatus("cancelled")).toBe("Removed");
expect(humanizePipelineItemStatus("in_review")).toBe("In review");
expect(humanizePipelineItemStatus("in_progress")).toBe("In progress");
expect(humanizePipelineItemStatus("needs_qa")).toBe("Needs qa");
});
it("filters internal fields and formats display values", () => {
const fields = {
audience: ["Founders", "Operators"],
owner: { label: "Launch team" },
backupOwner: { name: "Growth" },
source: { title: "Launch brief" },
metadata: { nested: true },
title: "Announcement",
nextSuggestedStageId: "review",
suggestionResolution: "accept",
upstreamDrift: true,
changeAcknowledgedAt: "2026-06-10T12:00:00.000Z",
thisChanged: true,
};
const displayed = displayPipelineItemFields(fields);
expect(displayed).toEqual([
{ key: "audience", label: "Audience", value: "Founders, Operators" },
{ key: "owner", label: "Owner", value: "Launch team" },
{ key: "backupOwner", label: "Backup Owner", value: "Growth" },
{ key: "source", label: "Source", value: "Launch brief" },
{ key: "metadata", label: "Metadata", value: "Added details" },
{ key: "title", label: "Title", value: "Announcement" },
]);
for (const key of INTERNAL_FIELD_KEYS) {
expect(displayed.some((field) => field.key === key)).toBe(false);
}
});
it("splits short sidebar fields from long main-pane fields without relying on key names", () => {
const displayed = displayPipelineItemFields({
owner: "Launch team",
reviewerOpenQuestions: "1. Does the headline need a source?\n2. Should we include the rollout caveat?",
arbitraryLongText: "This is a long field from a dynamic pipeline schema. ".repeat(5),
});
expect(splitPipelineItemFields(displayed)).toEqual({
shortFields: [
{ key: "owner", label: "Owner", value: "Launch team" },
],
longFields: [
{
key: "reviewerOpenQuestions",
label: "Reviewer Open Questions",
value: "1. Does the headline need a source?\n2. Should we include the rollout caveat?",
},
{
key: "arbitraryLongText",
label: "Arbitrary Long Text",
value: "This is a long field from a dynamic pipeline schema. ".repeat(5),
},
],
});
});
it("normalizes rollup-tree children responses into direct child rows", () => {
const rows = normalizePipelineChildRows({
case: {
id: "release-1",
title: "Release v0.42",
pipeline: { id: "release-pipeline", key: "release", name: "Release" },
stage: { id: "release-producing", key: "producing", name: "Producing", kind: "working" },
childGroups: [
{
pipeline: { id: "feature-pipeline", key: "feature", name: "Feature" },
cases: [
{
id: "feature-1",
caseKey: "v0.42-pipelines-ui",
title: "Feature: Pipelines UI",
terminalKind: "done",
pipeline: { id: "feature-pipeline", key: "feature", name: "Feature" },
stage: { id: "feature-covered", key: "covered", name: "Covered", kind: "done" },
rollup: { total: 6, done: 3, dropped: 3, inMotion: 0 },
childGroups: [],
},
],
},
],
},
});
expect(rows).toHaveLength(1);
expect(rows[0].case).toMatchObject({
id: "feature-1",
pipelineId: "feature-pipeline",
stageId: "feature-covered",
title: "Feature: Pipelines UI",
terminalKind: "done",
childCount: 6,
});
expect(rows[0].stage).toMatchObject({
id: "feature-covered",
pipelineId: "feature-pipeline",
key: "covered",
name: "Covered",
kind: "done",
});
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { PipelineCompanyCaseEvent } from "../api/pipelines";
import { formatLearningEvent, groupLearningEventsByDay, learningDayKey } from "./pipeline-learnings";
function event(overrides: Partial<PipelineCompanyCaseEvent>): PipelineCompanyCaseEvent {
return {
id: "event-1",
companyId: "company-1",
caseId: "item-1",
type: "review_decided",
actorType: "user",
createdAt: "2026-06-10T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
payload: {},
case: { id: "item-1", caseKey: "CASE-1", title: "Launch tweet" },
pipeline: { id: "pipeline-1", key: "content", name: "Content production" },
fromStage: null,
toStage: null,
actorAgent: null,
...overrides,
};
}
describe("formatLearningEvent", () => {
it("translates review decisions with a target stage", () => {
const result = formatLearningEvent(event({
type: "review_decided",
toStage: { id: "stage-2", key: "publish", name: "Publish", kind: "done" },
payload: { actorName: "Dotta", decision: "approve" },
}));
expect(result.kind).toBe("review");
expect(result.sentence).toBe("Dotta approved 'Launch tweet' moving to Publish.");
expect(result.sentence).not.toContain("review_decided");
});
it("translates review decisions without optional context", () => {
const result = formatLearningEvent(event({
type: "review_decided",
payload: { decision: "request_changes" },
}));
expect(result.sentence).toBe("Someone sent back 'Launch tweet'.");
expect(result.sentence).not.toContain("review_decided");
});
it("prefers the acting agent's name and renders reject decisions", () => {
const result = formatLearningEvent(event({
type: "review_decided",
actorType: "agent",
actorAgent: { id: "agent-1", name: "Drafting agent" },
payload: { decision: "reject", reason: "Off brand" },
}));
expect(result.sentence).toBe("Drafting agent declined 'Launch tweet' - note: Off brand.");
});
it("translates forced moves with a reason", () => {
const result = formatLearningEvent(event({
type: "transition_forced",
fromStage: { id: "stage-1", key: "drafting", name: "Drafting", kind: "working" },
toStage: { id: "stage-3", key: "done", name: "Done", kind: "done" },
payload: { reason: "Ready for the campaign" },
}));
expect(result.kind).toBe("forced_move");
expect(result.sentence).toBe("'Launch tweet' was moved by hand from Drafting to Done - reason: Ready for the campaign.");
expect(result.sentence).not.toContain("transition_forced");
});
it("translates forced moves without a reason", () => {
const result = formatLearningEvent(event({
type: "transition_forced",
toStage: { id: "stage-3", key: "done", name: "Done", kind: "done" },
payload: {},
}));
expect(result.sentence).toBe("'Launch tweet' was moved by hand to Done.");
expect(result.sentence).not.toContain("transition_forced");
});
it("falls back to neutral copy for unknown event types", () => {
const result = formatLearningEvent(event({ type: "blockers_set" }));
expect(result.kind).toBe("unknown");
expect(result.sentence).toBe("'Launch tweet' changed.");
expect(result.sentence).not.toContain("blockers_set");
});
});
describe("groupLearningEventsByDay", () => {
it("groups consecutive events by calendar day", () => {
const groups = groupLearningEventsByDay([
event({ id: "a", createdAt: "2026-06-10T12:00:00.000Z" }),
event({ id: "b", createdAt: "2026-06-10T08:00:00.000Z" }),
event({ id: "c", createdAt: "2026-06-09T22:00:00.000Z" }),
]);
expect(groups).toHaveLength(2);
expect(groups[0].events.map((item) => item.id)).toEqual(["a", "b"]);
expect(groups[1].events.map((item) => item.id)).toEqual(["c"]);
});
it("buckets invalid timestamps under Unknown", () => {
expect(learningDayKey("not-a-date")).toBe("Unknown");
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { PipelineCaseLiveness } from "@paperclipai/shared";
import {
derivePipelineLivenessBanner,
shouldDisableRerunForPermission,
} from "./pipeline-liveness";
function liveness(overrides: Partial<PipelineCaseLiveness>): PipelineCaseLiveness {
return {
state: "attention",
reason: "no_action_path",
message: "stub",
...overrides,
} as PipelineCaseLiveness;
}
describe("derivePipelineLivenessBanner", () => {
it("returns null for non-stuck states", () => {
for (const reason of [
"terminal",
"lease_active",
"linked_issue_active",
"linked_issue_waiting",
"children_waiting",
"review_waiting",
] as const) {
expect(derivePipelineLivenessBanner(liveness({ reason }))).toBeNull();
}
expect(derivePipelineLivenessBanner(null)).toBeNull();
expect(derivePipelineLivenessBanner(undefined)).toBeNull();
});
it("renders an amber blocked banner with a blocking-issue link", () => {
const view = derivePipelineLivenessBanner(
liveness({
state: "blocked",
reason: "linked_issue_blocked",
message: "Linked automation issue is blocked.",
issue: { id: "auto-1", identifier: "PAP-900", title: "Build the thing", status: "blocked" },
blocker: { issueId: "blk-1", title: "Waiting on legal", status: "in_progress" },
}),
);
expect(view).not.toBeNull();
expect(view!.tone).toBe("blocked");
expect(view!.showRetry).toBe(false);
expect(view!.blockerLink).toEqual({ issueId: "blk-1", title: "Waiting on legal" });
expect(view!.automationLink).toEqual({ issueId: "auto-1", identifier: "PAP-900", title: "Build the thing" });
expect(view!.helperNote).toMatch(/automatically/i);
});
it("renders a purple permission banner and surfaces the permission key", () => {
const view = derivePipelineLivenessBanner(
liveness({
state: "blocked",
reason: "permission_preflight_failed",
message: "Pipeline automation is blocked until the assignee can write to the target pipeline.",
automation: {
automationId: "auto-2",
fingerprint: "case-1:stage-1:auto-2:target-pipe:agent-9:pipelines:write",
},
}),
);
expect(view!.tone).toBe("permission");
expect(view!.permissionKey).toBe("pipelines:write");
expect(view!.showRetry).toBe(false);
});
it("renders an indigo ready-to-retry banner for restored permission", () => {
const view = derivePipelineLivenessBanner(
liveness({
state: "attention",
reason: "automation_failed",
message: "Pipeline automation permission has been restored; retry the failed automation ledger.",
automation: { automationId: "auto-3" },
}),
);
expect(view!.tone).toBe("retry");
expect(view!.showRetry).toBe(true);
expect(view!.retryKind).toBe("automation");
expect(view!.retryLabel).toBe("Retry now");
});
it("renders an attention banner with a stage-rerun retry for generic failure", () => {
const view = derivePipelineLivenessBanner(
liveness({
state: "attention",
reason: "automation_failed",
message: "Pipeline automation failed and needs retry or recovery.",
automation: { automationId: null },
}),
);
expect(view!.tone).toBe("attention");
expect(view!.showRetry).toBe(true);
expect(view!.retryKind).toBe("stage");
});
it("counts missing breakdown pieces in the body", () => {
const view = derivePipelineLivenessBanner(
liveness({
state: "blocked",
reason: "breakdown_incomplete",
message: "Breakdown evidence does not match created child cases.",
breakdown: { missingRequestKeys: ["a", "b"] },
}),
);
expect(view!.tone).toBe("blocked");
expect(view!.body).toMatch(/2 expected pieces are still missing/);
});
it("treats no_action_path as a stuck attention banner with a retry", () => {
const view = derivePipelineLivenessBanner(liveness({ reason: "no_action_path" }));
expect(view!.tone).toBe("attention");
expect(view!.showRetry).toBe(true);
expect(view!.retryKind).toBe("stage");
});
it("uses prosumer copy for no_action_path, never the raw server vocabulary (PAP-11259)", () => {
const view = derivePipelineLivenessBanner(
liveness({
reason: "no_action_path",
message: "No lease, linked work, blocker, automation retry, review, or breakdown action path is visible.",
}),
);
expect(view!.title).toBe("This item is stuck");
expect(view!.body).not.toMatch(/lease|linked work|action path/i);
expect(view!.body).toMatch(/Re-run the stage/);
});
});
describe("shouldDisableRerunForPermission", () => {
it("disables only for the permission preflight reason", () => {
expect(shouldDisableRerunForPermission(liveness({ reason: "permission_preflight_failed" }))).toBe(true);
expect(shouldDisableRerunForPermission(liveness({ reason: "automation_failed" }))).toBe(false);
expect(shouldDisableRerunForPermission(null)).toBe(false);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { extractWorkReferences, referenceFieldKeys, type WorkReference } from "./pipeline-references";
function kinds(refs: WorkReference[]) {
return refs.map((ref) => ref.kind);
}
describe("extractWorkReferences", () => {
it("returns nothing for a case with no references", () => {
expect(extractWorkReferences({ fields: { topic: "Launch", count: 3 } })).toEqual([]);
});
it("surfaces the workspaceRef column as a workspace reference", () => {
const refs = extractWorkReferences({
workspaceRef: { path: "/content/blog", branch: "feature/blog" },
fields: {},
});
expect(refs).toHaveLength(1);
expect(refs[0]).toMatchObject({ kind: "workspace", path: "/content/blog", branch: "feature/blog" });
});
it("detects a bare URL string field", () => {
const refs = extractWorkReferences({ fields: { draft_link: "https://docs.example.com/x" } });
expect(refs[0]).toMatchObject({ kind: "url", url: "https://docs.example.com/x", label: "Draft link" });
});
it("ignores non-http strings", () => {
expect(extractWorkReferences({ fields: { note: "just text" } })).toEqual([]);
});
it("detects an explicit url record", () => {
const refs = extractWorkReferences({
fields: { asset: { kind: "url", url: "https://cdn.example.com/i.png", label: "Hero image" } },
});
expect(refs[0]).toMatchObject({ kind: "url", url: "https://cdn.example.com/i.png", label: "Hero image" });
});
it("detects an issue reference record", () => {
const refs = extractWorkReferences({
fields: { work: { issueId: "issue-1", identifier: "PAP-99", title: "Write blog" } },
});
expect(refs[0]).toMatchObject({ kind: "issue", issueId: "issue-1", identifier: "PAP-99", label: "Write blog" });
});
it("detects a workspace-shaped field record", () => {
const refs = extractWorkReferences({ fields: { folder: { path: "/assets", kind: "workspace" } } });
expect(refs[0]).toMatchObject({ kind: "workspace", path: "/assets" });
});
it("combines workspaceRef and field references in order", () => {
const refs = extractWorkReferences({
workspaceRef: { path: "/root" },
fields: {
link: "https://example.com",
work: { issueIdentifier: "PAP-1" },
},
});
expect(kinds(refs)).toEqual(["workspace", "url", "issue"]);
});
it("reports reference field keys for exclusion from the plain list", () => {
const fields = { link: "https://example.com", topic: "Launch", work: { issueId: "i1" } };
const keys = referenceFieldKeys(fields);
expect(keys.has("link")).toBe(true);
expect(keys.has("work")).toBe(true);
expect(keys.has("topic")).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { pipelineStageConfigSchema } from "@paperclipai/shared";
describe("pipeline settings stage config", () => {
it("accepts disable and approval settings stored in stage config", () => {
const parsed = pipelineStageConfigSchema.safeParse({
variables: [
{
key: "customer",
label: "Customer",
type: "text",
options: [],
required: true,
showInAddForm: true,
},
],
disabled: true,
disabledReason: "Pause intake while the team clears the queue.",
requireApproval: true,
approver: {
kind: "agent",
id: "agent-1",
},
whatHappensHere: "Triage every incoming item before work starts.",
});
expect(parsed.success).toBe(true);
});
it("requires a concrete approver when approval targets a specific person or agent", () => {
const parsed = pipelineStageConfigSchema.safeParse({
variables: [],
requireApproval: true,
approver: {
kind: "user",
},
});
expect(parsed.success).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,307 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useEffect, useRef, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Route, Routes, useNavigate } from "react-router-dom";
import { queryKeys } from "@/lib/queryKeys";
import { PipelineSettings } from "@/pages/PipelineSettings";
import { Pipelines, PipelineItemDetail } from "@/pages/Pipelines";
import type {
PipelineCaseDetail,
PipelineDetail,
PipelineHealthReport,
PipelineIntakeForm,
PipelineListItem,
PipelineStage,
} from "@/api/pipelines";
import { storybookAgents } from "../fixtures/paperclipData";
/**
* Visual coverage for the PAP-11059 "Break into pieces" breakdown UI. Each
* story seeds the React Query cache (the storybook QueryClient uses
* staleTime: Infinity + retry: false, so seeded data is never refetched) and
* renders the real route page so screenshots reflect production markup.
*/
const companyId = "company-storybook";
const SOURCE = "pipeline-releases";
const TARGET = "pipeline-features";
const sourceStages: PipelineStage[] = [
{ id: "src-planning", pipelineId: SOURCE, key: "planning", name: "Planning", kind: "working", position: 100, config: { variables: [] } },
{
id: "src-coverage",
pipelineId: SOURCE,
key: "coverage",
name: "Release Coverage",
kind: "working",
position: 200,
config: {
variables: [],
automation: {
assigneeAgentId: "agent-codex",
instructionsBody: "Decide which parts of this release deserve their own coverage write-up. List one per piece.",
},
// Server derives the children gate from breakdown.waitForPieces; the
// persisted stage keeps the breakdown block as the source of truth.
breakdown: {
targetPipelineId: TARGET,
targetStageKey: "intake",
pieceNoun: "feature",
inheritFields: ["release"],
advanceTo: "review",
waitForPieces: true,
whenFinishedMoveTo: "ship",
},
},
},
{ id: "src-review", pipelineId: SOURCE, key: "review", name: "Review", kind: "review", position: 300, config: { variables: [], requireApproval: true, approver: { kind: "any_human" } } },
{ id: "src-ship", pipelineId: SOURCE, key: "ship", name: "Shipped", kind: "done", position: 400, config: { variables: [] } },
];
const targetStages: PipelineStage[] = [
{ id: "tg-intake", pipelineId: TARGET, key: "intake", name: "Intake", kind: "working", position: 100, config: { variables: [] } },
{ id: "tg-build", pipelineId: TARGET, key: "build", name: "Drafting", kind: "working", position: 200, config: { variables: [] } },
{ id: "tg-done", pipelineId: TARGET, key: "done", name: "Done", kind: "done", position: 300, config: { variables: [] } },
];
const baseList = (id: string, name: string, stages: PipelineStage[]): PipelineListItem => ({
id,
companyId,
key: id,
name,
description: null,
projectId: null,
enforceTransitions: true,
archivedAt: null,
stageCount: stages.length,
stages,
openCaseCount: 3,
connections: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-10T00:00:00Z"),
});
const sourceDetail: PipelineDetail = {
...baseList(SOURCE, "Releases", sourceStages),
stages: sourceStages,
transitions: [
{ fromStageId: "src-planning", toStageId: "src-coverage" },
{ fromStageId: "src-coverage", toStageId: "src-review" },
{ fromStageId: "src-review", toStageId: "src-ship" },
],
};
const targetDetail: PipelineDetail = {
...baseList(TARGET, "Features", targetStages),
stages: targetStages,
transitions: [
{ fromStageId: "tg-intake", toStageId: "tg-build" },
{ fromStageId: "tg-build", toStageId: "tg-done" },
],
};
const pipelineList: PipelineListItem[] = [
baseList(SOURCE, "Releases", sourceStages),
baseList(TARGET, "Features", targetStages),
];
const targetIntake: PipelineIntakeForm = {
pipelineId: TARGET,
stageId: "tg-intake",
fields: [
{ key: "release", label: "Release", type: "text", required: false },
{ key: "owner", label: "Owner", type: "text", required: true },
],
} as PipelineIntakeForm;
const sourceHealth: PipelineHealthReport = {
pipelineId: SOURCE,
ok: false,
warnings: [
{
code: "breakdown_field_mismatch",
stageId: "src-coverage",
stageKey: "coverage",
stageName: "Release Coverage",
message: "One or more carried-over fields don't exist on Features. The chosen fields won't be stamped onto new features.",
},
],
};
function makeChild(id: string, title: string, stageKey: string, kind: string, terminal: string | null) {
return {
case: {
id,
companyId,
pipelineId: TARGET,
stageId: `tg-${stageKey}`,
caseKey: id,
title,
terminalKind: terminal,
version: 1,
},
stage: targetStages.find((s) => s.key === stageKey)!,
activeWork: null,
};
}
const coverageCaseDetail: PipelineCaseDetail = {
case: {
id: "case-q3",
companyId,
pipelineId: SOURCE,
stageId: "src-coverage",
caseKey: "REL-204",
title: "Q3 platform release",
summary: "Coverage pass for the Q3 release train.",
version: 3,
childCount: 5,
terminalChildCount: 3,
},
stage: sourceStages[1]!,
pipeline: sourceDetail,
allowedNextStages: [sourceStages[2]!],
links: [],
blockers: [],
blocks: [],
childrenSummary: { childCount: 5, terminalChildCount: 3, loadedChildren: 5 },
parentCase: null,
};
const coverageChildren = [
makeChild("case-auth", "Auth rate-limit coverage", "done", "done", "done"),
makeChild("case-billing", "Billing webhook coverage", "done", "done", "done"),
makeChild("case-search", "Search reindex coverage", "done", "done", "done"),
makeChild("case-mobile", "Mobile deep-link coverage", "build", "working", null),
makeChild("case-export", "CSV export coverage", "intake", "working", null),
];
function Seeder({ seed, children }: { seed: (qc: ReturnType<typeof useQueryClient>) => void; children: ReactNode }) {
const qc = useQueryClient();
const seeded = useRef(false);
if (!seeded.current) {
qc.setQueryData(queryKeys.agents.list(companyId), storybookAgents);
seed(qc);
seeded.current = true;
}
return <>{children}</>;
}
// The storybook decorator already provides a single MemoryRouter; nesting
// another throws. Instead we navigate the existing router to the target URL
// once on mount, then match it with a local <Routes>.
function RouteHarness({ entry, path, element }: { entry: string; path: string; element: ReactNode }) {
const navigate = useNavigate();
const navigated = useRef(false);
if (!navigated.current) {
navigated.current = true;
}
useEffect(() => {
navigate(entry, { replace: true });
}, [entry, navigate]);
return (
<Routes>
<Route path={path} element={element} />
</Routes>
);
}
const meta: Meta = {
title: "Pipelines/Breakdown primitive",
parameters: { layout: "fullscreen", a11y: { test: "off" } },
};
export default meta;
type Story = StoryObj;
/** Settings → Advanced: the "Break into smaller pieces" card + generated summary. */
export const SettingsBreakIntoPiecesCard: Story = {
render: () => (
<Seeder
seed={(qc) => {
qc.setQueryData(queryKeys.pipelines.detail(SOURCE), sourceDetail);
qc.setQueryData(queryKeys.pipelines.health(SOURCE), sourceHealth);
qc.setQueryData(queryKeys.pipelines.list(companyId), pipelineList);
qc.setQueryData(queryKeys.pipelines.intakeForm(TARGET), targetIntake);
qc.setQueryData(queryKeys.pipelines.document(SOURCE, "stage-instructions:src-coverage"), null);
}}
>
<div className="min-h-screen bg-background p-6">
<RouteHarness
entry={`/PAP/pipelines/${SOURCE}/settings?stage=src-coverage`}
path="/:companyPrefix/pipelines/:pipelineId/settings"
element={<PipelineSettings />}
/>
</div>
</Seeder>
),
};
/** Board: outbound "Breaks into Features" chip + inbound "Fed by Releases" chip. */
export const BoardConnectorChips: Story = {
render: () => (
<Seeder
seed={(qc) => {
qc.setQueryData(queryKeys.pipelines.detail(SOURCE), sourceDetail);
qc.setQueryData(queryKeys.pipelines.health(SOURCE), sourceHealth);
qc.setQueryData(queryKeys.pipelines.list(companyId), pipelineList);
qc.setQueryData(queryKeys.pipelines.cases(SOURCE), [
{ case: coverageCaseDetail.case, activeWork: null },
]);
}}
>
<div className="min-h-screen bg-background">
<RouteHarness
entry={`/PAP/pipelines/${SOURCE}`}
path="/:companyPrefix/pipelines/:pipelineId"
element={<Pipelines />}
/>
</div>
</Seeder>
),
};
/** Board for the target pipeline: "Fed by Releases" chip on the title bar. */
export const BoardFedByChip: Story = {
render: () => (
<Seeder
seed={(qc) => {
qc.setQueryData(queryKeys.pipelines.detail(TARGET), targetDetail);
qc.setQueryData(queryKeys.pipelines.health(TARGET), { pipelineId: TARGET, ok: true, warnings: [] });
qc.setQueryData(queryKeys.pipelines.list(companyId), pipelineList);
qc.setQueryData(queryKeys.pipelines.cases(TARGET), coverageChildren.map((c) => ({ case: c.case, activeWork: null })));
}}
>
<div className="min-h-screen bg-background">
<RouteHarness
entry={`/PAP/pipelines/${TARGET}`}
path="/:companyPrefix/pipelines/:pipelineId"
element={<Pipelines />}
/>
</div>
</Seeder>
),
};
/** Case detail: piece-noun rollup banner + "Built from 5 features" section. */
export const CaseDetailPiecesRollup: Story = {
render: () => (
<Seeder
seed={(qc) => {
qc.setQueryData(queryKeys.pipelines.detail(SOURCE), sourceDetail);
qc.setQueryData(queryKeys.pipelines.caseDetail("case-q3"), coverageCaseDetail);
qc.setQueryData(queryKeys.pipelines.caseChildren("case-q3"), coverageChildren);
qc.setQueryData(queryKeys.pipelines.caseEvents("case-q3"), { items: [] });
qc.setQueryData(queryKeys.pipelines.caseIssueLinks("case-q3"), []);
}}
>
<div className="min-h-screen bg-background">
<RouteHarness
entry={`/PAP/pipelines/${SOURCE}/items/case-q3`}
path="/:companyPrefix/pipelines/:pipelineId/items/:caseId"
element={<PipelineItemDetail />}
/>
</div>
</Seeder>
),
};
@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { PipelineCaseLiveness } from "@paperclipai/shared";
import { PipelineLivenessBanner } from "@/components/PipelineLivenessBanner";
/**
* Visual coverage for the PAP-11246 pipeline item liveness banner. Each story
* renders the banner from a Phase 2 `liveness` payload so screenshots reflect
* the real markup operators see at the top of an item detail page.
*
* The banner replaces the green `ActivePipelineWorkBanner` 1:1 whenever the item
* is not actively running, per the PAP-11245 design. Manual "Move to stage…" is
* never promoted here — it stays in the ⋯ menu.
*/
const meta = {
title: "Pipelines/Liveness Banner",
component: PipelineLivenessBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof PipelineLivenessBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
function liveness(overrides: Partial<PipelineCaseLiveness>): PipelineCaseLiveness {
return { state: "attention", reason: "no_action_path", message: "stub", ...overrides } as PipelineCaseLiveness;
}
export const AutomationPaused: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "blocked",
reason: "linked_issue_blocked",
message: "The automation issue for this stage is blocked and can't continue on its own.",
issue: { id: "auto-1", identifier: "PAP-9201", title: "Draft the release notes", status: "blocked" },
blocker: { issueId: "blk-1", title: "Legal sign-off on the changelog", status: "in_progress" },
}),
},
};
export const BlockedByAnotherItem: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "blocked",
reason: "case_blocked",
message: 'Pipeline item is blocked by "Upstream data export".',
blocker: { caseId: "case-99", title: "Upstream data export", terminalKind: null },
}),
},
};
export const PermissionNeeded: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "blocked",
reason: "permission_preflight_failed",
message:
"Pipeline automation is blocked until the configured assignee can write to the target pipeline.",
automation: {
automationId: "auto-2",
fingerprint: "case-1:stage-1:auto-2:target-pipe:agent-codex:pipelines:write",
error: "permission_preflight_failed",
},
}),
},
};
export const ReadyToRetry: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "attention",
reason: "automation_failed",
message: "Pipeline automation permission has been restored; retry the failed automation ledger.",
automation: { automationId: "auto-3", routineId: "routine-7" },
}),
},
};
export const AutomationFailed: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "attention",
reason: "automation_failed",
message: "Pipeline automation failed and needs retry or recovery.",
automation: { automationId: "auto-4", error: "adapter_timeout" },
}),
},
};
export const RetryError: Story = {
args: {
onRetry: () => {},
retryError: "Forbidden: the assignee still lacks pipelines:write on the target pipeline.",
liveness: liveness({
state: "attention",
reason: "automation_failed",
message: "Pipeline automation failed and needs retry or recovery.",
automation: { automationId: "auto-4" },
}),
},
};
export const BreakdownIncomplete: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "blocked",
reason: "breakdown_incomplete",
message: "Breakdown evidence does not match created child cases.",
breakdown: { expectedRequestKeys: ["a", "b", "c"], createdRequestKeys: ["a"], missingRequestKeys: ["b", "c"] },
}),
},
};
// The server still sends the implementation-flavored `message` here, but the
// banner translates `no_action_path` into prosumer copy (PAP-11259), so the
// rendered body is the friendly version regardless of this raw message.
export const StuckNoActionPath: Story = {
args: {
onRetry: () => {},
liveness: liveness({
state: "attention",
reason: "no_action_path",
message: "No lease, linked work, blocker, automation retry, review, or breakdown action path is visible.",
}),
},
};
@@ -0,0 +1,215 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Route, Routes, useNavigate } from "@/lib/router";
import { PipelineSettings } from "@/pages/PipelineSettings";
const COMPANY_ID = "company-storybook";
const PIPELINE_ID = "pipeline-settings-1";
const STAGES = [
{
id: "stage-drafting",
pipelineId: PIPELINE_ID,
key: "drafting",
name: "Drafting",
kind: "working",
position: 100,
config: {
variables: [
{ name: "audience", label: "Audience", type: "text", required: true, options: [], defaultValue: null },
{ name: "deadline", label: "Deadline", type: "text", required: false, options: [], defaultValue: null },
],
},
},
{
id: "stage-review",
pipelineId: PIPELINE_ID,
key: "review",
name: "Final review",
kind: "review",
position: 200,
config: {
requireApproval: true,
approveToStageKey: "published",
rejectToStageKey: "dropped",
requireRejectReason: true,
},
},
{
id: "stage-published",
pipelineId: PIPELINE_ID,
key: "published",
name: "Published",
kind: "done",
position: 300,
config: {},
},
{
id: "stage-dropped",
pipelineId: PIPELINE_ID,
key: "dropped",
name: "Dropped",
kind: "cancelled",
position: 400,
config: {},
},
];
const PIPELINE = {
id: PIPELINE_ID,
companyId: COMPANY_ID,
key: "content",
name: "Content production",
description: "Draft, review, and publish launch content.",
projectId: null,
enforceTransitions: true,
archivedAt: null,
stageCount: STAGES.length,
openCaseCount: 4,
createdAt: "2026-06-01T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
stages: STAGES,
transitions: [
{ fromStageId: "stage-drafting", toStageId: "stage-review", label: null },
{ fromStageId: "stage-drafting", toStageId: "stage-dropped", label: null },
{ fromStageId: "stage-review", toStageId: "stage-published", label: null },
{ fromStageId: "stage-review", toStageId: "stage-dropped", label: null },
],
};
function installFixtures() {
if (typeof window === "undefined") return;
const winAny = window as typeof window & {
__pipelineSettingsOriginalFetch?: typeof window.fetch;
__pipelineSettingsInstalled?: boolean;
};
const draftingDocKey = "stage-instructions:stage-drafting";
const draftingDocBody =
"Draft a piece for the **{{audience}}** audience.\n\nGround it in the latest launch notes and keep the tone punchy. Ship before {{deadline}}.";
const draftingDoc = {
link: { key: draftingDocKey, documentId: "doc-drafting" },
document: {
id: "doc-drafting",
title: "Drafting instructions",
latestBody: draftingDocBody,
latestRevisionId: "rev-drafting-3",
latestRevisionNumber: 3,
},
revision: { id: "rev-drafting-3", body: draftingDocBody, title: "Drafting instructions" },
};
const draftingRevisions = [
{
id: "rev-drafting-3",
companyId: COMPANY_ID,
documentId: "doc-drafting",
pipelineId: PIPELINE_ID,
key: draftingDocKey,
revisionNumber: 3,
title: "Drafting instructions",
format: "markdown",
body: draftingDocBody,
changeSummary: "Tightened the tone guidance",
createdByAgentId: null,
createdByUserId: null,
createdAt: "2026-06-09T16:00:00.000Z",
},
{
id: "rev-drafting-2",
companyId: COMPANY_ID,
documentId: "doc-drafting",
pipelineId: PIPELINE_ID,
key: draftingDocKey,
revisionNumber: 2,
title: "Drafting instructions",
format: "markdown",
body: "Draft a piece for the {{audience}} audience.",
changeSummary: "Added the deadline variable",
createdByAgentId: null,
createdByUserId: null,
createdAt: "2026-06-07T10:30:00.000Z",
},
{
id: "rev-drafting-1",
companyId: COMPANY_ID,
documentId: "doc-drafting",
pipelineId: PIPELINE_ID,
key: draftingDocKey,
revisionNumber: 1,
title: "Drafting instructions",
format: "markdown",
body: "Draft the piece.",
changeSummary: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: "2026-06-05T09:00:00.000Z",
},
];
const fixtures: Record<string, unknown> = {
[`/api/pipelines/${PIPELINE_ID}`]: PIPELINE,
[`/api/companies/${COMPANY_ID}/agents`]: [
{ id: "agent-draft", name: "Drafting agent", role: "writer" },
{ id: "agent-review", name: "Review agent", role: "editor" },
],
};
const originalFetch = winAny.__pipelineSettingsInstalled
? (winAny.__pipelineSettingsOriginalFetch as typeof window.fetch)
: window.fetch.bind(window);
winAny.__pipelineSettingsOriginalFetch = originalFetch;
winAny.__pipelineSettingsInstalled = true;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(rawUrl, window.location.origin);
if (Object.prototype.hasOwnProperty.call(fixtures, url.pathname)) {
return Response.json(fixtures[url.pathname]);
}
const decoded = decodeURIComponent(url.pathname);
// Pipeline guidance document — none yet.
if (url.pathname === `/api/pipelines/${PIPELINE_ID}/documents/guidance`) {
return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
}
// Drafting stage instructions document + revision history.
if (decoded === `/api/pipelines/${PIPELINE_ID}/documents/${draftingDocKey}/revisions`) {
return Response.json(draftingRevisions);
}
if (decoded === `/api/pipelines/${PIPELINE_ID}/documents/${draftingDocKey}`) {
return Response.json(draftingDoc);
}
// Any other per-stage instructions document — none yet.
if (decoded.includes(`/api/pipelines/${PIPELINE_ID}/documents/stage-instructions:`)) {
return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
}
return originalFetch(input, init);
};
}
function Wrapper() {
const navigate = useNavigate();
const [ready, setReady] = useState(false);
useEffect(() => {
installFixtures();
navigate(`/pipelines/${PIPELINE_ID}/settings`, { replace: true });
setReady(true);
}, [navigate]);
if (!ready) return null;
return (
<div className="px-6 py-6">
<Routes>
<Route path="/pipelines/:pipelineId/settings" element={<PipelineSettings />} />
</Routes>
</div>
);
}
const meta: Meta<typeof PipelineSettings> = {
title: "Pipelines/Settings",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof PipelineSettings>;
export const StageEditor: Story = {
name: "Stage settings (allowed next steps, pause, review outcomes)",
render: () => <Wrapper />,
};
@@ -0,0 +1,103 @@
import { useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelinesIndexTable, type PipelineViewMode } from "@/pages/Pipelines";
import type { PipelineListItem } from "@/api/pipelines";
const COMPANY_ID = "company-storybook";
function pipeline(partial: Partial<PipelineListItem> & { id: string; name: string }): PipelineListItem {
return {
companyId: COMPANY_ID,
key: partial.id,
description: null,
projectId: null,
enforceTransitions: false,
archivedAt: null,
stageCount: 4,
openCaseCount: 0,
attentionCount: 0,
inMotionCount: 0,
lastActivityAt: "2026-06-10T12:00:00.000Z",
connections: null,
createdAt: "2026-06-01T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
...partial,
};
}
const PIPELINES: PipelineListItem[] = [
pipeline({
id: "content",
name: "Content production",
description: "Draft, review, and publish launch content.",
openCaseCount: 12,
attentionCount: 3,
inMotionCount: 5,
lastActivityAt: "2026-06-11T09:30:00.000Z",
}),
pipeline({
id: "support",
name: "Customer support triage",
openCaseCount: 27,
attentionCount: 8,
inMotionCount: 2,
lastActivityAt: "2026-06-11T11:55:00.000Z",
}),
pipeline({
id: "hiring",
name: "Hiring funnel",
openCaseCount: 4,
attentionCount: 0,
inMotionCount: 9,
lastActivityAt: "2026-06-09T08:00:00.000Z",
}),
pipeline({
id: "billing",
name: "Billing disputes",
openCaseCount: 1,
attentionCount: 1,
inMotionCount: 0,
lastActivityAt: "2026-05-30T08:00:00.000Z",
}),
];
function Frame({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-background p-8 text-foreground">
<div className="ml-auto max-w-6xl">{children}</div>
</div>
);
}
function Wrapper() {
const [viewMode, setViewMode] = useState<PipelineViewMode>("flat");
const [search, setSearch] = useState("");
return (
<PipelinesIndexTable
pipelines={PIPELINES}
viewMode={viewMode}
onViewModeChange={setViewMode}
connectionsAvailable={false}
search={search}
onSearchChange={setSearch}
/>
);
}
const meta: Meta<typeof PipelinesIndexTable> = {
title: "Pipelines/Index table",
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof PipelinesIndexTable>;
export const Default: Story = {
name: "Toggle + sort controls",
render: () => (
<Frame>
<Wrapper />
</Frame>
),
};
@@ -0,0 +1,395 @@
import { useEffect, useState, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PipelineItemDetailView } from "@/pages/Pipelines";
const COMPANY_ID = "company-storybook";
const PIPELINE_ID = "pipeline-1";
const CASE_ID = "item-1";
const CONVERSATION_ISSUE_ID = "issue-conv-1";
interface FixtureOptions {
withConversation: boolean;
withChildren: boolean;
pendingSuggestion: boolean;
changedNotice: boolean;
}
const STAGES = [
{ id: "stage-intake", pipelineId: PIPELINE_ID, key: "intake", name: "Intake", kind: "open", position: 100 },
{
id: "stage-review",
pipelineId: PIPELINE_ID,
key: "review",
name: "Review",
kind: "review",
position: 200,
config: { requireChildrenTerminal: true, autoAdvanceOnChildrenTerminal: "done" },
},
{ id: "stage-done", pipelineId: PIPELINE_ID, key: "done", name: "Done", kind: "done", position: 800 },
{ id: "stage-cancelled", pipelineId: PIPELINE_ID, key: "cancelled", name: "Removed", kind: "cancelled", position: 1000 },
];
const PIPELINE = {
id: PIPELINE_ID,
companyId: COMPANY_ID,
key: "content",
name: "Content",
description: "Tutorial pipeline for QA",
projectId: null,
enforceTransitions: false,
archivedAt: null,
stageCount: STAGES.length,
openCaseCount: 1,
createdAt: "2026-06-10T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
stages: STAGES,
transitions: [],
};
const CONVERSATION_ISSUE = {
id: CONVERSATION_ISSUE_ID,
companyId: COMPANY_ID,
projectId: null,
identifier: "PAP-9999",
title: "Refine the launch announcement copy",
description: "",
status: "in_progress",
createdAt: "2026-06-10T12:00:00.000Z",
updatedAt: "2026-06-10T12:30:00.000Z",
};
const LINKED_ISSUE_LINK = [
{
link: {
id: "link-1",
companyId: COMPANY_ID,
caseId: CASE_ID,
issueId: CONVERSATION_ISSUE_ID,
role: "conversation",
createdAt: "2026-06-10T12:00:00.000Z",
updatedAt: "2026-06-10T12:00:00.000Z",
},
issue: CONVERSATION_ISSUE,
},
];
const CHILD_ROWS = [
{
case: {
id: "child-1",
companyId: COMPANY_ID,
pipelineId: PIPELINE_ID,
stageId: "stage-review",
title: "Outline draft",
fields: { audience: "Operators" },
childCount: 2,
terminalKind: null,
},
stage: STAGES[1],
},
{
case: {
id: "child-2",
companyId: COMPANY_ID,
pipelineId: PIPELINE_ID,
stageId: "stage-done",
title: "Headline benchmarks",
fields: {},
childCount: 0,
terminalKind: "done",
},
stage: STAGES[2],
},
{
case: {
id: "child-3",
companyId: COMPANY_ID,
pipelineId: PIPELINE_ID,
stageId: "stage-cancelled",
title: "Audience notes",
fields: {},
childCount: 0,
terminalKind: "cancelled",
},
stage: STAGES[3],
},
];
const EVENTS = [
{
id: "ev-1",
companyId: COMPANY_ID,
caseId: CASE_ID,
type: "case.ingested",
actorType: "system",
payload: {},
createdAt: "2026-06-10T08:00:00.000Z",
updatedAt: "2026-06-10T08:00:00.000Z",
},
{
id: "ev-2",
companyId: COMPANY_ID,
caseId: CASE_ID,
type: "case.transitioned",
actorType: "agent",
actorAgent: { id: "agent-copy", name: "Dotta" },
payload: { reason: "Start content intake for v0.42", transitionClass: "manual" },
fromStageId: "stage-intake",
toStageId: "stage-review",
createdAt: "2026-06-10T09:00:00.000Z",
updatedAt: "2026-06-10T09:00:00.000Z",
},
{
id: "ev-automation",
companyId: COMPANY_ID,
caseId: CASE_ID,
type: "automation_executed",
actorType: "system",
payload: { routineId: "routine-1", routineRunId: "routine-run-1", issueId: "issue-auto-1" },
automation: {
routine: { id: "routine-1", title: "Draft announcement" },
issue: {
id: "issue-auto-1",
identifier: "PAP-1001",
title: "Draft the launch announcement",
status: "todo",
},
routineRunId: "routine-run-1",
stage: { id: "stage-review", key: "review", name: "Review", kind: "review" },
},
createdAt: "2026-06-10T10:20:00.000Z",
updatedAt: "2026-06-10T10:20:00.000Z",
},
{
id: "ev-3",
companyId: COMPANY_ID,
caseId: CASE_ID,
type: "case.suggested",
actorType: "agent",
payload: { suggestion: { toStageKey: "done" } },
createdAt: "2026-06-10T10:00:00.000Z",
updatedAt: "2026-06-10T10:00:00.000Z",
},
{
id: "ev-4",
companyId: COMPANY_ID,
caseId: CASE_ID,
type: "case.conversation_opened",
actorType: "agent",
payload: {},
createdAt: "2026-06-10T10:30:00.000Z",
updatedAt: "2026-06-10T10:30:00.000Z",
},
];
const CONVERSATION_COMMENTS = [
{
id: "comment-1",
companyId: COMPANY_ID,
issueId: CONVERSATION_ISSUE_ID,
authorAgentId: null,
authorUserId: "user-board",
body: "Looks great — can we tighten the headline?",
authorType: "user",
presentation: null,
metadata: null,
createdAt: "2026-06-10T11:00:00.000Z",
updatedAt: "2026-06-10T11:00:00.000Z",
},
{
id: "comment-2",
companyId: COMPANY_ID,
issueId: CONVERSATION_ISSUE_ID,
authorAgentId: "agent-script",
authorUserId: null,
body: "Pushed a shorter version with a stronger hook.",
authorType: "agent",
presentation: null,
metadata: null,
createdAt: "2026-06-10T11:30:00.000Z",
updatedAt: "2026-06-10T11:30:00.000Z",
},
];
function buildCaseDetail(options: FixtureOptions) {
const fields: Record<string, unknown> = {
audience: ["Operators", "Founders"],
owner: { label: "Launch team" },
notes: "Keep it plain and direct.",
nextSuggestedStageId: options.pendingSuggestion ? "done" : undefined,
};
if (!options.pendingSuggestion) {
delete fields.nextSuggestedStageId;
}
if (options.changedNotice) {
fields.upstreamDrift = true;
}
return {
case: {
id: CASE_ID,
companyId: COMPANY_ID,
pipelineId: PIPELINE_ID,
stageId: "stage-review",
title: "Draft launch announcement",
summary: "Prepare a plain-language announcement for the launch.",
fields,
version: 4,
terminalKind: null,
childCount: options.withChildren ? CHILD_ROWS.length : 0,
terminalChildCount: options.withChildren ? 2 : 0,
pendingSuggestion: options.pendingSuggestion
? {
id: "suggestion-1",
toStageKey: "done",
rationale: "Approval criteria look met. Move forward?",
createdAt: "2026-06-10T10:00:00.000Z",
}
: null,
},
stage: STAGES[1],
pipeline: PIPELINE,
allowedNextStages: STAGES,
links: [],
blockers: [],
blocks: [],
childrenSummary: {
childCount: options.withChildren ? CHILD_ROWS.length : 0,
terminalChildCount: options.withChildren ? 2 : 0,
loadedChildren: options.withChildren ? CHILD_ROWS.length : 0,
},
pendingSuggestion: null,
};
}
function installFixtures(options: FixtureOptions) {
if (typeof window === "undefined") return;
const winAny = window as typeof window & {
__pipelineItemFetchInstalled?: boolean;
__pipelineItemOriginalFetch?: typeof window.fetch;
};
const fixtures: Record<string, unknown> = {
[`/api/pipelines/${PIPELINE_ID}`]: PIPELINE,
[`/api/cases/${CASE_ID}`]: buildCaseDetail(options),
[`/api/pipelines/${PIPELINE_ID}/cases?parentCaseId=${CASE_ID}`]: options.withChildren ? CHILD_ROWS : [],
[`/api/cases/${CASE_ID}/events?limit=100&order=asc`]: {
items: EVENTS,
pagination: { limit: 100, offset: 0, nextOffset: null, hasMore: false, order: "asc" },
},
[`/api/issues/${CONVERSATION_ISSUE_ID}/comments?limit=50&order=asc`]: CONVERSATION_COMMENTS,
[`/api/cases/${CASE_ID}/issue-links`]: options.withConversation ? LINKED_ISSUE_LINK : [],
[`/api/issues/${CONVERSATION_ISSUE_ID}/comments?order=asc&limit=50`]: CONVERSATION_COMMENTS,
};
const originalFetch = winAny.__pipelineItemFetchInstalled
? (winAny.__pipelineItemOriginalFetch as typeof window.fetch)
: window.fetch.bind(window);
winAny.__pipelineItemOriginalFetch = originalFetch;
winAny.__pipelineItemFetchInstalled = true;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const rawUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(rawUrl, window.location.origin);
const pathWithQuery = `${url.pathname}${url.search}`;
if (Object.prototype.hasOwnProperty.call(fixtures, pathWithQuery)) {
return Response.json(fixtures[pathWithQuery]);
}
if (Object.prototype.hasOwnProperty.call(fixtures, url.pathname)) {
return Response.json(fixtures[url.pathname]);
}
return originalFetch(input, init);
};
}
function Wrapper({ options }: { options: FixtureOptions }) {
const [ready, setReady] = useState(false);
useEffect(() => {
installFixtures(options);
setReady(true);
}, [options]);
if (!ready) return null;
return <PipelineItemDetailView pipelineId={PIPELINE_ID} caseId={CASE_ID} />;
}
function Frame({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="paperclip-story__frame">
<div className="border-b border-border bg-muted/30 px-5 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">QA fixture</p>
<h2 className="text-base font-semibold text-foreground">{title}</h2>
</div>
<div className="bg-background">{children}</div>
</div>
);
}
const meta: Meta = {
title: "Pipelines/Item detail (QA)",
parameters: {
layout: "fullscreen",
},
};
export default meta;
type Story = StoryObj;
export const FullPage: Story = {
name: "P4 — pending suggestion, conversation, children",
render: () => (
<Frame title="Tutorial item with pending suggestion, linked conversation, children">
<Wrapper
options={{
withConversation: true,
withChildren: true,
pendingSuggestion: true,
changedNotice: false,
}}
/>
</Frame>
),
};
export const NoConversationNoChildren: Story = {
name: "P4 empty — no conversation, no children",
render: () => (
<Frame title="Tutorial item without conversation or children">
<Wrapper
options={{
withConversation: false,
withChildren: false,
pendingSuggestion: false,
changedNotice: false,
}}
/>
</Frame>
),
};
export const ChangedBanner: Story = {
name: "P8 — This changed banner",
render: () => (
<Frame title="Item with upstream drift (This changed banner)">
<Wrapper
options={{
withConversation: true,
withChildren: true,
pendingSuggestion: false,
changedNotice: true,
}}
/>
</Frame>
),
};
export const ActivityTimeline: Story = {
name: "Activity — event-to-sentence timeline",
render: () => (
<Frame title="Activity timeline showing translated event sentences">
<Wrapper
options={{
withConversation: true,
withChildren: false,
pendingSuggestion: false,
changedNotice: false,
}}
/>
</Frame>
),
};
@@ -0,0 +1,147 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { PipelineHealthWarning } from "@paperclipai/shared";
import { PipelineHealthBar, StageHealthWarnings } from "@/components/PipelineHealthWarnings";
import { PipelineWorkReferences } from "@/components/PipelineWorkReferences";
import { extractWorkReferences } from "@/lib/pipeline-references";
/**
* Phase 3 (PAP-10941) prosumer surfaces: setup-health warnings on the board
* header + in stage settings, and typed work references on the case detail
* panel. These stories are the source for the UXDesigner copy review.
*/
const BOARD_WARNINGS: PipelineHealthWarning[] = [
{
code: "paused_agent",
stageId: "stage-drafting",
stageKey: "drafting",
stageName: "Drafting",
message: "Robin is paused, so this step won't run until they're back. Reassign it if you can't wait.",
},
{
code: "automation_no_instructions",
stageId: "stage-assets",
stageKey: "assets",
stageName: "Assets",
message: "Assigned to a teammate, but there are no instructions yet. Add instructions so this step doesn't stall.",
},
{
code: "review_no_approver",
stageId: "stage-final-review",
stageKey: "final_review",
stageName: "Final review",
message: "No approver picked yet, so work will pile up here. Choose who approves.",
},
{
code: "missing_pipeline_reference",
stageId: "stage-assets",
stageKey: "assets",
stageName: "Assets",
message: "These instructions hand off to a workflow that's been deleted. Point them at one that exists.",
},
{
code: "unset_required_variable",
stageId: "stage-intake",
stageKey: "intake",
stageName: "Intake",
message: '"Release notes" is empty. Fill it in so this step can run.',
},
];
/** A busier pipeline whose warning count exceeds the board-bar cap. */
const MANY_BOARD_WARNINGS: PipelineHealthWarning[] = [
...BOARD_WARNINGS,
{
code: "paused_agent",
stageId: "stage-edit",
stageKey: "edit",
stageName: "Edit",
message: "Assigned to a teammate who's no longer here. Pick someone else to run this step.",
},
{
code: "missing_stage_reference",
stageId: "stage-edit",
stageKey: "edit",
stageName: "Edit",
message: 'These instructions hand off to a step that no longer exists in "Content Production". Point them at one that does.',
},
{
code: "review_no_approver",
stageId: "stage-legal",
stageKey: "legal",
stageName: "Legal review",
message: "Casey is the approver and they're paused, so nothing can be approved until they're back.",
},
];
const meta: Meta = {
title: "Pipelines/Setup health",
parameters: { layout: "padded" },
};
export default meta;
/** The amber warning strip that sits at the top of a pipeline board. */
export const BoardHeaderWarningBar: StoryObj = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">Pipeline</p>
<h1 className="text-2xl font-semibold text-foreground">Content Production</h1>
<p className="mt-1 text-xs text-muted-foreground">12 total items</p>
</div>
</div>
<PipelineHealthBar warnings={BOARD_WARNINGS} onSelectStage={() => {}} />
</div>
),
};
/** The same bar on a busy pipeline: capped at 5, with a "+n more" trailing line. */
export const BoardHeaderWarningBarCapped: StoryObj = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">Pipeline</p>
<h1 className="text-2xl font-semibold text-foreground">Content Production</h1>
<p className="mt-1 text-xs text-muted-foreground">12 total items</p>
</div>
</div>
<PipelineHealthBar warnings={MANY_BOARD_WARNINGS} onSelectStage={() => {}} />
</div>
),
};
/** A single stage's warnings, as shown inside that stage's settings panel. */
export const StageSettingsWarning: StoryObj = {
render: () => (
<div className="w-full max-w-3xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Overview</h2>
</div>
<StageHealthWarnings
warnings={BOARD_WARNINGS.filter((warning) => warning.stageId === "stage-assets")}
/>
</div>
),
};
/** The "Linked work" section on the case detail panel, rendering typed references. */
export const CaseDetailTypedReferences: StoryObj = {
render: () => {
const references = extractWorkReferences({
workspaceRef: { path: "/content/spring-launch/blog", branch: "feature/spring-blog" },
fields: {
draft_doc: { kind: "url", url: "https://docs.example.com/spring-blog-draft", label: "Blog draft" },
hero_image: "https://cdn.example.com/assets/spring-hero.png",
work_issue: { issueId: "issue-1", identifier: "PAP-9912", title: "Write the spring launch blog" },
},
});
return (
<div className="w-full max-w-sm rounded-lg border border-border p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">Linked work</h3>
<PipelineWorkReferences references={references} />
</div>
);
},
};
@@ -0,0 +1,107 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { CompanySecret, RoutineEnvConfig } from "@paperclipai/shared";
import { StageSecretsPanel } from "@/components/StageSecretsPanel";
import { storybookSecrets } from "../fixtures/paperclipData";
const meta: Meta = {
title: "Product/Pipelines · Stage secrets tab",
parameters: {
layout: "fullscreen",
a11y: { test: "off" },
},
};
export default meta;
type Story = StoryObj;
async function fakeCreateSecret(name: string): Promise<CompanySecret> {
return {
...(storybookSecrets[0] as CompanySecret),
id: `secret-${name.toLowerCase()}`,
name,
key: name.toLowerCase(),
description: `Inline-created secret ${name}`,
};
}
function StageSecretsSurface({
hasAutomation,
agentName,
initial,
secretsLoading,
}: {
hasAutomation: boolean;
agentName?: string | null;
initial?: RoutineEnvConfig | null;
secretsLoading?: boolean;
}) {
const [env, setEnv] = useState<RoutineEnvConfig>(() => (initial ?? {}) as RoutineEnvConfig);
const [saving, setSaving] = useState(false);
const [savedKey, setSavedKey] = useState(() => JSON.stringify(initial ?? {}));
const dirty = JSON.stringify(env) !== savedKey;
return (
<div className="mx-auto w-full max-w-3xl p-6">
<StageSecretsPanel
hasAutomation={hasAutomation}
agentName={agentName}
agentIcon="bot"
secrets={storybookSecrets as CompanySecret[]}
secretsLoading={Boolean(secretsLoading)}
value={env}
onChange={setEnv}
onCreateSecret={fakeCreateSecret}
onSetupAutomation={() => {}}
onSave={() => {
setSaving(true);
window.setTimeout(() => {
setSavedKey(JSON.stringify(env));
setSaving(false);
}, 600);
}}
saving={saving}
dirty={dirty}
/>
</div>
);
}
export const NoAutomationEmpty: Story = {
name: "No automation — empty state",
render: () => <StageSecretsSurface hasAutomation={false} />,
};
export const ConfiguredBindings: Story = {
name: "Configured — secret refs + plain value",
render: () => (
<StageSecretsSurface
hasAutomation
agentName="Releasebot"
initial={{
GH_TOKEN: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
PROD_AWS_DEPLOY_KEY: { type: "secret_ref", secretId: "secret-aws-prod", version: 2 },
STAGE: { type: "plain", value: "production" },
}}
/>
),
};
export const WarningBindings: Story = {
name: "Warning — disabled + missing secret",
render: () => (
<StageSecretsSurface
hasAutomation
agentName="Releasebot"
initial={{
GITHUB_APP_PEM: { type: "secret_ref", secretId: "secret-github", version: "latest" },
ABANDONED: { type: "secret_ref", secretId: "missing-id", version: "latest" },
}}
/>
),
};
export const LoadingState: Story = {
name: "Loading secrets",
render: () => <StageSecretsSurface hasAutomation agentName="Releasebot" secretsLoading />,
};