diff --git a/packages/plugins/plugin-missions/migrations/001_mission_issue_links.sql b/packages/plugins/plugin-missions/migrations/001_mission_issue_links.sql index d7e2f03f21c..1429247a13d 100644 --- a/packages/plugins/plugin-missions/migrations/001_mission_issue_links.sql +++ b/packages/plugins/plugin-missions/migrations/001_mission_issue_links.sql @@ -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, diff --git a/packages/plugins/plugin-missions/migrations/002_missions.sql b/packages/plugins/plugin-missions/migrations/002_missions.sql index 6edba85b6e6..e4d8efc1c50 100644 --- a/packages/plugins/plugin-missions/migrations/002_missions.sql +++ b/packages/plugins/plugin-missions/migrations/002_missions.sql @@ -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, diff --git a/packages/plugins/plugin-missions/src/manifest.ts b/packages/plugins/plugin-missions/src/manifest.ts index aab28bced9d..dcd14553c91 100644 --- a/packages/plugins/plugin-missions/src/manifest.ts +++ b/packages/plugins/plugin-missions/src/manifest.ts @@ -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: [ { diff --git a/packages/plugins/plugin-missions/src/worker.ts b/packages/plugins/plugin-missions/src/worker.ts index c775fdb33a1..b0c72a29eba 100644 --- a/packages/plugins/plugin-missions/src/worker.ts +++ b/packages/plugins/plugin-missions/src/worker.ts @@ -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): Partial { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function actorFromParams(params: Record) { + 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) { + 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) { + 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) { + 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"], }, }; }, diff --git a/packages/plugins/plugin-missions/tests/plugin.spec.ts b/packages/plugins/plugin-missions/tests/plugin.spec.ts index 9e0643de3ae..7192dc1396f 100644 --- a/packages/plugins/plugin-missions/tests/plugin.spec.ts +++ b/packages/plugins/plugin-missions/tests/plugin.spec.ts @@ -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 & Pick { 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."); + }); });