mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Fix missions plugin workflow endpoints
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8231868774
commit
1b9800907e
5 files changed
+531
-8
No files matched your search
@@ -1,4 +1,4 @@
|
||||
CREATE TABLE plugin_missions_8ceb7cd69c.mission_issue_links (
|
||||
CREATE TABLE plugin_missions_8c47c0d099.mission_issue_links (
|
||||
mission_issue_id uuid NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
generated_issue_id uuid NOT NULL REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
generated_kind text NOT NULL,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
CREATE TABLE plugin_missions_8ceb7cd69c.missions (
|
||||
CREATE TABLE plugin_missions_8c47c0d099.missions (
|
||||
mission_issue_id uuid PRIMARY KEY REFERENCES public.issues(id) ON DELETE CASCADE,
|
||||
company_id uuid NOT NULL,
|
||||
state text NOT NULL DEFAULT 'draft',
|
||||
@@ -10,8 +10,8 @@ CREATE TABLE plugin_missions_8ceb7cd69c.missions (
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_missions_8ceb7cd69c.mission_findings (
|
||||
mission_issue_id uuid NOT NULL REFERENCES plugin_missions_8ceb7cd69c.missions(mission_issue_id) ON DELETE CASCADE,
|
||||
CREATE TABLE plugin_missions_8c47c0d099.mission_findings (
|
||||
mission_issue_id uuid NOT NULL REFERENCES plugin_missions_8c47c0d099.missions(mission_issue_id) ON DELETE CASCADE,
|
||||
finding_key text NOT NULL,
|
||||
validation_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
fix_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
@@ -24,8 +24,8 @@ CREATE TABLE plugin_missions_8ceb7cd69c.mission_findings (
|
||||
PRIMARY KEY (mission_issue_id, finding_key)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_missions_8ceb7cd69c.mission_events (
|
||||
mission_issue_id uuid NOT NULL REFERENCES plugin_missions_8ceb7cd69c.missions(mission_issue_id) ON DELETE CASCADE,
|
||||
CREATE TABLE plugin_missions_8c47c0d099.mission_events (
|
||||
mission_issue_id uuid NOT NULL REFERENCES plugin_missions_8c47c0d099.missions(mission_issue_id) ON DELETE CASCADE,
|
||||
company_id uuid NOT NULL,
|
||||
event_key text NOT NULL,
|
||||
event_type text NOT NULL,
|
||||
|
||||
@@ -27,12 +27,21 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
author: "Paperclip",
|
||||
categories: ["automation", "ui"],
|
||||
capabilities: [
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issues.checkout",
|
||||
"issues.wakeup",
|
||||
"issues.orchestration.read",
|
||||
"issue.relations.read",
|
||||
"issue.relations.write",
|
||||
"issue.comments.create",
|
||||
"issue.documents.read",
|
||||
"issue.documents.write",
|
||||
"issue.subtree.read",
|
||||
@@ -47,6 +56,65 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "missions",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["issues"],
|
||||
},
|
||||
apiRoutes: [
|
||||
{
|
||||
routeKey: "init",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/missions/init",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
{
|
||||
routeKey: "summary",
|
||||
method: "GET",
|
||||
path: "/issues/:issueId/missions/summary",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
{
|
||||
routeKey: "decompose",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/missions/decompose",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
{
|
||||
routeKey: "advance",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/missions/advance",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
{
|
||||
routeKey: "waive",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/missions/findings/:findingKey/waive",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
{
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/missions",
|
||||
auth: "board-or-agent",
|
||||
capability: "api.routes.register",
|
||||
companyResolution: { from: "query", key: "companyId" },
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import type { Agent } from "@paperclipai/plugin-sdk";
|
||||
import type { Agent, PluginApiRequestInput, PluginContext } from "@paperclipai/plugin-sdk";
|
||||
import { decomposeMission } from "./decompose.js";
|
||||
import type { MissionSettings } from "./mission-service.js";
|
||||
import {
|
||||
initializeMission,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
readMissionSettings,
|
||||
writeMissionSettings,
|
||||
} from "./mission-service.js";
|
||||
import { advanceMission, waiveMissionFinding } from "./mission-runtime.js";
|
||||
|
||||
type MissionAgentSummary = {
|
||||
id: string;
|
||||
@@ -17,11 +19,17 @@ type MissionAgentSummary = {
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
let activeCtx: PluginContext | null = null;
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function optionalInteger(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
@@ -68,8 +76,76 @@ function settingsPatchFromParams(params: Record<string, unknown>): Partial<Missi
|
||||
return patch;
|
||||
}
|
||||
|
||||
function paramsRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function actorFromParams(params: Record<string, unknown>) {
|
||||
return {
|
||||
actorAgentId: optionalString(params.actorAgentId),
|
||||
actorUserId: optionalString(params.actorUserId),
|
||||
actorRunId: optionalString(params.actorRunId),
|
||||
};
|
||||
}
|
||||
|
||||
function actorFromApiRequest(input: PluginApiRequestInput) {
|
||||
return {
|
||||
actorAgentId: input.actor.agentId ?? null,
|
||||
actorUserId: input.actor.userId ?? null,
|
||||
actorRunId: input.actor.runId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function maxValidationRoundsFrom(value: unknown): number | undefined {
|
||||
return optionalInteger(value);
|
||||
}
|
||||
|
||||
function requireActiveCtx() {
|
||||
if (!activeCtx) throw new Error("Missions plugin is not initialized");
|
||||
return activeCtx;
|
||||
}
|
||||
|
||||
async function runDecompose(ctx: PluginContext, params: Record<string, unknown>) {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
return decomposeMission(ctx, {
|
||||
companyId,
|
||||
issueId,
|
||||
dryRun: optionalBoolean(params.dryRun),
|
||||
actor: actorFromParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
async function runAdvance(ctx: PluginContext, params: Record<string, unknown>) {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
const settings = await readMissionSettings(ctx, companyId);
|
||||
return advanceMission(ctx, {
|
||||
companyId,
|
||||
issueId,
|
||||
maxValidationRounds: maxValidationRoundsFrom(params.maxValidationRounds) ?? settings.maxValidationRounds,
|
||||
...actorFromParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
async function runWaive(ctx: PluginContext, params: Record<string, unknown>) {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
const findingId = requireString(params.findingId ?? params.findingKey, "findingId");
|
||||
const rationale = requireString(params.rationale, "rationale");
|
||||
return waiveMissionFinding(ctx, {
|
||||
companyId,
|
||||
issueId,
|
||||
findingId,
|
||||
rationale,
|
||||
...actorFromParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
activeCtx = ctx;
|
||||
|
||||
ctx.data.register("mission-panel", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
const issueId = requireString(params.issueId, "issueId");
|
||||
@@ -111,19 +187,93 @@ const plugin = definePlugin({
|
||||
return initializeMission(ctx, companyId, issueId);
|
||||
});
|
||||
|
||||
ctx.actions.register("decompose", async (params) => runDecompose(ctx, params));
|
||||
|
||||
ctx.actions.register("advance", async (params) => runAdvance(ctx, params));
|
||||
|
||||
ctx.actions.register("waive", async (params) => runWaive(ctx, params));
|
||||
|
||||
ctx.actions.register("save-mission-settings", async (params) => {
|
||||
const companyId = requireString(params.companyId, "companyId");
|
||||
return writeMissionSettings(ctx, companyId, settingsPatchFromParams(params));
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input) {
|
||||
const ctx = requireActiveCtx();
|
||||
const body = paramsRecord(input.body);
|
||||
const issueId = input.params.issueId;
|
||||
const actor = actorFromApiRequest(input);
|
||||
|
||||
if (input.routeKey === "init") {
|
||||
return {
|
||||
status: 201,
|
||||
body: await initializeMission(ctx, input.companyId, issueId),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "summary") {
|
||||
return {
|
||||
body: await loadMissionSummary({ ctx, companyId: input.companyId, missionRootIssueId: issueId }),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "decompose") {
|
||||
return {
|
||||
body: await decomposeMission(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId,
|
||||
dryRun: optionalBoolean(body.dryRun),
|
||||
actor,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "advance") {
|
||||
const settings = await readMissionSettings(ctx, input.companyId);
|
||||
return {
|
||||
body: await advanceMission(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId,
|
||||
maxValidationRounds: maxValidationRoundsFrom(body.maxValidationRounds) ?? settings.maxValidationRounds,
|
||||
...actor,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "waive") {
|
||||
return {
|
||||
body: await waiveMissionFinding(ctx, {
|
||||
companyId: input.companyId,
|
||||
issueId,
|
||||
findingId: requireString(input.params.findingKey, "findingKey"),
|
||||
rationale: requireString(body.rationale, "rationale"),
|
||||
...actor,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.routeKey === "list") {
|
||||
const companyId = requireString(input.query.companyId, "companyId");
|
||||
return {
|
||||
body: await listMissionSummaries(ctx, companyId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: `Unknown missions route: ${input.routeKey}` },
|
||||
};
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return {
|
||||
status: "ok",
|
||||
message: "Missions plugin worker is running",
|
||||
details: {
|
||||
dataKeys: ["mission-panel", "mission-summary", "mission-list", "mission-settings", "mission-agents"],
|
||||
actionKeys: ["initialize-mission", "save-mission-settings"],
|
||||
actionKeys: ["initialize-mission", "decompose", "advance", "waive", "save-mission-settings"],
|
||||
routeKeys: ["init", "summary", "decompose", "advance", "waive", "list"],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent, Issue } from "@paperclipai/plugin-sdk";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import { pluginManifestV1Schema } from "@paperclipai/shared";
|
||||
import manifest, { MISSIONS_PAGE_ROUTE, MISSIONS_UI_EXPORTS } from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
@@ -78,13 +79,106 @@ function agent(input: Partial<Agent> & Pick<Agent, "id" | "companyId" | "name" |
|
||||
};
|
||||
}
|
||||
|
||||
function validationContractDocument() {
|
||||
return JSON.stringify({
|
||||
assertions: [
|
||||
{
|
||||
id: "VAL-MISSION-001",
|
||||
title: "Mission value is preserved",
|
||||
user_value: "The shipped workflow produces a visible mission outcome.",
|
||||
scope: "Mission workflow",
|
||||
setup: "Open the mission root issue.",
|
||||
steps: ["Run the workflow"],
|
||||
oracle: "Generated work is visible and bounded.",
|
||||
tooling: ["manual_review"],
|
||||
evidence: [{ kind: "screenshot", description: "Mission workflow evidence", required: true }],
|
||||
claimed_by: ["FEAT-MISSION-001"],
|
||||
status: "claimed",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function featuresDocument() {
|
||||
return JSON.stringify({
|
||||
milestones: [
|
||||
{
|
||||
id: "MILESTONE-MISSION-001",
|
||||
title: "Workflow wiring",
|
||||
summary: "Expose the mission workflow through plugin routes and actions.",
|
||||
depends_on: [],
|
||||
features: [
|
||||
{
|
||||
id: "FEAT-MISSION-001",
|
||||
title: "Route and action wiring",
|
||||
kind: "original",
|
||||
summary: "Wire the worker entrypoints.",
|
||||
acceptance_criteria: ["The route and action handlers dispatch successfully."],
|
||||
claimed_assertion_ids: ["VAL-MISSION-001"],
|
||||
depends_on: [],
|
||||
status: "planned",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validationReportWithBlockingFinding() {
|
||||
return JSON.stringify({
|
||||
round: 1,
|
||||
validator_role: "scrutiny_validator",
|
||||
summary: "One blocking issue remains.",
|
||||
findings: [
|
||||
{
|
||||
id: "FINDING-MISSION-001",
|
||||
severity: "blocking",
|
||||
assertion_id: "VAL-MISSION-001",
|
||||
title: "Workflow action is missing",
|
||||
evidence: ["Action endpoint returned a missing handler error."],
|
||||
repro_steps: ["Call the advance action."],
|
||||
expected: "The action dispatches to the workflow.",
|
||||
actual: "The worker reports no handler.",
|
||||
suspected_area: "Missions worker",
|
||||
recommended_fix_scope: "Register the missing action handler.",
|
||||
status: "open",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("missions plugin manifest", () => {
|
||||
it("declares the page, issue panel, global toolbar, settings, and dashboard surfaces", () => {
|
||||
expect(manifest.id).toBe("paperclip.missions");
|
||||
expect(pluginManifestV1Schema.parse(manifest)).toMatchObject({
|
||||
id: "paperclip.missions",
|
||||
database: {
|
||||
namespaceSlug: "missions",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["issues"],
|
||||
},
|
||||
apiRoutes: expect.arrayContaining([
|
||||
expect.objectContaining({ routeKey: "init", path: "/issues/:issueId/missions/init" }),
|
||||
expect.objectContaining({ routeKey: "summary", path: "/issues/:issueId/missions/summary" }),
|
||||
expect.objectContaining({ routeKey: "decompose", path: "/issues/:issueId/missions/decompose" }),
|
||||
expect.objectContaining({ routeKey: "advance", path: "/issues/:issueId/missions/advance" }),
|
||||
expect.objectContaining({ routeKey: "waive", path: "/issues/:issueId/missions/findings/:findingKey/waive" }),
|
||||
expect.objectContaining({ routeKey: "list", path: "/missions" }),
|
||||
]),
|
||||
});
|
||||
expect(manifest.capabilities).toEqual(
|
||||
expect.arrayContaining([
|
||||
"api.routes.register",
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issues.checkout",
|
||||
"issues.wakeup",
|
||||
"issue.relations.write",
|
||||
"issue.comments.create",
|
||||
"issue.documents.read",
|
||||
"issue.documents.write",
|
||||
"issue.subtree.read",
|
||||
@@ -251,4 +345,215 @@ describe("missions plugin worker", () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("dispatches the documented scoped API routes", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Scoped API mission root",
|
||||
identifier: "PAP-1700",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await expect(plugin.definition.onApiRequest?.({
|
||||
routeKey: "init",
|
||||
method: "POST",
|
||||
path: `/issues/${rootIssueId}/missions/init`,
|
||||
params: { issueId: rootIssueId },
|
||||
query: {},
|
||||
body: {},
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
})).resolves.toMatchObject({
|
||||
status: 201,
|
||||
body: expect.objectContaining({
|
||||
mode: "mission",
|
||||
missionRootIssueId: rootIssueId,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(plugin.definition.onApiRequest?.({
|
||||
routeKey: "summary",
|
||||
method: "GET",
|
||||
path: `/issues/${rootIssueId}/missions/summary`,
|
||||
params: { issueId: rootIssueId },
|
||||
query: {},
|
||||
body: null,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
})).resolves.toMatchObject({
|
||||
body: expect.objectContaining({
|
||||
missionIssueId: rootIssueId,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(plugin.definition.onApiRequest?.({
|
||||
routeKey: "list",
|
||||
method: "GET",
|
||||
path: "/missions",
|
||||
params: {},
|
||||
query: { companyId },
|
||||
body: null,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: "board",
|
||||
userId: "board",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
},
|
||||
companyId,
|
||||
headers: {},
|
||||
})).resolves.toMatchObject({
|
||||
body: [expect.objectContaining({ missionIssueId: rootIssueId })],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers decompose action and syncs generated issues idempotently", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Idempotent mission decomposition",
|
||||
identifier: "PAP-1701",
|
||||
originKind: "plugin:paperclip.missions",
|
||||
originId: "PAP-1701",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: rootIssueId,
|
||||
companyId,
|
||||
key: "validation-contract",
|
||||
title: "Validation Contract",
|
||||
format: "markdown",
|
||||
body: validationContractDocument(),
|
||||
changeSummary: "Seed validation contract",
|
||||
});
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: rootIssueId,
|
||||
companyId,
|
||||
key: "features",
|
||||
title: "Features",
|
||||
format: "markdown",
|
||||
body: featuresDocument(),
|
||||
changeSummary: "Seed features",
|
||||
});
|
||||
|
||||
const first = await harness.performAction<{ createdIssueIds: string[]; updatedIssueIds: string[] }>("decompose", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
const second = await harness.performAction<{ createdIssueIds: string[]; updatedIssueIds: string[] }>("decompose", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
|
||||
expect(first.createdIssueIds).toHaveLength(3);
|
||||
expect(first.updatedIssueIds).toHaveLength(0);
|
||||
expect(second.createdIssueIds).toHaveLength(0);
|
||||
expect(second.updatedIssueIds).toHaveLength(3);
|
||||
|
||||
const generated = await harness.ctx.issues.list({ companyId, limit: 50 });
|
||||
expect(generated.filter((item) => item.originKind?.startsWith("plugin:paperclip.missions:"))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("registers advance and waive actions without duplicating fix issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const workerAgentId = randomUUID();
|
||||
const harness = createTestHarness({ manifest });
|
||||
|
||||
harness.seed({
|
||||
issues: [
|
||||
issue({
|
||||
id: rootIssueId,
|
||||
companyId,
|
||||
title: "Mission with one blocking finding",
|
||||
identifier: "PAP-1702",
|
||||
assigneeAgentId: workerAgentId,
|
||||
originKind: "plugin:paperclip.missions",
|
||||
originId: "PAP-1702",
|
||||
billingCode: "mission:PAP-1702",
|
||||
}),
|
||||
],
|
||||
agents: [agent({ id: workerAgentId, companyId, name: "Worker One", status: "active" })],
|
||||
});
|
||||
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
for (const [key, body] of Object.entries({
|
||||
"validation-contract": validationContractDocument(),
|
||||
features: featuresDocument(),
|
||||
"validation-report-round-1": validationReportWithBlockingFinding(),
|
||||
"decision-log": "# Decision Log",
|
||||
})) {
|
||||
await harness.ctx.issues.documents.upsert({
|
||||
issueId: rootIssueId,
|
||||
companyId,
|
||||
key,
|
||||
title: key,
|
||||
format: "markdown",
|
||||
body,
|
||||
changeSummary: `Seed ${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
const first = await harness.performAction<{ createdFixIssueIds: string[] }>("advance", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
const second = await harness.performAction<{ createdFixIssueIds: string[] }>("advance", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
});
|
||||
|
||||
expect(first.createdFixIssueIds).toHaveLength(1);
|
||||
expect(second.createdFixIssueIds).toEqual(first.createdFixIssueIds);
|
||||
|
||||
const generated = await harness.ctx.issues.list({ companyId, limit: 50 });
|
||||
expect(generated.filter((item) => item.originKind === "plugin:paperclip.missions:fix")).toHaveLength(1);
|
||||
|
||||
await expect(harness.performAction("waive", {
|
||||
companyId,
|
||||
issueId: rootIssueId,
|
||||
findingId: "FINDING-MISSION-001",
|
||||
rationale: "Accepted temporarily for test coverage.",
|
||||
})).resolves.toMatchObject({
|
||||
findingId: "FINDING-MISSION-001",
|
||||
waived: true,
|
||||
});
|
||||
|
||||
const decisionLog = await harness.ctx.issues.documents.get(rootIssueId, "decision-log", companyId);
|
||||
expect(decisionLog?.body).toContain("paperclip:mission-finding-waiver:FINDING-MISSION-001");
|
||||
expect(decisionLog?.body).toContain("Accepted temporarily for test coverage.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user