Fix missions plugin workflow endpoints

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-22 11:05:48 -05:00
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: [
{
+152 -2
View File
@@ -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.");
});
});