mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Backend defaulted ingested items to stateKey="draft" while the UI hardcoded its workflow on "new", so fresh installs rendered the raw stateKey with no transition buttons. Extract the v1 default state and transition lists into src/workflow-defaults.ts and have both the worker (seed + in-memory store) and the UI (state pill, TransitionBar, action form) derive their state keys from that single module. transitionItem now also validates the destination against the queue's configured triage_queue_transitions and rejects unknown destinations with a 422 invalid_state_transition so direct API callers can't push items into made-up states. Regression tests assert the ingested stateKey matches the UI's allowedTransitions keys, that the UI sources from workflow-defaults, that the SQL column default agrees, and that transitionItem rejects unknown / skipped destinations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
972 lines
36 KiB
TypeScript
972 lines
36 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { Company } from "@paperclipai/plugin-sdk";
|
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
|
import manifest, {
|
|
PLUGIN_ID,
|
|
TRIAGE_ASSISTANT_AGENT_KEY,
|
|
TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
|
|
TRIAGE_MANAGED_SKILL_KEYS,
|
|
TRIAGE_PROJECT_KEY,
|
|
} from "../src/manifest.js";
|
|
import plugin, { createTriagePlugin } from "../src/worker.js";
|
|
import { createInMemoryTriageStore } from "../src/triage.js";
|
|
import {
|
|
DEFAULT_TRIAGE_DEFAULT_STATE_KEY,
|
|
DEFAULT_TRIAGE_QUEUE_TRANSITIONS,
|
|
} from "../src/workflow-defaults.js";
|
|
|
|
const COMPANY_ID = "company-1";
|
|
const OTHER_COMPANY_ID = "company-2";
|
|
|
|
function company(id: string, name = id): Company {
|
|
const now = new Date();
|
|
return {
|
|
id,
|
|
name,
|
|
description: null,
|
|
status: "active",
|
|
pauseReason: null,
|
|
pausedAt: null,
|
|
issuePrefix: id === COMPANY_ID ? "PAP" : "ALT",
|
|
issueCounter: 1,
|
|
budgetMonthlyCents: 0,
|
|
spentMonthlyCents: 0,
|
|
attachmentMaxBytes: 25_000_000,
|
|
requireBoardApprovalForNewAgents: false,
|
|
feedbackDataSharingEnabled: false,
|
|
feedbackDataSharingConsentAt: null,
|
|
feedbackDataSharingConsentByUserId: null,
|
|
feedbackDataSharingTermsVersion: null,
|
|
brandColor: null,
|
|
logoAssetId: null,
|
|
logoUrl: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
describe("Paperclip Triage scaffold", () => {
|
|
it("declares the package manifest, UI slots, and managed resources", () => {
|
|
expect(manifest).toMatchObject({
|
|
id: PLUGIN_ID,
|
|
});
|
|
expect(manifest.database).toEqual({
|
|
namespaceSlug: "triage",
|
|
migrationsDir: "migrations",
|
|
coreReadTables: ["companies", "issues"],
|
|
});
|
|
expect(manifest.apiRoutes).toEqual([
|
|
expect.objectContaining({
|
|
routeKey: "items.ingest",
|
|
method: "POST",
|
|
path: "/queues/:queueKey/items",
|
|
}),
|
|
]);
|
|
expect(manifest.capabilities).toEqual(expect.arrayContaining([
|
|
"api.routes.register",
|
|
"database.namespace.migrate",
|
|
"database.namespace.read",
|
|
"database.namespace.write",
|
|
"companies.read",
|
|
"issues.read",
|
|
"issues.create",
|
|
"issues.update",
|
|
"issue.comments.read",
|
|
"issue.comments.create",
|
|
"agents.managed",
|
|
"projects.managed",
|
|
"skills.managed",
|
|
"instance.settings.register",
|
|
"ui.sidebar.register",
|
|
"ui.page.register",
|
|
]));
|
|
expect(manifest.ui?.slots).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ type: "sidebar", exportName: "SidebarLink" }),
|
|
expect.objectContaining({ type: "page", exportName: "TriagePage", routePath: "triage" }),
|
|
expect.objectContaining({ type: "routeSidebar", exportName: "TriageRouteSidebar", routePath: "triage" }),
|
|
expect.objectContaining({ type: "settingsPage", exportName: "SettingsPage" }),
|
|
]));
|
|
expect(manifest.agents?.[0]).toEqual(expect.objectContaining({
|
|
agentKey: TRIAGE_ASSISTANT_AGENT_KEY,
|
|
displayName: "Triage Assistant",
|
|
status: "paused",
|
|
permissions: { pluginTools: [PLUGIN_ID] },
|
|
}));
|
|
expect(manifest.agents?.[0]?.adapterConfig?.paperclipSkillSync).toEqual({
|
|
desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
|
|
});
|
|
expect(manifest.projects?.[0]).toEqual(expect.objectContaining({
|
|
projectKey: TRIAGE_PROJECT_KEY,
|
|
displayName: "Triage",
|
|
}));
|
|
expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual(TRIAGE_MANAGED_SKILL_KEYS);
|
|
});
|
|
|
|
it("declares the plugin-owned triage migration shape", () => {
|
|
const migration = readFileSync(new URL("../migrations/001_triage_core.sql", import.meta.url), "utf8");
|
|
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queues");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_states");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_transitions");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_items");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_chats");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_docs");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_doc_revisions");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_guidance_proposals");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_item_events");
|
|
expect(migration).toContain("CREATE TABLE plugin_triage_f3b4aa721e.triage_transition_actions");
|
|
expect(migration).toContain("REFERENCES public.companies(id)");
|
|
expect(migration).toContain("REFERENCES public.issues(id)");
|
|
expect(migration).toContain("CREATE UNIQUE INDEX triage_items_queue_item_key_idx");
|
|
expect(migration).toContain("CREATE UNIQUE INDEX triage_items_queue_idempotency_key_idx");
|
|
});
|
|
|
|
it("reports missing managed resources before reconcile", async () => {
|
|
const harness = createTestHarness({ manifest });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const health = await harness.getData<{ status: string; skills: Array<{ status: string }> }>(
|
|
"managed-resource-health",
|
|
{ companyId: COMPANY_ID },
|
|
);
|
|
|
|
expect(health.status).toBe("missing");
|
|
expect(health.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
|
|
expect(health.skills.every((skill) => skill.status === "missing")).toBe(true);
|
|
});
|
|
|
|
it("reconciles managed project, assistant, and skills", async () => {
|
|
const harness = createTestHarness({ manifest });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await harness.performAction<{
|
|
status: string;
|
|
agent: { status: string; agentId: string | null };
|
|
project: { status: string; projectId: string | null };
|
|
skills: Array<{ status: string; skillId: string | null; key: string | null }>;
|
|
}>("reconcile-managed-resources", { companyId: COMPANY_ID });
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.agent).toEqual(expect.objectContaining({ status: "created" }));
|
|
expect(result.agent.agentId).toBeTruthy();
|
|
expect(result.project).toEqual(expect.objectContaining({ status: "created" }));
|
|
expect(result.project.projectId).toBeTruthy();
|
|
expect(result.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
|
|
expect(result.skills.every((skill) => skill.status === "created")).toBe(true);
|
|
expect(result.skills.map((skill) => skill.key)).toEqual(TRIAGE_MANAGED_SKILL_CANONICAL_KEYS);
|
|
});
|
|
});
|
|
|
|
describe("Paperclip Triage queue and item ingest", () => {
|
|
function createHarness() {
|
|
const store = createInMemoryTriageStore();
|
|
const testPlugin = createTriagePlugin({ createStore: () => store });
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] });
|
|
return { harness, plugin: testPlugin, store };
|
|
}
|
|
|
|
it("creates and updates queues through worker actions", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const created = await harness.performAction<{ queue: { queueKey: string; title: string }; created: boolean }>(
|
|
"create-queue",
|
|
{ companyId: COMPANY_ID, queueKey: "reviews", title: "Reviews" },
|
|
);
|
|
expect(created).toMatchObject({
|
|
created: true,
|
|
queue: { queueKey: "reviews", title: "Reviews" },
|
|
});
|
|
|
|
await harness.performAction("update-queue", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Editorial Reviews",
|
|
});
|
|
|
|
await expect(harness.getData("queue", { companyId: COMPANY_ID, queueKey: "reviews" }))
|
|
.resolves.toMatchObject({ queueKey: "reviews", title: "Editorial Reviews" });
|
|
await expect(harness.getData("queues", { companyId: OTHER_COMPANY_ID }))
|
|
.resolves.toEqual([]);
|
|
});
|
|
|
|
it("strictly rejects missing queues when requireExistingQueue is true", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
await expect(harness.performAction("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "unknown",
|
|
title: "Should not create",
|
|
requireExistingQueue: true,
|
|
})).rejects.toMatchObject({
|
|
status: 404,
|
|
code: "queue_not_found",
|
|
});
|
|
|
|
await expect(harness.getData("queues", { companyId: COMPANY_ID })).resolves.toEqual([]);
|
|
});
|
|
|
|
it("auto-creates a queue on ingest and upserts by stable item key", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const first = await harness.performAction<{
|
|
createdQueue: boolean;
|
|
createdItem: boolean;
|
|
item: { id: string; title: string; content: string; revision: number };
|
|
queue: { queueKey: string; activeItemCount: number };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "content-training",
|
|
title: "Draft launch post",
|
|
content: "# Draft",
|
|
itemKey: "external-1",
|
|
properties: { sourceKind: "fixture" },
|
|
});
|
|
|
|
expect(first).toMatchObject({
|
|
createdQueue: true,
|
|
createdItem: true,
|
|
item: { title: "Draft launch post", content: "# Draft", revision: 1 },
|
|
queue: { queueKey: "content-training", activeItemCount: 1 },
|
|
});
|
|
|
|
const second = await harness.performAction<{
|
|
createdQueue: boolean;
|
|
createdItem: boolean;
|
|
upserted: boolean;
|
|
item: { id: string; title: string; content: string; revision: number };
|
|
queue: { activeItemCount: number };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "content-training",
|
|
title: "Revised launch post",
|
|
content: "# Revised",
|
|
itemKey: "external-1",
|
|
});
|
|
|
|
expect(second).toMatchObject({
|
|
createdQueue: false,
|
|
createdItem: false,
|
|
upserted: true,
|
|
item: { id: first.item.id, title: "Revised launch post", content: "# Revised", revision: 2 },
|
|
queue: { activeItemCount: 1 },
|
|
});
|
|
|
|
await expect(harness.getData("queue-items", { companyId: COMPANY_ID, queueKey: "content-training" }))
|
|
.resolves.toHaveLength(1);
|
|
});
|
|
|
|
it("keeps queue keys company-scoped and validates scoped API company context", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
await harness.performAction("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "inbox",
|
|
title: "Company one item",
|
|
idempotencyKey: "same-upstream-request",
|
|
});
|
|
await harness.performAction("ingest-item", {
|
|
companyId: OTHER_COMPANY_ID,
|
|
queueKey: "inbox",
|
|
title: "Company two item",
|
|
idempotencyKey: "same-upstream-request",
|
|
});
|
|
|
|
await expect(harness.getData("queue-items", { companyId: COMPANY_ID, queueKey: "inbox" }))
|
|
.resolves.toEqual([expect.objectContaining({ title: "Company one item" })]);
|
|
await expect(harness.getData("queue-items", { companyId: OTHER_COMPANY_ID, queueKey: "inbox" }))
|
|
.resolves.toEqual([expect.objectContaining({ title: "Company two item" })]);
|
|
|
|
const mismatch = await testPlugin.definition.onApiRequest?.({
|
|
routeKey: "items.ingest",
|
|
method: "POST",
|
|
path: "/queues/inbox/items",
|
|
params: { queueKey: "inbox" },
|
|
query: {},
|
|
body: {
|
|
companyId: OTHER_COMPANY_ID,
|
|
title: "Wrong company",
|
|
},
|
|
actor: {
|
|
actorType: "user",
|
|
actorId: "board",
|
|
userId: "board",
|
|
agentId: null,
|
|
runId: null,
|
|
},
|
|
companyId: COMPANY_ID,
|
|
headers: {},
|
|
});
|
|
|
|
expect(mismatch).toEqual({
|
|
status: 403,
|
|
body: {
|
|
error: {
|
|
code: "company_scope_mismatch",
|
|
message: "Request companyId does not match the resolved plugin route company",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reuses one hidden queue chat and assistant session per queue while isolating other queues", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
const boardUserId = "board-user-1";
|
|
const commentCalls: Array<{
|
|
issueId: string;
|
|
body: string;
|
|
companyId: string;
|
|
options: Record<string, unknown> | undefined;
|
|
}> = [];
|
|
const originalCreateComment = harness.ctx.issues.createComment.bind(harness.ctx.issues);
|
|
harness.ctx.issues.createComment = async (issueId, body, companyId, options) => {
|
|
commentCalls.push({ issueId, body, companyId, options: options as Record<string, unknown> | undefined });
|
|
return originalCreateComment(issueId, body, companyId, options);
|
|
};
|
|
|
|
const first = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Launch review",
|
|
content: "Please review the launch copy.",
|
|
properties: { sourceKind: "manual" },
|
|
});
|
|
const other = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "support",
|
|
title: "Support review",
|
|
content: "Please review the support reply.",
|
|
});
|
|
|
|
const firstSend = await harness.performAction<{
|
|
chat: { id: string; hiddenIssueId: string; metadata: Record<string, unknown> };
|
|
hiddenIssueId: string;
|
|
session: { sessionId: string };
|
|
runId: string;
|
|
prompt: string;
|
|
}>("send-assistant-message", {
|
|
companyId: COMPANY_ID,
|
|
itemId: first.item.id,
|
|
message: "What should I change?",
|
|
actorType: "user",
|
|
actorUserId: boardUserId,
|
|
});
|
|
|
|
expect(firstSend.hiddenIssueId).toBeTruthy();
|
|
expect(firstSend.chat.hiddenIssueId).toBe(firstSend.hiddenIssueId);
|
|
expect(firstSend.runId).toBeTruthy();
|
|
expect(firstSend.prompt).toContain("Queue: Reviews (reviews)");
|
|
expect(firstSend.prompt).toContain("Please review the launch copy.");
|
|
expect(firstSend.prompt).toContain("guidance.md");
|
|
expect(firstSend.prompt).toContain("What should I change?");
|
|
|
|
const secondSend = await harness.performAction<{
|
|
chat: { id: string; hiddenIssueId: string };
|
|
hiddenIssueId: string;
|
|
session: { sessionId: string };
|
|
}>("send-assistant-message", {
|
|
companyId: COMPANY_ID,
|
|
itemId: first.item.id,
|
|
message: "Use the same thread.",
|
|
actorType: "user",
|
|
actorUserId: boardUserId,
|
|
});
|
|
expect(secondSend.chat.id).toBe(firstSend.chat.id);
|
|
expect(secondSend.hiddenIssueId).toBe(firstSend.hiddenIssueId);
|
|
expect(secondSend.session.sessionId).toBe(firstSend.session.sessionId);
|
|
|
|
const otherSend = await harness.performAction<{
|
|
chat: { id: string };
|
|
hiddenIssueId: string;
|
|
}>("send-assistant-message", {
|
|
companyId: COMPANY_ID,
|
|
itemId: other.item.id,
|
|
message: "Different queue.",
|
|
actorType: "user",
|
|
actorUserId: boardUserId,
|
|
});
|
|
expect(otherSend.chat.id).not.toBe(firstSend.chat.id);
|
|
expect(otherSend.hiddenIssueId).not.toBe(firstSend.hiddenIssueId);
|
|
|
|
const hiddenComments = await harness.ctx.issues.listComments(firstSend.hiddenIssueId, COMPANY_ID);
|
|
expect(hiddenComments).toEqual(expect.arrayContaining([expect.objectContaining({
|
|
body: expect.stringContaining("What should I change?"),
|
|
})]));
|
|
expect(commentCalls).toEqual(expect.arrayContaining([expect.objectContaining({
|
|
companyId: COMPANY_ID,
|
|
options: expect.objectContaining({ authorUserId: boardUserId }),
|
|
})]));
|
|
});
|
|
|
|
it("updates item content only when the expected revision matches", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
const boardUserId = "board-user-1";
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string; revision: number };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Original",
|
|
content: "Original content",
|
|
});
|
|
|
|
const updated = await harness.performAction<{
|
|
item: { title: string; content: string; revision: number };
|
|
event: { eventType: string; metadata: Record<string, unknown> };
|
|
}>("update-item-content", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
expectedRevision: ingested.item.revision,
|
|
title: "Edited",
|
|
content: "Edited content",
|
|
actorType: "user",
|
|
actorUserId: boardUserId,
|
|
});
|
|
|
|
expect(updated.item).toMatchObject({ title: "Edited", content: "Edited content", revision: 2 });
|
|
expect(updated.event).toMatchObject({
|
|
eventType: "item.content.updated",
|
|
actorType: "user",
|
|
actorId: boardUserId,
|
|
metadata: { previousRevision: 1, nextRevision: 2 },
|
|
});
|
|
|
|
await expect(harness.performAction("update-item-content", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
expectedRevision: 1,
|
|
content: "Stale edit",
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
code: "item_revision_conflict",
|
|
});
|
|
});
|
|
|
|
it("supports guidance proposal generation, revision, acceptance, rejection, and manual edits", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Review example",
|
|
content: "Prefer concise copy.",
|
|
});
|
|
|
|
const manual = await harness.performAction<{
|
|
doc: { content: string; currentRevisionId: string };
|
|
revision: { id: string; summary: string };
|
|
}>("manual-edit-guidance", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
content: "# Reviews Guidance\n\nStart from the user's target audience.",
|
|
summary: "Seed guidance",
|
|
});
|
|
expect(manual.doc.content).toContain("target audience");
|
|
expect(manual.doc.currentRevisionId).toBe(manual.revision.id);
|
|
|
|
const generated = await harness.performAction<{
|
|
proposal: { id: string; status: string; proposedContent: string };
|
|
}>("generate-guidance-proposal", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
suggestedChange: "For launch copy, flag vague benefits before approving.",
|
|
rationale: "The item exposed a repeatable approval rule.",
|
|
});
|
|
expect(generated.proposal.status).toBe("proposed");
|
|
expect(generated.proposal.proposedContent).toContain("target audience");
|
|
expect(generated.proposal.proposedContent).toContain("flag vague benefits");
|
|
|
|
const revised = await harness.performAction<{ status: string; proposedContent: string }>(
|
|
"revise-guidance-proposal",
|
|
{
|
|
companyId: COMPANY_ID,
|
|
proposalId: generated.proposal.id,
|
|
proposedContent: "# Reviews Guidance\n\nFlag vague benefits and ask for concrete proof.",
|
|
rationale: "Sharper wording.",
|
|
},
|
|
);
|
|
expect(revised).toMatchObject({ status: "revised" });
|
|
|
|
const accepted = await harness.performAction<{
|
|
proposal: { status: string; metadata: Record<string, unknown> };
|
|
doc: { content: string; currentRevisionId: string };
|
|
revision: { id: string };
|
|
}>("accept-guidance-proposal", {
|
|
companyId: COMPANY_ID,
|
|
proposalId: generated.proposal.id,
|
|
});
|
|
expect(accepted.proposal.status).toBe("accepted");
|
|
expect(accepted.proposal.metadata.acceptedRevisionId).toBe(accepted.revision.id);
|
|
expect(accepted.doc.content).toContain("concrete proof");
|
|
expect(accepted.doc.currentRevisionId).toBe(accepted.revision.id);
|
|
|
|
const rejectedCandidate = await harness.performAction<{
|
|
proposal: { id: string };
|
|
}>("generate-guidance-proposal", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
proposedContent: "# Reviews Guidance\n\nToo broad.",
|
|
});
|
|
const rejected = await harness.performAction<{ status: string; metadata: Record<string, unknown> }>(
|
|
"reject-guidance-proposal",
|
|
{
|
|
companyId: COMPANY_ID,
|
|
proposalId: rejectedCandidate.proposal.id,
|
|
reason: "Too broad for this queue.",
|
|
},
|
|
);
|
|
expect(rejected).toMatchObject({
|
|
status: "rejected",
|
|
metadata: expect.objectContaining({ rejectionReason: "Too broad for this queue." }),
|
|
});
|
|
await expect(harness.performAction("accept-guidance-proposal", {
|
|
companyId: COMPANY_ID,
|
|
proposalId: rejectedCandidate.proposal.id,
|
|
})).rejects.toMatchObject({
|
|
status: 409,
|
|
code: "guidance_proposal_rejected",
|
|
});
|
|
});
|
|
|
|
it("creates a linked work issue from a create_if_missing transition action and records history", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
const boardUserId = "board-user-1";
|
|
const createCalls: Array<Record<string, unknown>> = [];
|
|
const commentCalls: Array<Record<string, unknown> | undefined> = [];
|
|
const originalCreateIssue = harness.ctx.issues.create.bind(harness.ctx.issues);
|
|
const originalCreateComment = harness.ctx.issues.createComment.bind(harness.ctx.issues);
|
|
harness.ctx.issues.create = async (input) => {
|
|
createCalls.push(input as Record<string, unknown>);
|
|
return originalCreateIssue(input);
|
|
};
|
|
harness.ctx.issues.createComment = async (issueId, body, companyId, options) => {
|
|
commentCalls.push(options as Record<string, unknown> | undefined);
|
|
return originalCreateComment(issueId, body, companyId, options);
|
|
};
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Draft launch post",
|
|
content: "# Draft\n\nMake this sharper.",
|
|
properties: { sourceKind: "fixture", priority: "high" },
|
|
});
|
|
|
|
await harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "create-work",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "create_if_missing",
|
|
template: {
|
|
title: "{{item.title}}",
|
|
description: "{{item.content}}\n\nMetadata:\n{{item.propertiesJson}}",
|
|
comment: "Triage item moved to {{transition.toStateKey}}.",
|
|
priority: "high",
|
|
status: "todo",
|
|
},
|
|
},
|
|
});
|
|
|
|
const transitioned = await harness.performAction<{
|
|
item: { stateKey: string; linkedWorkIssueId: string | null };
|
|
actionResults: Array<{ result: string; issueId: string | null; commentCreated: boolean }>;
|
|
}>("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "approved",
|
|
actorType: "user",
|
|
actorUserId: boardUserId,
|
|
});
|
|
|
|
expect(transitioned.item.stateKey).toBe("approved");
|
|
expect(transitioned.item.linkedWorkIssueId).toBeTruthy();
|
|
expect(transitioned.actionResults).toEqual([
|
|
expect.objectContaining({ result: "created", issueId: transitioned.item.linkedWorkIssueId, commentCreated: true }),
|
|
]);
|
|
|
|
const issue = await harness.ctx.issues.get(transitioned.item.linkedWorkIssueId!, COMPANY_ID);
|
|
expect(issue).toMatchObject({
|
|
title: "Draft launch post",
|
|
description: expect.stringContaining("Make this sharper."),
|
|
status: "todo",
|
|
priority: "high",
|
|
originId: ingested.item.id,
|
|
});
|
|
const comments = await harness.ctx.issues.listComments(transitioned.item.linkedWorkIssueId!, COMPANY_ID);
|
|
expect(comments).toEqual([expect.objectContaining({
|
|
body: "Triage item moved to approved.",
|
|
})]);
|
|
expect(createCalls).toEqual(expect.arrayContaining([expect.objectContaining({
|
|
actor: expect.objectContaining({ actorUserId: boardUserId }),
|
|
})]));
|
|
expect(commentCalls).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ authorUserId: boardUserId }),
|
|
]));
|
|
|
|
const events = await harness.getData<Array<{ eventType: string; metadata: Record<string, unknown> }>>(
|
|
"item-events",
|
|
{ companyId: COMPANY_ID, itemId: ingested.item.id },
|
|
);
|
|
expect(events.map((event) => event.eventType)).toEqual(expect.arrayContaining([
|
|
"item.transitioned",
|
|
"transition.action.executed",
|
|
"item.ingested.created",
|
|
]));
|
|
expect(events.find((event) => event.eventType === "transition.action.executed")?.metadata)
|
|
.toMatchObject({ actionKey: "create-work", result: "created", commentCreated: true });
|
|
});
|
|
|
|
it("updates an existing linked work issue from an update_existing transition action", async () => {
|
|
const { harness, plugin: testPlugin, store } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const issue = await harness.ctx.issues.create({
|
|
companyId: COMPANY_ID,
|
|
title: "Old title",
|
|
status: "todo",
|
|
priority: "medium",
|
|
});
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Needs owner",
|
|
content: "Please assign this.",
|
|
});
|
|
await store.updateItem({
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
linkedWorkIssueId: issue.id,
|
|
});
|
|
await harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "update-work",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "update_existing",
|
|
template: {
|
|
title: "Updated: {{item.title}}",
|
|
comment: "Existing work issue updated for {{transition.toStateKey}}.",
|
|
priority: "high",
|
|
status: "in_review",
|
|
},
|
|
},
|
|
});
|
|
|
|
const transitioned = await harness.performAction<{
|
|
actionResults: Array<{ result: string; issueId: string | null; commentCreated: boolean }>;
|
|
}>("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "approved",
|
|
});
|
|
|
|
expect(transitioned.actionResults).toEqual([
|
|
expect.objectContaining({ result: "updated", issueId: issue.id, commentCreated: true }),
|
|
]);
|
|
await expect(harness.ctx.issues.get(issue.id, COMPANY_ID)).resolves.toMatchObject({
|
|
title: "Updated: Needs owner",
|
|
status: "in_review",
|
|
priority: "high",
|
|
});
|
|
await expect(harness.ctx.issues.listComments(issue.id, COMPANY_ID)).resolves.toEqual([
|
|
expect.objectContaining({ body: "Existing work issue updated for approved." }),
|
|
]);
|
|
});
|
|
|
|
it("create_or_update creates when missing and updates the same linked issue later", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Reusable handoff",
|
|
content: "Create once, update later.",
|
|
});
|
|
await harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "create-or-update",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "create_or_update",
|
|
template: {
|
|
title: "{{item.title}}",
|
|
description: "{{item.content}}",
|
|
status: "todo",
|
|
},
|
|
},
|
|
});
|
|
const first = await harness.performAction<{
|
|
item: { linkedWorkIssueId: string | null };
|
|
actionResults: Array<{ result: string; issueId: string | null }>;
|
|
}>("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "approved",
|
|
});
|
|
|
|
await harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "finish-work",
|
|
fromStateKey: "approved",
|
|
toStateKey: "done",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "create_or_update",
|
|
template: {
|
|
title: "Finished: {{item.title}}",
|
|
comment: "Completed from triage.",
|
|
status: "done",
|
|
},
|
|
},
|
|
});
|
|
const second = await harness.performAction<{
|
|
item: { linkedWorkIssueId: string | null };
|
|
actionResults: Array<{ result: string; issueId: string | null }>;
|
|
}>("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "done",
|
|
});
|
|
|
|
expect(first.actionResults).toEqual([expect.objectContaining({ result: "created" })]);
|
|
expect(second.actionResults).toEqual([
|
|
expect.objectContaining({ result: "updated", issueId: first.item.linkedWorkIssueId }),
|
|
]);
|
|
expect(second.item.linkedWorkIssueId).toBe(first.item.linkedWorkIssueId);
|
|
await expect(harness.ctx.issues.get(first.item.linkedWorkIssueId!, COMPANY_ID)).resolves.toMatchObject({
|
|
title: "Finished: Reusable handoff",
|
|
status: "done",
|
|
});
|
|
});
|
|
|
|
it("rejects unsupported transition action types and invalid issue templates", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
await harness.performAction("create-queue", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Reviews",
|
|
});
|
|
|
|
await expect(harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "external-call",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "webhook",
|
|
mode: "create_or_update",
|
|
template: { title: "Nope" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: "unsupported_transition_action",
|
|
});
|
|
|
|
await expect(harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "bad-template",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "create_or_update",
|
|
template: { title: "{{item.title}}", externalUrl: "https://example.test" },
|
|
},
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: "invalid_transition_action_template",
|
|
});
|
|
});
|
|
|
|
it("denies transition actions linked to a work issue from another company", async () => {
|
|
const { harness, plugin: testPlugin, store } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const otherIssue = await harness.ctx.issues.create({
|
|
companyId: OTHER_COMPANY_ID,
|
|
title: "Other company issue",
|
|
});
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Cross-company link",
|
|
});
|
|
await store.updateItem({
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
linkedWorkIssueId: otherIssue.id,
|
|
});
|
|
await harness.performAction("upsert-transition-action", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
actionKey: "update-work",
|
|
fromStateKey: "draft",
|
|
toStateKey: "approved",
|
|
action: {
|
|
type: "create_or_update_issue",
|
|
mode: "update_existing",
|
|
template: {
|
|
title: "Should not update",
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(harness.performAction("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "approved",
|
|
})).rejects.toMatchObject({
|
|
status: 403,
|
|
code: "linked_issue_cross_company_denied",
|
|
});
|
|
|
|
await expect(harness.getData("queue-item", { companyId: COMPANY_ID, itemId: ingested.item.id }))
|
|
.resolves.toMatchObject({ stateKey: "draft" });
|
|
await expect(harness.ctx.issues.get(otherIssue.id, OTHER_COMPANY_ID)).resolves.toMatchObject({
|
|
title: "Other company issue",
|
|
});
|
|
});
|
|
});
|
|
|
|
// PAP-9894: backend defaulted ingested items to stateKey="draft" while the
|
|
// UI hardcoded "new", so transition buttons disappeared on fresh installs.
|
|
// These regressions lock the alignment in place.
|
|
describe("Paperclip Triage default workflow alignment", () => {
|
|
function createHarness() {
|
|
const store = createInMemoryTriageStore();
|
|
const testPlugin = createTriagePlugin({ createStore: () => store });
|
|
const harness = createTestHarness({ manifest });
|
|
harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] });
|
|
return { harness, plugin: testPlugin, store };
|
|
}
|
|
|
|
it("ingests with the canonical default state key and has at least one allowed UI transition", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const result = await harness.performAction<{
|
|
item: { stateKey: string };
|
|
queue: { defaultStateKey: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "probe-q",
|
|
title: "Probe",
|
|
content: "Probe content",
|
|
});
|
|
|
|
expect(result.queue.defaultStateKey).toBe(DEFAULT_TRIAGE_DEFAULT_STATE_KEY);
|
|
expect(result.item.stateKey).toBe(DEFAULT_TRIAGE_DEFAULT_STATE_KEY);
|
|
|
|
// The UI's TransitionBar filters DEFAULT_TRANSITIONS by item.stateKey. If
|
|
// the ingested key is not present as a `fromStateKey` in the default
|
|
// transitions, the bar renders empty and PAP-9894 returns.
|
|
const transitionsFromDefault = DEFAULT_TRIAGE_QUEUE_TRANSITIONS.filter(
|
|
(transition) => transition.fromStateKey === result.item.stateKey,
|
|
);
|
|
expect(transitionsFromDefault.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps the UI workflow keys identical to the shared workflow defaults", () => {
|
|
const uiSource = readFileSync(new URL("../src/ui/app.tsx", import.meta.url), "utf8");
|
|
// The UI must derive its state and transition lists from the shared
|
|
// `workflow-defaults` module, never re-declare them.
|
|
expect(uiSource).toContain("from \"../workflow-defaults.js\"");
|
|
expect(uiSource).toContain("DEFAULT_TRIAGE_QUEUE_STATES.map");
|
|
expect(uiSource).toContain("DEFAULT_TRIAGE_QUEUE_TRANSITIONS.map");
|
|
|
|
const migrationSource = readFileSync(
|
|
new URL("../migrations/001_triage_core.sql", import.meta.url),
|
|
"utf8",
|
|
);
|
|
expect(migrationSource).toContain(`default_state_key text NOT NULL DEFAULT '${DEFAULT_TRIAGE_DEFAULT_STATE_KEY}'`);
|
|
});
|
|
|
|
it("rejects transition-item calls whose destination is not in the queue's configured transitions", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string; stateKey: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Probe",
|
|
});
|
|
|
|
await expect(harness.performAction("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "qa-pending",
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: "invalid_state_transition",
|
|
});
|
|
|
|
// The item should still be in its starting state — the validation must
|
|
// run before any mutation.
|
|
await expect(harness.getData("queue-item", { companyId: COMPANY_ID, itemId: ingested.item.id }))
|
|
.resolves.toMatchObject({ stateKey: DEFAULT_TRIAGE_DEFAULT_STATE_KEY });
|
|
});
|
|
|
|
it("rejects transition-item calls that skip a configured step (draft → done with no direct edge)", async () => {
|
|
const { harness, plugin: testPlugin } = createHarness();
|
|
await testPlugin.definition.setup(harness.ctx);
|
|
|
|
const ingested = await harness.performAction<{
|
|
item: { id: string };
|
|
}>("ingest-item", {
|
|
companyId: COMPANY_ID,
|
|
queueKey: "reviews",
|
|
title: "Probe",
|
|
});
|
|
|
|
await expect(harness.performAction("transition-item", {
|
|
companyId: COMPANY_ID,
|
|
itemId: ingested.item.id,
|
|
toStateKey: "done",
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: "invalid_state_transition",
|
|
});
|
|
});
|
|
});
|