mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Rescue pipeline coverage from PR 7907
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 />,
|
||||
};
|
||||
Reference in new issue
Block a user