diff --git a/packages/plugins/plugin-missions/.gitignore b/packages/plugins/plugin-missions/.gitignore new file mode 100644 index 00000000000..0430cc35608 --- /dev/null +++ b/packages/plugins/plugin-missions/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.paperclip-sdk diff --git a/packages/plugins/plugin-missions/README.md b/packages/plugins/plugin-missions/README.md new file mode 100644 index 00000000000..36d3f339e22 --- /dev/null +++ b/packages/plugins/plugin-missions/README.md @@ -0,0 +1,72 @@ +# @paperclipai/plugin-missions + +First-party Missions plugin package for Paperclip mission orchestration workflows. + +This package is the packaging and installation home for the Missions plugin in the +current alpha plugin runtime. Install is instance-wide, but the workflow it owns +is company-scoped once the worker logic is invoked against company issues. + +Today this package carries the plugin identity, local install path, verification +commands, and basic health surface. Mission advance/findings/waiver behavior +continues to land in the same package under the sibling implementation issues. + +## Operator Workflow + +Build the plugin, then install it into the local Paperclip instance by repo path: + +```bash +pnpm --filter @paperclipai/plugin-missions typecheck +pnpm --filter @paperclipai/plugin-missions test +pnpm --filter @paperclipai/plugin-missions build +pnpm paperclipai plugin install ./packages/plugins/plugin-missions +``` + +The current first-party install path is a checked-out local package path. Do not +document npm publishing for this plugin until the release/distribution path is +ready. + +API install is equivalent: + +```bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"packageName":"/absolute/path/to/paperclip/packages/plugins/plugin-missions","isLocalPath":true}' +``` + +Build first. The host loads the worker and UI from the manifest entrypoints under +`dist/`, so a fresh install without a build will fail activation. + +## Developer Workflow + +From the repo root: + +```bash +pnpm --filter @paperclipai/plugin-missions typecheck +pnpm --filter @paperclipai/plugin-missions test +pnpm --filter @paperclipai/plugin-missions build +``` + +For local iteration inside the package directory: + +```bash +pnpm install +pnpm dev +pnpm dev:ui +``` + +## Maintenance Path + +The main package files are: + +- `src/manifest.ts` for plugin identity, capabilities, and mounted UI slots +- `src/worker.ts` for worker lifecycle, state, events, and orchestration logic +- `src/ui/index.tsx` for the currently mounted UI surface +- `tests/plugin.spec.ts` for package-local verification + +When the Missions workflow changes, keep the package README and the targeted +verification commands in sync with the actual install path and runtime surface. + +## Build Options + +- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`. +- `pnpm build:rollup` uses rollup presets from the same SDK. diff --git a/packages/plugins/plugin-missions/esbuild.config.mjs b/packages/plugins/plugin-missions/esbuild.config.mjs new file mode 100644 index 00000000000..b5cfd36ed95 --- /dev/null +++ b/packages/plugins/plugin-missions/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/plugin-missions/package.json b/packages/plugins/plugin-missions/package.json new file mode 100644 index 00000000000..3c45be3a410 --- /dev/null +++ b/packages/plugins/plugin-missions/package.json @@ -0,0 +1,51 @@ +{ + "name": "@paperclipai/plugin-missions", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "First-party Missions plugin package for Paperclip mission orchestration workflows", + "scripts": { + "prebuild": "node ../../../scripts/ensure-plugin-build-deps.mjs", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "clean": "rm -rf dist", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "missions", + "orchestration" + ], + "author": "Paperclip", + "license": "MIT", + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*", + "zod": "^3.24.2" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "esbuild": "^0.27.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/plugin-missions/rollup.config.mjs b/packages/plugins/plugin-missions/rollup.config.mjs new file mode 100644 index 00000000000..ccee40a7443 --- /dev/null +++ b/packages/plugins/plugin-missions/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/plugin-missions/src/manifest.ts b/packages/plugins/plugin-missions/src/manifest.ts new file mode 100644 index 00000000000..105b27fe015 --- /dev/null +++ b/packages/plugins/plugin-missions/src/manifest.ts @@ -0,0 +1,33 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.missions", + apiVersion: 1, + version: "0.1.0", + displayName: "Missions", + description: "First-party Missions plugin package for Paperclip mission orchestration workflows.", + author: "Paperclip", + categories: ["automation", "ui"], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write", + "ui.dashboardWidget.register" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "missions-health-widget", + displayName: "Missions Health", + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; diff --git a/packages/plugins/plugin-missions/src/ui/index.tsx b/packages/plugins/plugin-missions/src/ui/index.tsx new file mode 100644 index 00000000000..b19a7cf54b1 --- /dev/null +++ b/packages/plugins/plugin-missions/src/ui/index.tsx @@ -0,0 +1,23 @@ +import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ Missions +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} diff --git a/packages/plugins/plugin-missions/src/worker.ts b/packages/plugins/plugin-missions/src/worker.ts new file mode 100644 index 00000000000..cdffc118065 --- /dev/null +++ b/packages/plugins/plugin-missions/src/worker.ts @@ -0,0 +1,27 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Missions plugin observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Missions plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/plugin-missions/tests/plugin.spec.ts b/packages/plugins/plugin-missions/tests/plugin.spec.ts new file mode 100644 index 00000000000..b1c811416e0 --- /dev/null +++ b/packages/plugins/plugin-missions/tests/plugin.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("missions plugin package", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-missions/tsconfig.json b/packages/plugins/plugin-missions/tsconfig.json new file mode 100644 index 00000000000..a697519ed9d --- /dev/null +++ b/packages/plugins/plugin-missions/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/plugin-missions/vitest.config.ts b/packages/plugins/plugin-missions/vitest.config.ts new file mode 100644 index 00000000000..649a293ef95 --- /dev/null +++ b/packages/plugins/plugin-missions/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +});