Merge pull request #821 from paperclipai/feature/plugin-runtime-instance-cleanup
WIP: Simplify plugin runtime and cleanup lifecycle
This commit is contained in:
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
@@ -0,0 +1,699 @@
|
||||
# Kitchen Sink Plugin Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place.
|
||||
|
||||
This plugin is meant to be:
|
||||
|
||||
- a living reference implementation for contributors
|
||||
- a manual test harness for the plugin runtime
|
||||
- a discoverable demo of what plugins can actually do today
|
||||
|
||||
It is not meant to be a polished end-user product plugin.
|
||||
|
||||
## Why
|
||||
|
||||
The current plugin system has a real API surface, but it is spread across:
|
||||
|
||||
- SDK docs
|
||||
- SDK types
|
||||
- plugin spec prose
|
||||
- two example plugins that each show only a narrow slice
|
||||
|
||||
That makes it hard to answer basic questions like:
|
||||
|
||||
- what can plugins render?
|
||||
- what can plugin workers actually do?
|
||||
- which surfaces are real versus aspirational?
|
||||
- how should a new plugin be structured in this repo?
|
||||
|
||||
The kitchen-sink plugin should answer those questions by example.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip.
|
||||
|
||||
Concretely:
|
||||
|
||||
- it installs from the bundled examples list
|
||||
- it exposes at least one demo for every implemented worker API surface
|
||||
- it exposes at least one demo for every host-mounted UI surface
|
||||
- it clearly labels local-only / trusted-only demos
|
||||
- it is safe enough for local development by default
|
||||
- it doubles as a regression harness for plugin runtime changes
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep it instance-installed, not company-installed.
|
||||
- Treat this as a trusted/local example plugin.
|
||||
- Do not rely on cloud-safe runtime assumptions.
|
||||
- Avoid destructive defaults.
|
||||
- Avoid irreversible mutations unless they are clearly labeled and easy to undo.
|
||||
|
||||
## Source Of Truth For This Plan
|
||||
|
||||
This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec.
|
||||
|
||||
Primary references:
|
||||
|
||||
- `packages/plugins/sdk/README.md`
|
||||
- `packages/plugins/sdk/src/types.ts`
|
||||
- `packages/plugins/sdk/src/ui/types.ts`
|
||||
- `packages/shared/src/constants.ts`
|
||||
- `packages/shared/src/types/plugin.ts`
|
||||
|
||||
## Current Surface Inventory
|
||||
|
||||
### Worker/runtime APIs to demonstrate
|
||||
|
||||
These are the concrete `ctx` clients currently exposed by the SDK:
|
||||
|
||||
- `ctx.config`
|
||||
- `ctx.events`
|
||||
- `ctx.jobs`
|
||||
- `ctx.launchers`
|
||||
- `ctx.http`
|
||||
- `ctx.secrets`
|
||||
- `ctx.assets`
|
||||
- `ctx.activity`
|
||||
- `ctx.state`
|
||||
- `ctx.entities`
|
||||
- `ctx.projects`
|
||||
- `ctx.companies`
|
||||
- `ctx.issues`
|
||||
- `ctx.agents`
|
||||
- `ctx.goals`
|
||||
- `ctx.data`
|
||||
- `ctx.actions`
|
||||
- `ctx.streams`
|
||||
- `ctx.tools`
|
||||
- `ctx.metrics`
|
||||
- `ctx.logger`
|
||||
|
||||
### UI surfaces to demonstrate
|
||||
|
||||
Surfaces defined in the SDK:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
### Current host confidence
|
||||
|
||||
Confirmed or strongly indicated as mounted in the current app:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab`
|
||||
- `projectSidebarItem`
|
||||
- comment surfaces
|
||||
- launcher infrastructure
|
||||
|
||||
Need explicit validation before claiming full demo coverage:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton` as direct slot, distinct from launcher placement
|
||||
- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement
|
||||
|
||||
The implementation should keep a small validation checklist for these before we call the plugin "complete".
|
||||
|
||||
## Plugin Concept
|
||||
|
||||
The plugin should be named:
|
||||
|
||||
- display name: `Kitchen Sink (Example)`
|
||||
- package: `@paperclipai/plugin-kitchen-sink-example`
|
||||
- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example`
|
||||
|
||||
Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style.
|
||||
|
||||
Category mix:
|
||||
|
||||
- `ui`
|
||||
- `automation`
|
||||
- `workspace`
|
||||
- `connector`
|
||||
|
||||
That is intentionally broad because the point is coverage.
|
||||
|
||||
## UX Shape
|
||||
|
||||
The plugin should have one main full-page demo console plus smaller satellites on other surfaces.
|
||||
|
||||
### 1. Plugin page
|
||||
|
||||
Primary route: the plugin `page` surface should be the central dashboard for all demos.
|
||||
|
||||
Recommended page sections:
|
||||
|
||||
- `Overview`
|
||||
- what this plugin demonstrates
|
||||
- current capabilities granted
|
||||
- current host context
|
||||
- `UI Surfaces`
|
||||
- links explaining where each other surface should appear
|
||||
- `Data + Actions`
|
||||
- buttons and forms for bridge-driven worker demos
|
||||
- `Events + Streams`
|
||||
- emit event
|
||||
- watch event log
|
||||
- stream demo output
|
||||
- `Paperclip Domain APIs`
|
||||
- companies
|
||||
- projects/workspaces
|
||||
- issues
|
||||
- goals
|
||||
- agents
|
||||
- `Local Workspace + Process`
|
||||
- file listing
|
||||
- file read/write scratch area
|
||||
- child process demo
|
||||
- `Jobs + Webhooks + Tools`
|
||||
- job status
|
||||
- webhook URL and recent deliveries
|
||||
- declared tools
|
||||
- `State + Entities + Assets`
|
||||
- scoped state editor
|
||||
- plugin entity inspector
|
||||
- upload/generated asset demo
|
||||
- `Observability`
|
||||
- metrics written
|
||||
- activity log samples
|
||||
- latest worker logs
|
||||
|
||||
### 2. Dashboard widget
|
||||
|
||||
A compact widget on the main dashboard should show:
|
||||
|
||||
- plugin health
|
||||
- count of demos exercised
|
||||
- recent event/stream activity
|
||||
- shortcut to the full plugin page
|
||||
|
||||
### 3. Project sidebar item
|
||||
|
||||
Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab.
|
||||
|
||||
### 4. Detail tabs
|
||||
|
||||
Use detail tabs to demonstrate entity-context rendering on:
|
||||
|
||||
- `project`
|
||||
- `issue`
|
||||
- `agent`
|
||||
- `goal`
|
||||
|
||||
Each tab should show:
|
||||
|
||||
- the host context it received
|
||||
- the relevant entity fetch via worker bridge
|
||||
- one small action scoped to that entity
|
||||
|
||||
### 5. Comment surfaces
|
||||
|
||||
Use issue comment demos to prove comment-specific extension points:
|
||||
|
||||
- `commentAnnotation`
|
||||
- render parsed metadata below each comment
|
||||
- show comment id, issue id, and a small derived status
|
||||
- `commentContextMenuItem`
|
||||
- add a menu action like `Copy Context To Kitchen Sink`
|
||||
- action writes a plugin entity or state record for later inspection
|
||||
|
||||
### 6. Settings page
|
||||
|
||||
Custom `settingsPage` should be intentionally simple and operational:
|
||||
|
||||
- `About`
|
||||
- `Danger / Trust Model`
|
||||
- demo toggles
|
||||
- local process defaults
|
||||
- workspace scratch-path behavior
|
||||
- secret reference inputs
|
||||
- event/job/webhook sample config
|
||||
|
||||
This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics.
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
Each implemented worker API should have a visible demo.
|
||||
|
||||
### `ctx.config`
|
||||
|
||||
Demo:
|
||||
|
||||
- read live config
|
||||
- show config JSON
|
||||
- react to config changes without restart where possible
|
||||
|
||||
### `ctx.events`
|
||||
|
||||
Demos:
|
||||
|
||||
- emit a plugin event
|
||||
- subscribe to plugin events
|
||||
- subscribe to a core Paperclip event such as `issue.created`
|
||||
- show recent received events in a timeline
|
||||
|
||||
### `ctx.jobs`
|
||||
|
||||
Demos:
|
||||
|
||||
- one scheduled heartbeat-style demo job
|
||||
- one manual run button from the UI if host supports manual job trigger
|
||||
- show last run result and timestamps
|
||||
|
||||
### `ctx.launchers`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare launchers in manifest
|
||||
- optionally register one runtime launcher from the worker
|
||||
- show launcher metadata on the plugin page
|
||||
|
||||
### `ctx.http`
|
||||
|
||||
Demo:
|
||||
|
||||
- make a simple outbound GET request to a safe endpoint
|
||||
- show status code, latency, and JSON result
|
||||
|
||||
Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs.
|
||||
|
||||
### `ctx.secrets`
|
||||
|
||||
Demo:
|
||||
|
||||
- operator enters a secret reference in config
|
||||
- plugin resolves it on demand
|
||||
- UI only shows masked result length / success status, never raw secret
|
||||
|
||||
### `ctx.assets`
|
||||
|
||||
Demos:
|
||||
|
||||
- generate a text asset from the UI
|
||||
- optionally upload a tiny JSON blob or screenshot-like text file
|
||||
- show returned asset URL
|
||||
|
||||
### `ctx.activity`
|
||||
|
||||
Demo:
|
||||
|
||||
- button to write a plugin activity log entry against current company/entity
|
||||
|
||||
### `ctx.state`
|
||||
|
||||
Demos:
|
||||
|
||||
- instance-scoped state
|
||||
- company-scoped state
|
||||
- project-scoped state
|
||||
- issue-scoped state
|
||||
- delete/reset controls
|
||||
|
||||
Use a small state inspector/editor on the plugin page.
|
||||
|
||||
### `ctx.entities`
|
||||
|
||||
Demos:
|
||||
|
||||
- create plugin-owned sample records
|
||||
- list/filter them
|
||||
- show one realistic use case such as "copied comments" or "demo sync records"
|
||||
|
||||
### `ctx.projects`
|
||||
|
||||
Demos:
|
||||
|
||||
- list projects
|
||||
- list project workspaces
|
||||
- resolve primary workspace
|
||||
- resolve workspace for issue
|
||||
|
||||
### `ctx.companies`
|
||||
|
||||
Demo:
|
||||
|
||||
- list companies and show current selected company
|
||||
|
||||
### `ctx.issues`
|
||||
|
||||
Demos:
|
||||
|
||||
- list issues in current company
|
||||
- create issue
|
||||
- update issue status/title
|
||||
- list comments
|
||||
- create comment
|
||||
|
||||
### `ctx.agents`
|
||||
|
||||
Demos:
|
||||
|
||||
- list agents
|
||||
- invoke one agent with a test prompt
|
||||
- pause/resume where safe
|
||||
|
||||
Agent mutation controls should be behind an explicit warning.
|
||||
|
||||
### `ctx.agents.sessions`
|
||||
|
||||
Demos:
|
||||
|
||||
- create agent chat session
|
||||
- send message
|
||||
- stream events back to the UI
|
||||
- close session
|
||||
|
||||
This is a strong candidate for the best "wow" demo on the plugin page.
|
||||
|
||||
### `ctx.goals`
|
||||
|
||||
Demos:
|
||||
|
||||
- list goals
|
||||
- create goal
|
||||
- update status/title
|
||||
|
||||
### `ctx.data`
|
||||
|
||||
Use throughout the plugin for all read-side bridge demos.
|
||||
|
||||
### `ctx.actions`
|
||||
|
||||
Use throughout the plugin for all mutation-side bridge demos.
|
||||
|
||||
### `ctx.streams`
|
||||
|
||||
Demos:
|
||||
|
||||
- live event log stream
|
||||
- token-style stream from an agent session relay
|
||||
- fake progress stream for a long-running action
|
||||
|
||||
### `ctx.tools`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare 2-3 simple agent tools
|
||||
- tool 1: echo/diagnostics
|
||||
- tool 2: project/workspace summary
|
||||
- tool 3: create issue or write plugin state
|
||||
|
||||
The plugin page should list declared tools and show example input payloads.
|
||||
|
||||
### `ctx.metrics`
|
||||
|
||||
Demo:
|
||||
|
||||
- write a sample metric on each major demo action
|
||||
- surface a small recent metrics table in the plugin page
|
||||
|
||||
### `ctx.logger`
|
||||
|
||||
Demo:
|
||||
|
||||
- every action logs structured entries
|
||||
- plugin settings `Status` page then doubles as the log viewer
|
||||
|
||||
## Local Workspace And Process Demos
|
||||
|
||||
The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata.
|
||||
|
||||
The kitchen-sink plugin should demonstrate that explicitly.
|
||||
|
||||
### Workspace demos
|
||||
|
||||
- list files from a selected workspace
|
||||
- read a file
|
||||
- write to a plugin-owned scratch file
|
||||
- optionally search files with `rg` if available
|
||||
|
||||
### Process demos
|
||||
|
||||
- run a short-lived command like `pwd`, `ls`, or `git status`
|
||||
- stream stdout/stderr back to UI
|
||||
- show exit code and timing
|
||||
|
||||
Important safeguards:
|
||||
|
||||
- default commands must be read-only
|
||||
- no shell interpolation from arbitrary free-form input in v1
|
||||
- provide a curated command list or a strongly validated command form
|
||||
- clearly label this area as local-only and trusted-only
|
||||
|
||||
## Proposed Manifest Coverage
|
||||
|
||||
The plugin should aim to declare:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab` for `project`, `issue`, `agent`, `goal`
|
||||
- `projectSidebarItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
Then, after host validation, add if supported:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
|
||||
It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering.
|
||||
|
||||
## Proposed Package Layout
|
||||
|
||||
New package:
|
||||
|
||||
- `packages/plugins/examples/plugin-kitchen-sink-example/`
|
||||
|
||||
Expected files:
|
||||
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
- `tsconfig.json`
|
||||
- `src/index.ts`
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `src/ui/components/...`
|
||||
- `src/ui/hooks/...`
|
||||
- `src/lib/...`
|
||||
- optional `scripts/build-ui.mjs` if UI bundling needs esbuild
|
||||
|
||||
## Proposed Internal Architecture
|
||||
|
||||
### Worker modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/worker.ts`
|
||||
- plugin definition and wiring
|
||||
- `src/worker/data.ts`
|
||||
- `ctx.data.register(...)`
|
||||
- `src/worker/actions.ts`
|
||||
- `ctx.actions.register(...)`
|
||||
- `src/worker/events.ts`
|
||||
- event subscriptions and event log buffer
|
||||
- `src/worker/jobs.ts`
|
||||
- scheduled job handlers
|
||||
- `src/worker/tools.ts`
|
||||
- tool declarations and handlers
|
||||
- `src/worker/local-runtime.ts`
|
||||
- file/process demos
|
||||
- `src/worker/demo-store.ts`
|
||||
- helpers for state/entities/assets/metrics
|
||||
|
||||
### UI modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/ui/index.tsx`
|
||||
- exported slot components
|
||||
- `src/ui/page/KitchenSinkPage.tsx`
|
||||
- `src/ui/settings/KitchenSinkSettingsPage.tsx`
|
||||
- `src/ui/widgets/KitchenSinkDashboardWidget.tsx`
|
||||
- `src/ui/tabs/ProjectKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/IssueKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/AgentKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/GoalKitchenSinkTab.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentAnnotation.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentMenuItem.tsx`
|
||||
- `src/ui/shared/...`
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
The plugin should have a substantial but understandable `instanceConfigSchema`.
|
||||
|
||||
Recommended config fields:
|
||||
|
||||
- `enableDangerousDemos`
|
||||
- `enableWorkspaceDemos`
|
||||
- `enableProcessDemos`
|
||||
- `showSidebarEntry`
|
||||
- `showSidebarPanel`
|
||||
- `showProjectSidebarItem`
|
||||
- `showCommentAnnotation`
|
||||
- `showCommentContextMenuItem`
|
||||
- `showToolbarLauncher`
|
||||
- `defaultDemoCompanyId` optional
|
||||
- `secretRefExample`
|
||||
- `httpDemoUrl`
|
||||
- `processAllowedCommands`
|
||||
- `workspaceScratchSubdir`
|
||||
|
||||
Defaults should keep risky behavior off.
|
||||
|
||||
## Safety Defaults
|
||||
|
||||
Default posture:
|
||||
|
||||
- UI and read-only demos on
|
||||
- mutating domain demos on but explicitly labeled
|
||||
- process demos off by default
|
||||
- no arbitrary shell input by default
|
||||
- no raw secret rendering ever
|
||||
|
||||
## Phased Build Plan
|
||||
|
||||
### Phase 1: Core plugin skeleton
|
||||
|
||||
- scaffold package
|
||||
- add manifest, worker, UI entrypoints
|
||||
- add README
|
||||
- make it appear in bundled examples list
|
||||
|
||||
### Phase 2: Core, confirmed UI surfaces
|
||||
|
||||
- plugin page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- project sidebar item
|
||||
- detail tabs
|
||||
|
||||
### Phase 3: Core worker APIs
|
||||
|
||||
- config
|
||||
- state
|
||||
- entities
|
||||
- companies/projects/issues/goals
|
||||
- data/actions
|
||||
- metrics/logger/activity
|
||||
|
||||
### Phase 4: Real-time and automation APIs
|
||||
|
||||
- streams
|
||||
- events
|
||||
- jobs
|
||||
- webhooks
|
||||
- agent sessions
|
||||
- tools
|
||||
|
||||
### Phase 5: Local trusted runtime demos
|
||||
|
||||
- workspace file demos
|
||||
- child process demos
|
||||
- guarded by config
|
||||
|
||||
### Phase 6: Secondary UI surfaces
|
||||
|
||||
- comment annotation
|
||||
- comment context menu item
|
||||
- launchers
|
||||
|
||||
### Phase 7: Validation-only surfaces
|
||||
|
||||
Validate whether the current host truly mounts:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- direct-slot `toolbarButton`
|
||||
- direct-slot `contextMenuItem`
|
||||
|
||||
If mounted, add demos.
|
||||
If not mounted, document them as SDK-defined but host-pending.
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
The plugin should ship with a README that includes:
|
||||
|
||||
- what it demonstrates
|
||||
- which surfaces are local-only
|
||||
- how to install it
|
||||
- where each UI surface should appear
|
||||
- a mapping from demo card to SDK API
|
||||
|
||||
It should also be referenced from plugin docs as the "reference everything plugin".
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Minimum verification:
|
||||
|
||||
- package typecheck/build
|
||||
- install from bundled example list
|
||||
- page loads
|
||||
- widget appears
|
||||
- project tab appears
|
||||
- comment surfaces render
|
||||
- settings page loads
|
||||
- key actions succeed
|
||||
|
||||
Recommended manual checklist:
|
||||
|
||||
- create issue from plugin
|
||||
- create goal from plugin
|
||||
- emit and receive plugin event
|
||||
- stream action output
|
||||
- open agent session and receive streamed reply
|
||||
- upload an asset
|
||||
- write plugin activity log
|
||||
- run a safe local process demo
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should the process demo remain curated-command-only in the first pass?
|
||||
Recommendation: yes.
|
||||
|
||||
2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically?
|
||||
Recommendation: no. Make creation explicit.
|
||||
|
||||
3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired?
|
||||
Recommendation: yes, but label them as `SDK-defined / host validation pending`.
|
||||
|
||||
4. Should agent mutation demos include pause/resume by default?
|
||||
Recommendation: probably yes, but behind a warning block.
|
||||
|
||||
5. Should this plugin be treated as a supported regression harness in CI later?
|
||||
Recommendation: yes. Long term, this should be the plugin-runtime smoke test package.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
If this plan looks right, the next implementation pass should start by building only:
|
||||
|
||||
- package skeleton
|
||||
- page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- one project detail tab
|
||||
- one issue detail tab
|
||||
- the basic worker/action/data/state/event scaffolding
|
||||
|
||||
That is enough to lock the architecture before filling in every demo surface.
|
||||
154
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
154
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `tests/plugin.spec.ts`
|
||||
- `esbuild.config.mjs`
|
||||
- `rollup.config.mjs`
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Recommended local workflow
|
||||
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
|
||||
- config
|
||||
- events
|
||||
- jobs
|
||||
- launchers
|
||||
- http
|
||||
- secrets
|
||||
- activity
|
||||
- state
|
||||
- entities
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues and comments
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
- tools
|
||||
- metrics
|
||||
- logger
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
- `usePluginAction`
|
||||
- `usePluginStream`
|
||||
- `usePluginToast`
|
||||
- `useHostContext`
|
||||
- typed slot props from `@paperclipai/plugin-sdk/ui`
|
||||
|
||||
Mount surfaces currently wired in the host include:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
```text
|
||||
/:companyPrefix/<routePath>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `routePath` must be a single lowercase slug
|
||||
- it cannot collide with reserved host routes
|
||||
- it cannot duplicate another installed plugin page route
|
||||
|
||||
## Publishing guidance
|
||||
|
||||
- Use npm packages as the deployment artifact.
|
||||
- Treat repo-local example installs as a development workflow only.
|
||||
- Prefer keeping plugin UI self-contained inside the package.
|
||||
- Do not rely on host design-system components or undocumented app internals.
|
||||
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
|
||||
|
||||
## Verification before handoff
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
pnpm --filter <your-plugin-package> typecheck
|
||||
pnpm --filter <your-plugin-package> test
|
||||
pnpm --filter <your-plugin-package> build
|
||||
```
|
||||
|
||||
If you changed host integration too, also run:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
@@ -8,6 +8,29 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea
|
||||
This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md).
|
||||
It is the full target architecture for the plugin system that should follow V1.
|
||||
|
||||
## Current implementation caveats
|
||||
|
||||
The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
|
||||
|
||||
Today, the practical deployment model is:
|
||||
|
||||
- single-tenant
|
||||
- self-hosted
|
||||
- single-node or otherwise filesystem-persistent
|
||||
|
||||
Current limitations to keep in mind:
|
||||
|
||||
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
|
||||
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
|
||||
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
|
||||
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This spec covers:
|
||||
@@ -212,6 +235,8 @@ Suggested layout:
|
||||
|
||||
The package install directory and the plugin data directory are separate.
|
||||
|
||||
This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
|
||||
|
||||
## 8.2 Operator Commands
|
||||
|
||||
Paperclip should add CLI commands:
|
||||
@@ -237,6 +262,8 @@ The install process is:
|
||||
7. Start plugin worker and run health/validation.
|
||||
8. Mark plugin `ready` or `error`.
|
||||
|
||||
For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
|
||||
|
||||
## 9. Load Order And Precedence
|
||||
|
||||
Load order must be deterministic.
|
||||
|
||||
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- Rollback:
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
|
||||
-- DROP TABLE IF EXISTS "plugin_logs";
|
||||
-- DROP TABLE IF EXISTS "plugin_company_settings";
|
||||
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
|
||||
-- DROP TABLE IF EXISTS "plugin_job_runs";
|
||||
-- DROP TABLE IF EXISTS "plugin_jobs";
|
||||
-- DROP TABLE IF EXISTS "plugin_entities";
|
||||
-- DROP TABLE IF EXISTS "plugin_state";
|
||||
-- DROP TABLE IF EXISTS "plugin_config";
|
||||
-- DROP TABLE IF EXISTS "plugins";
|
||||
|
||||
CREATE TABLE "plugins" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_key" text NOT NULL,
|
||||
"package_name" text NOT NULL,
|
||||
"package_path" text,
|
||||
"version" text NOT NULL,
|
||||
"api_version" integer DEFAULT 1 NOT NULL,
|
||||
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"manifest_json" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'installed' NOT NULL,
|
||||
"install_order" integer,
|
||||
"last_error" text,
|
||||
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_state" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"namespace" text DEFAULT 'default' NOT NULL,
|
||||
"state_key" text NOT NULL,
|
||||
"value_json" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_entities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"external_id" text,
|
||||
"title" text,
|
||||
"status" text,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"job_key" text NOT NULL,
|
||||
"schedule" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"last_run_at" timestamp with time zone,
|
||||
"next_run_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_job_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"trigger" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_webhook_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"webhook_key" text NOT NULL,
|
||||
"external_id" text,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"payload" jsonb NOT NULL,
|
||||
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_company_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"level" text NOT NULL DEFAULT 'info',
|
||||
"message" text NOT NULL,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
|
||||
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,13 @@
|
||||
"when": 1773432085646,
|
||||
"tag": "0028_harsh_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1773417600000,
|
||||
"tag": "0029_plugin_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,11 @@ export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
export { pluginState } from "./plugin_state.js";
|
||||
export { pluginEntities } from "./plugin_entities.js";
|
||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
|
||||
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||
* scoped to a specific company.
|
||||
*
|
||||
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||
* configuration. Each company can have at most one settings row per plugin.
|
||||
*
|
||||
* Rows represent explicit overrides from the default company behavior:
|
||||
* - no row => plugin is enabled for the company by default
|
||||
* - row with `enabled = false` => plugin is disabled for that company
|
||||
* - row with `enabled = true` => plugin remains enabled and stores company settings
|
||||
*/
|
||||
export const pluginCompanySettings = pgTable(
|
||||
"plugin_company_settings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
|
||||
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
|
||||
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
30
packages/db/src/schema/plugin_config.ts
Normal file
30
packages/db/src/schema/plugin_config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_config` table — stores operator-provided instance configuration
|
||||
* for each plugin (one row per plugin, enforced by a unique index on
|
||||
* `plugin_id`).
|
||||
*
|
||||
* The `config_json` column holds the values that the operator enters in the
|
||||
* plugin settings UI. These values are validated at runtime against the
|
||||
* plugin's `instanceConfigSchema` from the manifest.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginConfig = pgTable(
|
||||
"plugin_config",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||
}),
|
||||
);
|
||||
54
packages/db/src/schema/plugin_entities.ts
Normal file
54
packages/db/src/schema/plugin_entities.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_entities` table — persistent high-level mapping between Paperclip
|
||||
* objects and external plugin-defined entities.
|
||||
*
|
||||
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
|
||||
* to their respective external IDs for projects, issues, etc. and to store
|
||||
* their custom data.
|
||||
*
|
||||
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
|
||||
* is intended for structured object mappings that the host can understand
|
||||
* and query for cross-plugin UI integration.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginEntities = pgTable(
|
||||
"plugin_entities",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||
externalId: text("external_id"), // ID in the external system
|
||||
title: text("title"),
|
||||
status: text("status"),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
102
packages/db/src/schema/plugin_jobs.ts
Normal file
102
packages/db/src/schema/plugin_jobs.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_jobs` table — registration and runtime configuration for
|
||||
* scheduled jobs declared by plugins in their manifests.
|
||||
*
|
||||
* Each row represents one scheduled job entry for a plugin. The
|
||||
* `job_key` matches the key declared in the manifest's `jobs` array.
|
||||
* The `schedule` column stores the cron expression or interval string
|
||||
* used by the job scheduler to decide when to fire the job.
|
||||
*
|
||||
* Status values:
|
||||
* - `active` — job is enabled and will run on schedule
|
||||
* - `paused` — job is temporarily disabled by the operator
|
||||
* - `error` — job has been disabled due to repeated failures
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
|
||||
*/
|
||||
export const pluginJobs = pgTable(
|
||||
"plugin_jobs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `jobs` array. */
|
||||
jobKey: text("job_key").notNull(),
|
||||
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
|
||||
schedule: text("schedule").notNull(),
|
||||
/** Current scheduling state. */
|
||||
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
|
||||
/** Timestamp of the most recent successful execution. */
|
||||
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
|
||||
/** Pre-computed timestamp of the next scheduled execution. */
|
||||
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
|
||||
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
|
||||
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
|
||||
*
|
||||
* Each row is created when a job run begins and updated when it completes.
|
||||
* Rows are never modified after `status` reaches a terminal value
|
||||
* (`succeeded` | `failed` | `cancelled`).
|
||||
*
|
||||
* Trigger values:
|
||||
* - `scheduled` — fired automatically by the cron/interval scheduler
|
||||
* - `manual` — triggered by an operator via the admin UI or API
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
|
||||
*/
|
||||
export const pluginJobRuns = pgTable(
|
||||
"plugin_job_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the parent job definition. Cascades on delete. */
|
||||
jobId: uuid("job_id")
|
||||
.notNull()
|
||||
.references(() => pluginJobs.id, { onDelete: "cascade" }),
|
||||
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Current lifecycle state of this run. */
|
||||
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Ordered list of log lines emitted during this run. */
|
||||
logs: jsonb("logs").$type<string[]>().notNull().default([]),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/plugin_logs.ts
Normal file
43
packages/db/src/schema/plugin_logs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_logs` table — structured log storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single log entry emitted by a plugin worker via
|
||||
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
|
||||
* time range to support the operator logs panel and debugging workflows.
|
||||
*
|
||||
* Rows are inserted by the host when handling `log` notifications from
|
||||
* the worker process. A capped retention policy can be applied via
|
||||
* periodic cleanup (e.g. delete rows older than 7 days).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §26 — Observability
|
||||
*/
|
||||
export const pluginLogs = pgTable(
|
||||
"plugin_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
level: text("level").notNull().default("info"),
|
||||
message: text("message").notNull(),
|
||||
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
|
||||
table.pluginId,
|
||||
table.createdAt,
|
||||
),
|
||||
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||
}),
|
||||
);
|
||||
90
packages/db/src/schema/plugin_state.ts
Normal file
90
packages/db/src/schema/plugin_state.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_state` table — scoped key-value storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single JSON value identified by
|
||||
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
|
||||
* this table through `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` in the SDK.
|
||||
*
|
||||
* Scope kinds determine the granularity of isolation:
|
||||
* - `instance` — one value shared across the whole Paperclip instance
|
||||
* - `company` — one value per company
|
||||
* - `project` — one value per project
|
||||
* - `project_workspace` — one value per project workspace
|
||||
* - `agent` — one value per agent
|
||||
* - `issue` — one value per issue
|
||||
* - `goal` — one value per goal
|
||||
* - `run` — one value per agent run
|
||||
*
|
||||
* The `namespace` column defaults to `"default"` and can be used to
|
||||
* logically group keys without polluting the root namespace.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export const pluginState = pgTable(
|
||||
"plugin_state",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* Null for `instance` scope (which has no associated entity).
|
||||
*/
|
||||
scopeId: text("scope_id"),
|
||||
/**
|
||||
* Sub-namespace to avoid key collisions within a scope.
|
||||
* Defaults to `"default"` if the plugin does not specify one.
|
||||
*/
|
||||
namespace: text("namespace").notNull().default("default"),
|
||||
/** The key identifying this state entry within the namespace. */
|
||||
stateKey: text("state_key").notNull(),
|
||||
/** JSON-serializable value stored by the plugin. */
|
||||
valueJson: jsonb("value_json").notNull(),
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
/**
|
||||
* Unique constraint enforces that there is at most one value per
|
||||
* (plugin, scope kind, scope id, namespace, key) tuple.
|
||||
*
|
||||
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
|
||||
* (used by `instance` scope) are treated as equal by PostgreSQL rather
|
||||
* than as distinct nulls — otherwise the upsert target in `set()` would
|
||||
* fail to match existing rows and create duplicates.
|
||||
*
|
||||
* Requires PostgreSQL 15+.
|
||||
*/
|
||||
uniqueEntry: unique("plugin_state_unique_entry_idx")
|
||||
.on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
table.scopeId,
|
||||
table.namespace,
|
||||
table.stateKey,
|
||||
)
|
||||
.nullsNotDistinct(),
|
||||
/** Speed up lookups by plugin + scope kind (most common access pattern). */
|
||||
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
),
|
||||
}),
|
||||
);
|
||||
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
|
||||
*
|
||||
* When an external system sends an HTTP POST to a plugin's registered webhook
|
||||
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
|
||||
* creates a row in this table before dispatching the payload to the plugin
|
||||
* worker. This provides an auditable log of every delivery attempt.
|
||||
*
|
||||
* The `webhook_key` matches the key declared in the plugin manifest's
|
||||
* `webhooks` array. `external_id` is an optional identifier supplied by the
|
||||
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
|
||||
* and reject duplicate deliveries.
|
||||
*
|
||||
* Status values:
|
||||
* - `pending` — received but not yet dispatched to the worker
|
||||
* - `processing` — currently being handled by the plugin worker
|
||||
* - `succeeded` — worker processed the payload successfully
|
||||
* - `failed` — worker returned an error or timed out
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
|
||||
*/
|
||||
export const pluginWebhookDeliveries = pgTable(
|
||||
"plugin_webhook_deliveries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||
webhookKey: text("webhook_key").notNull(),
|
||||
/** Optional de-duplication ID provided by the external system. */
|
||||
externalId: text("external_id"),
|
||||
/** Current delivery state. */
|
||||
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
|
||||
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Raw JSON body of the inbound HTTP request. */
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||
}),
|
||||
);
|
||||
45
packages/db/src/schema/plugins.ts
Normal file
45
packages/db/src/schema/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugins` table — stores one row per installed plugin.
|
||||
*
|
||||
* Each plugin is uniquely identified by `plugin_key` (derived from
|
||||
* the manifest `id`). The full manifest is persisted as JSONB in
|
||||
* `manifest_json` so the host can reconstruct capability and UI
|
||||
* slot information without loading the plugin package.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const plugins = pgTable(
|
||||
"plugins",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
packageName: text("package_name").notNull(),
|
||||
version: text("version").notNull(),
|
||||
apiVersion: integer("api_version").notNull().default(1),
|
||||
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
|
||||
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
|
||||
status: text("status").$type<PluginStatus>().notNull().default("installed"),
|
||||
installOrder: integer("install_order"),
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: text("package_path"),
|
||||
lastError: text("last_error"),
|
||||
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
|
||||
statusIdx: index("plugins_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @paperclipai/create-paperclip-plugin
|
||||
|
||||
Scaffolding tool for creating new Paperclip plugins.
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin my-plugin
|
||||
```
|
||||
|
||||
Or with options:
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin @acme/my-plugin \
|
||||
--template connector \
|
||||
--category connector \
|
||||
--display-name "Acme Connector" \
|
||||
--description "Syncs Acme data into Paperclip" \
|
||||
--author "Acme Inc"
|
||||
```
|
||||
|
||||
Supported templates: `default`, `connector`, `workspace`
|
||||
Supported categories: `connector`, `workspace`, `automation`, `ui`
|
||||
|
||||
Generates:
|
||||
- typed manifest + worker entrypoint
|
||||
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
|
||||
- test file using `@paperclipai/plugin-sdk/testing`
|
||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||
|
||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
||||
|
||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||
|
||||
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
|
||||
|
||||
```bash
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
|
||||
--output /absolute/path/to/plugins \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That gives you an outside-repo local development path before the SDK is published to npm.
|
||||
|
||||
## Workflow after scaffolding
|
||||
|
||||
```bash
|
||||
cd my-plugin
|
||||
pnpm install
|
||||
pnpm dev # watch worker + manifest + ui bundles
|
||||
pnpm dev:ui # local UI preview server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@paperclipai/create-paperclip-plugin",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
|
||||
|
||||
export interface ScaffoldPluginOptions {
|
||||
pluginName: string;
|
||||
outputDir: string;
|
||||
template?: PluginTemplate;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: "connector" | "workspace" | "automation" | "ui";
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
/** Validate npm-style plugin package names (scoped or unscoped). */
|
||||
export function isValidPluginName(name: string): boolean {
|
||||
const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
|
||||
const unscopedPattern = /^[a-z0-9._-]+$/;
|
||||
return scopedPattern.test(name) || unscopedPattern.test(name);
|
||||
}
|
||||
|
||||
/** Convert `@scope/name` to an output directory basename (`name`). */
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
/** Convert an npm package name into a manifest-safe plugin id. */
|
||||
function packageToManifestId(pluginName: string): string {
|
||||
if (!pluginName.startsWith("@")) {
|
||||
return pluginName;
|
||||
}
|
||||
|
||||
return pluginName.slice(1).replace("/", ".");
|
||||
}
|
||||
|
||||
/** Build a human-readable display name from package name tokens. */
|
||||
function makeDisplayName(pluginName: string): string {
|
||||
const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function writeFile(target: string, content: string) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function formatFileDependency(absPath: string): string {
|
||||
return `file:${toPosixPath(path.resolve(absPath))}`;
|
||||
}
|
||||
|
||||
function getLocalSdkPackagePath(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
|
||||
}
|
||||
|
||||
function getRepoRootFromSdkPath(sdkPath: string): string {
|
||||
return path.resolve(sdkPath, "..", "..", "..");
|
||||
}
|
||||
|
||||
function getLocalSharedPackagePath(sdkPath: string): string {
|
||||
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
|
||||
}
|
||||
|
||||
function isInsideDir(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function packLocalPackage(packagePath: string, outputDir: string): string {
|
||||
const packageJsonPath = path.join(packagePath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(`Package package.json not found at ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
const packageName = packageJson.name ?? path.basename(packagePath);
|
||||
const packageVersion = packageJson.version ?? "0.0.0";
|
||||
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
|
||||
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
|
||||
|
||||
fs.mkdirSync(sdkBundleDir, { recursive: true });
|
||||
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
|
||||
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
|
||||
|
||||
const tarballPath = path.join(sdkBundleDir, tarballFileName);
|
||||
if (!fs.existsSync(tarballPath)) {
|
||||
throw new Error(`Packed tarball was not created at ${tarballPath}`);
|
||||
}
|
||||
|
||||
return tarballPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete Paperclip plugin starter project.
|
||||
*
|
||||
* Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
|
||||
* and a local dev server script for hot-reload workflow.
|
||||
*/
|
||||
export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
const template = options.template ?? "default";
|
||||
if (!VALID_TEMPLATES.includes(template)) {
|
||||
throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!isValidPluginName(options.pluginName)) {
|
||||
throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
|
||||
}
|
||||
|
||||
if (options.category && !VALID_CATEGORIES.has(options.category)) {
|
||||
throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(options.outputDir);
|
||||
if (fs.existsSync(outputDir)) {
|
||||
throw new Error(`Directory already exists: ${outputDir}`);
|
||||
}
|
||||
|
||||
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
|
||||
const description = options.description ?? "A Paperclip plugin";
|
||||
const author = options.author ?? "Plugin Author";
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||
const manifestId = packageToManifestId(options.pluginName);
|
||||
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
||||
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
||||
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
|
||||
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
|
||||
const sdkDependency = useWorkspaceSdk
|
||||
? "workspace:*"
|
||||
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
|
||||
|
||||
const packageJson = {
|
||||
name: options.pluginName,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
private: true,
|
||||
description,
|
||||
scripts: {
|
||||
build: "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
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: "tsc --noEmit"
|
||||
},
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui/"
|
||||
},
|
||||
keywords: ["paperclip", "plugin", category],
|
||||
author,
|
||||
license: "MIT",
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
pnpm: {
|
||||
overrides: {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
devDependencies: {
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
}
|
||||
: {}),
|
||||
"@paperclipai/plugin-sdk": sdkDependency,
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
esbuild: "^0.27.3",
|
||||
rollup: "^4.38.0",
|
||||
tslib: "^2.8.1",
|
||||
typescript: "^5.7.3",
|
||||
vitest: "^3.0.5"
|
||||
},
|
||||
peerDependencies: {
|
||||
react: ">=18"
|
||||
}
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const tsconfig = {
|
||||
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"]
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "esbuild.config.mjs"),
|
||||
`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()]);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "rollup.config.mjs"),
|
||||
`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);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "vitest.config.ts"),
|
||||
`import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: ${quote(manifestId)},
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: ${quote(displayName)},
|
||||
description: ${quote(description)},
|
||||
author: ${quote(author)},
|
||||
categories: [${quote(category)}],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: ${quote(`${displayName} Health`)},
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`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("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: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`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<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>${displayName}</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`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("plugin scaffold", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "README.md"),
|
||||
`# ${displayName}
|
||||
|
||||
${description}
|
||||
|
||||
## Development
|
||||
|
||||
\`\`\`bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
\`\`\`
|
||||
|
||||
${sdkDependency.startsWith("file:")
|
||||
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
||||
: ""}
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
|
||||
\`\`\`
|
||||
|
||||
## Build Options
|
||||
|
||||
- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`.
|
||||
- \`pnpm build:rollup\` uses rollup presets from the same SDK.
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
function parseArg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/** CLI wrapper for `scaffoldPluginProject`. */
|
||||
function runCli() {
|
||||
const pluginName = process.argv[2];
|
||||
if (!pluginName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const template = (parseArg("--template") ?? "default") as PluginTemplate;
|
||||
const outputRoot = parseArg("--output") ?? process.cwd();
|
||||
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
|
||||
|
||||
const out = scaffoldPluginProject({
|
||||
pluginName,
|
||||
outputDir: targetDir,
|
||||
template,
|
||||
displayName: parseArg("--display-name"),
|
||||
description: parseArg("--description"),
|
||||
author: parseArg("--author"),
|
||||
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
||||
sdkPath: parseArg("--sdk-path"),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Created plugin scaffold at ${out}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runCli();
|
||||
}
|
||||
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plugin Authoring Smoke Example
|
||||
|
||||
A Paperclip plugin
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-authoring-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "A Paperclip plugin",
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"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",
|
||||
"connector"
|
||||
],
|
||||
"author": "Plugin Author",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-authoring-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Authoring Smoke Example",
|
||||
description: "A Paperclip plugin",
|
||||
author: "Plugin Author",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Plugin Authoring Smoke Example Health",
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -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<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>Plugin Authoring Smoke Example</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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("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: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -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("plugin scaffold", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# File Browser Example Plugin
|
||||
|
||||
Example Paperclip plugin that demonstrates:
|
||||
|
||||
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
|
||||
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
|
||||
|
||||
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Type | Description |
|
||||
|---------------------|---------------------|--------------------------------------------------|
|
||||
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
|
||||
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
|
||||
|
||||
## Settings
|
||||
|
||||
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
|
||||
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `ui.sidebar.register` — project sidebar item
|
||||
- `ui.detailTab.register` — project detail tab
|
||||
- `projects.read` — resolve project
|
||||
- `project.workspaces.read` — list workspaces and read paths for file access
|
||||
|
||||
## Worker
|
||||
|
||||
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
|
||||
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
|
||||
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
|
||||
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
|
||||
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
|
||||
- `src/worker.ts` — data handlers for workspaces, file list, file content.
|
||||
- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-file-browser-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.28.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"codemirror": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip-file-browser-example";
|
||||
const FILES_SIDEBAR_SLOT_ID = "files-link";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
|
||||
const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "File Browser (Example)",
|
||||
description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.sidebar.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issue.comments.read",
|
||||
"plugin.state.read",
|
||||
],
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showFilesInSidebar: {
|
||||
type: "boolean",
|
||||
title: "Show Files in Sidebar",
|
||||
default: false,
|
||||
description: "Adds the Files link under each project in the sidebar.",
|
||||
},
|
||||
commentAnnotationMode: {
|
||||
type: "string",
|
||||
title: "Comment File Links",
|
||||
enum: ["annotation", "contextMenu", "both", "none"],
|
||||
default: "both",
|
||||
description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
|
||||
},
|
||||
},
|
||||
},
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: FILES_SIDEBAR_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesLink",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: FILES_TAB_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesTab",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: COMMENT_FILE_LINKS_SLOT_ID,
|
||||
displayName: "File Links",
|
||||
exportName: "CommentFileLinks",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: COMMENT_OPEN_FILES_SLOT_ID,
|
||||
displayName: "Open in Files",
|
||||
exportName: "CommentOpenFiles",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,815 @@
|
||||
import type {
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginDetailTabProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const PLUGIN_KEY = "paperclip-file-browser-example";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
|
||||
const editorBaseTheme = {
|
||||
"&": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "12px 14px 18px",
|
||||
},
|
||||
};
|
||||
|
||||
const editorDarkTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "oklch(0.23 0.02 255)",
|
||||
color: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "oklch(0.25 0.015 255)",
|
||||
color: "oklch(0.74 0.015 255)",
|
||||
borderRight: "1px solid oklch(0.34 0.01 255)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
|
||||
color: "oklch(0.95 0.01 255)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "oklch(0.70 0.08 24)",
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const editorLightTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-content": {
|
||||
...editorBaseTheme[".cm-content"],
|
||||
caretColor: "var(--foreground)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
|
||||
color: "var(--muted-foreground)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
|
||||
color: "var(--foreground)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
});
|
||||
|
||||
const editorDarkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
|
||||
]);
|
||||
|
||||
const editorLightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
|
||||
]);
|
||||
|
||||
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||
type FileTreeNodeProps = {
|
||||
entry: FileEntry;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const PathLikePattern = /[\\/]/;
|
||||
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||
const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isLikelyPath(pathValue: string): boolean {
|
||||
const trimmed = pathValue.trim();
|
||||
return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
|
||||
}
|
||||
|
||||
function workspaceLabel(workspace: Workspace): string {
|
||||
const pathLabel = workspace.path.trim();
|
||||
const nameLabel = workspace.name.trim();
|
||||
const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
|
||||
const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
|
||||
const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
|
||||
if (!baseLabel) {
|
||||
return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
|
||||
}
|
||||
|
||||
return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
|
||||
}
|
||||
|
||||
function useIsMobile(breakpointPx = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
|
||||
const update = () => setIsMobile(mediaQuery.matches);
|
||||
update();
|
||||
mediaQuery.addEventListener("change", update);
|
||||
return () => mediaQuery.removeEventListener("change", update);
|
||||
}, [breakpointPx]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
function useIsDarkMode(): boolean {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const update = () => setIsDarkMode(root.classList.contains("dark"));
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
|
||||
function useAvailableHeight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
options?: { bottomPadding?: number; minHeight?: number },
|
||||
): number | null {
|
||||
const bottomPadding = options?.bottomPadding ?? 24;
|
||||
const minHeight = options?.minHeight ?? 384;
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const update = () => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
|
||||
setHeight(nextHeight);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
|
||||
const observer = typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => update())
|
||||
: null;
|
||||
if (observer && ref.current) observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [bottomPadding, minHeight, ref]);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
entry,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: FileTreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isSelected = selectedPath === entry.path;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
|
||||
<span className="truncate font-medium">{entry.name}</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ExpandedDirectoryChildren
|
||||
directoryPath={entry.path}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 14 + 23}px` }}
|
||||
onClick={() => onSelect(entry.path)}
|
||||
>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDirectoryChildren({
|
||||
directoryPath,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
directoryPath: string;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
directoryPath,
|
||||
});
|
||||
const children = childData?.entries ?? [];
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path}
|
||||
entry={child}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||
*/
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const { data: config, loading: configLoading } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const showFilesInSidebar = config?.showFilesInSidebar ?? false;
|
||||
|
||||
if (configLoading || !showFilesInSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectId = context.entityId;
|
||||
const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
|
||||
.projectRef
|
||||
?? projectId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
|
||||
const isActive = typeof window !== "undefined" && (() => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "");
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const projectsIndex = segments.indexOf("projects");
|
||||
const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
|
||||
const activeTab = new URLSearchParams(window.location.search).get("tab");
|
||||
if (activeTab !== tabValue) return false;
|
||||
if (!activeProjectRef) return false;
|
||||
return activeProjectRef === projectId || activeProjectRef === projectRef;
|
||||
})();
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`block px-3 py-1 text-[12px] truncate transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project detail tab: workspace selector, file tree, and CodeMirror editor.
|
||||
*/
|
||||
export function FilesTab({ context }: PluginDetailTabProps) {
|
||||
const companyId = context.companyId;
|
||||
const projectId = context.entityId;
|
||||
const isMobile = useIsMobile();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const panesRef = useRef<HTMLDivElement | null>(null);
|
||||
const availableHeight = useAvailableHeight(panesRef, {
|
||||
bottomPadding: isMobile ? 16 : 24,
|
||||
minHeight: isMobile ? 320 : 420,
|
||||
});
|
||||
const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", {
|
||||
projectId,
|
||||
companyId,
|
||||
});
|
||||
const workspaces = workspacesData ?? [];
|
||||
const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
|
||||
const selectedWorkspace = useMemo(
|
||||
() => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
|
||||
[workspaces, resolvedWorkspaceId],
|
||||
);
|
||||
|
||||
const fileListParams = useMemo(
|
||||
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||
[companyId, projectId, selectedWorkspace],
|
||||
);
|
||||
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
|
||||
"fileList",
|
||||
fileListParams,
|
||||
);
|
||||
const entries = fileListData?.entries ?? [];
|
||||
|
||||
// Track the `?file=` query parameter across navigations (popstate).
|
||||
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get("file") || null;
|
||||
});
|
||||
const lastConsumedFileRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onNav = () => {
|
||||
const next = new URLSearchParams(window.location.search).get("file") || null;
|
||||
setUrlFilePath(next);
|
||||
};
|
||||
window.addEventListener("popstate", onNav);
|
||||
return () => window.removeEventListener("popstate", onNav);
|
||||
}, []);
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setSelectedPath(null);
|
||||
setMobileView("browser");
|
||||
lastConsumedFileRef.current = null;
|
||||
}, [selectedWorkspace?.id]);
|
||||
|
||||
// When a file path appears (or changes) in the URL and workspace is ready, select it.
|
||||
useEffect(() => {
|
||||
if (!urlFilePath || !selectedWorkspace) return;
|
||||
if (lastConsumedFileRef.current === urlFilePath) return;
|
||||
lastConsumedFileRef.current = urlFilePath;
|
||||
setSelectedPath(urlFilePath);
|
||||
setMobileView("editor");
|
||||
}, [urlFilePath, selectedWorkspace]);
|
||||
|
||||
const fileContentParams = useMemo(
|
||||
() =>
|
||||
selectedPath && selectedWorkspace
|
||||
? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
|
||||
: null,
|
||||
[companyId, projectId, selectedWorkspace, selectedPath],
|
||||
);
|
||||
const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
|
||||
"fileContent",
|
||||
fileContentParams ?? {},
|
||||
);
|
||||
const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
|
||||
const writeFile = usePluginAction("writeFile");
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const loadedContentRef = useRef("");
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const content = fileContentData?.content ?? "";
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
const view = new EditorView({
|
||||
doc: content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
isDarkMode ? editorDarkTheme : editorLightTheme,
|
||||
syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const nextValue = update.state.doc.toString();
|
||||
setIsDirty(nextValue !== loadedContentRef.current);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
}),
|
||||
],
|
||||
parent: editorRef.current,
|
||||
});
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [fileContentData?.content, selectedPath, isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [selectedWorkspace, selectedPath, isDirty, isSaving]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedWorkspace || !selectedPath || !viewRef.current) {
|
||||
return;
|
||||
}
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveMessage(null);
|
||||
try {
|
||||
await writeFile({
|
||||
projectId,
|
||||
companyId,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
filePath: selectedPath,
|
||||
content,
|
||||
});
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage("Saved");
|
||||
refreshFileContent();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<label className="text-sm font-medium text-muted-foreground">Workspace</label>
|
||||
<select
|
||||
key={workspaceSelectKey}
|
||||
className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={resolvedWorkspaceId ?? ""}
|
||||
onChange={(e) => setWorkspaceId(e.target.value || null)}
|
||||
>
|
||||
{workspaces.map((w) => {
|
||||
const label = workspaceLabel(w);
|
||||
return (
|
||||
<option key={`${w.id}:${label}`} value={w.id} label={label} title={label}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={panesRef}
|
||||
className="min-h-0"
|
||||
style={{
|
||||
display: isMobile ? "block" : "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)",
|
||||
height: availableHeight ? `${availableHeight}px` : undefined,
|
||||
minHeight: isMobile ? "20rem" : "26rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
File Tree
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{selectedWorkspace ? (
|
||||
fileListLoading ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
|
||||
) : entries.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeNode
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={selectedWorkspace.id}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
setSelectedPath(path);
|
||||
setMobileView("editor");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground"
|
||||
style={{ display: isMobile ? "inline-flex" : "none" }}
|
||||
onClick={() => setMobileView("browser")}
|
||||
>
|
||||
Back to files
|
||||
</button>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div>
|
||||
<div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isDirty || saveMessage || saveError ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs">
|
||||
{saveError ? (
|
||||
<span className="text-destructive">{saveError}</span>
|
||||
) : saveMessage ? (
|
||||
<span className="text-emerald-600">{saveMessage}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div>
|
||||
) : null}
|
||||
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Annotation: renders detected file links below each comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PluginConfig = {
|
||||
showFilesInSidebar?: boolean;
|
||||
commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-comment annotation showing file-path-like links extracted from the
|
||||
* comment body. Each link navigates to the project Files tab with the
|
||||
* matching path pre-selected.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"contextMenu"` or `"none"`.
|
||||
*/
|
||||
function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
|
||||
if (!projectId) return "#";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
function navigateToFileBrowser(href: string, event: MouseEvent<HTMLAnchorElement>) {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "contextMenu" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Context Menu Item: "Open in Files" action per comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-comment context menu item that appears in the comment "more" (⋮) menu.
|
||||
* Extracts file paths from the comment body and, if any are found, renders
|
||||
* a button to open the first file in the project Files tab.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"annotation"` or `"none"`.
|
||||
*/
|
||||
export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "annotation" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
const fileName = link.split("/").pop() ?? link;
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
<span className="truncate font-mono">{fileName}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const PLUGIN_NAME = "file-browser-example";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const PATH_LIKE_PATTERN = /[\\/]/;
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
|
||||
&& !UUID_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
function sanitizeWorkspacePath(pathValue: string): string {
|
||||
return looksLikePath(pathValue) ? pathValue.trim() : "";
|
||||
}
|
||||
|
||||
function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
|
||||
const root = path.resolve(workspacePath);
|
||||
const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that matches file-path-like tokens in comment text.
|
||||
* Captures tokens that either start with `.` `/` `~` or contain a `/`
|
||||
* (directory separator), plus bare words that could be filenames with
|
||||
* extensions (e.g. `README.md`). The file-extension check in
|
||||
* `extractFilePaths` filters out non-file matches.
|
||||
*/
|
||||
const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
|
||||
|
||||
/** Common file extensions to recognise path-like tokens as actual file references. */
|
||||
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
|
||||
|
||||
/**
|
||||
* Tokens that look like paths but are almost certainly URL route segments
|
||||
* (e.g. `/projects/abc`, `/settings`, `/dashboard`).
|
||||
*/
|
||||
const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
|
||||
|
||||
function extractFilePaths(body: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const match of body.matchAll(FILE_PATH_REGEX)) {
|
||||
const raw = match[1];
|
||||
// Strip trailing punctuation that isn't part of a path
|
||||
const cleaned = raw.replace(/[.:,;!?)]+$/, "");
|
||||
if (cleaned.length <= 1) continue;
|
||||
// Must have a file extension (e.g. .ts, .json, .md)
|
||||
if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
|
||||
// Skip things that look like URL routes
|
||||
if (URL_ROUTE_PATTERN.test(cleaned)) continue;
|
||||
paths.add(cleaned);
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
// Expose the current plugin config so UI components can read operator
|
||||
// settings from the canonical instance config store.
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
const config = await ctx.config.get();
|
||||
return {
|
||||
showFilesInSidebar: config?.showFilesInSidebar === true,
|
||||
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch a comment by ID and extract file-path-like tokens from its body.
|
||||
ctx.data.register("comment-file-links", async (params: Record<string, unknown>) => {
|
||||
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
||||
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!commentId || !issueId || !companyId) return { links: [] };
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(issueId, companyId);
|
||||
const comment = comments.find((c) => c.id === commentId);
|
||||
if (!comment?.body) return { links: [] };
|
||||
return { links: extractFilePaths(comment.body) };
|
||||
} catch (err) {
|
||||
ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
|
||||
return { links: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("workspaces", async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!projectId || !companyId) return [];
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
return workspaces.map((w) => ({
|
||||
id: w.id,
|
||||
projectId: w.projectId,
|
||||
name: w.name,
|
||||
path: sanitizeWorkspacePath(w.path),
|
||||
isPrimary: w.isPrimary,
|
||||
}));
|
||||
});
|
||||
|
||||
ctx.data.register(
|
||||
"fileList",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { entries: [] };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { entries: [] };
|
||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||
if (!dirPath) {
|
||||
return { entries: [] };
|
||||
}
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return { entries: [] };
|
||||
}
|
||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||
const entries = names.map((name) => {
|
||||
const full = path.join(dirPath, name);
|
||||
const stat = fs.lstatSync(full);
|
||||
const relativePath = path.relative(workspacePath, full);
|
||||
return {
|
||||
name,
|
||||
path: relativePath,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
},
|
||||
);
|
||||
|
||||
ctx.data.register(
|
||||
"fileContent",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = params.filePath as string;
|
||||
if (!projectId || !companyId || !workspaceId || !filePath) {
|
||||
return { content: null, error: "Missing file context" };
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { content: null, error: "Workspace not found" };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { content: null, error: "Workspace has no path" };
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
return { content: null, error: "Path outside workspace" };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
return { content };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { content: null, error: message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.actions.register(
|
||||
"writeFile",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
|
||||
if (!filePath) {
|
||||
throw new Error("filePath must be a non-empty string");
|
||||
}
|
||||
const content = typeof params.content === "string" ? params.content : null;
|
||||
if (!projectId || !companyId || !workspaceId) {
|
||||
throw new Error("Missing workspace context");
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) {
|
||||
throw new Error("Workspace has no path");
|
||||
}
|
||||
if (content === null) {
|
||||
throw new Error("Missing file content");
|
||||
}
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
throw new Error("Path outside workspace");
|
||||
}
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Selected path is not a file");
|
||||
}
|
||||
fs.writeFileSync(fullPath, content, "utf-8");
|
||||
return {
|
||||
ok: true,
|
||||
path: filePath,
|
||||
bytes: Buffer.byteLength(content, "utf-8"),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# @paperclipai/plugin-hello-world-example
|
||||
|
||||
First-party reference plugin showing the smallest possible UI extension.
|
||||
|
||||
## What It Demonstrates
|
||||
|
||||
- a manifest with a `dashboardWidget` UI slot
|
||||
- `entrypoints.ui` wiring for plugin UI bundles
|
||||
- a minimal React widget rendered in the Paperclip dashboard
|
||||
- reading host context (`companyId`) from `PluginWidgetProps`
|
||||
- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
|
||||
|
||||
## API Surface
|
||||
|
||||
- This example does not add custom HTTP endpoints.
|
||||
- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
|
||||
|
||||
## Notes
|
||||
|
||||
This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
|
||||
It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-hello-world-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
|
||||
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-hello-world-example",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party reference plugin that adds a Hello World dashboard widget",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Stable plugin ID used by host registration and namespacing.
|
||||
*/
|
||||
const PLUGIN_ID = "paperclip.hello-world-example";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
|
||||
const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
|
||||
|
||||
/**
|
||||
* Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
|
||||
*/
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Hello World Widget (Example)",
|
||||
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui"],
|
||||
capabilities: ["ui.dashboardWidget.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: DASHBOARD_WIDGET_SLOT_ID,
|
||||
displayName: "Hello World",
|
||||
exportName: DASHBOARD_WIDGET_EXPORT_NAME,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const WIDGET_LABEL = "Hello world plugin widget";
|
||||
|
||||
/**
|
||||
* Example dashboard widget showing the smallest possible UI contribution.
|
||||
*/
|
||||
export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
|
||||
return (
|
||||
<section aria-label={WIDGET_LABEL}>
|
||||
<strong>Hello world</strong>
|
||||
<div>This widget was added by @paperclipai/plugin-hello-world-example.</div>
|
||||
{/* Include host context so authors can see where scoped IDs come from. */}
|
||||
<div>Company context: {context.companyId}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_NAME = "hello-world-example";
|
||||
const HEALTH_MESSAGE = "Hello World example plugin ready";
|
||||
|
||||
/**
|
||||
* Worker lifecycle hooks for the Hello World reference plugin.
|
||||
* This stays intentionally small so new authors can copy the shape quickly.
|
||||
*/
|
||||
const plugin = definePlugin({
|
||||
/**
|
||||
* Called when the host starts the plugin worker.
|
||||
*/
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by the host health probe endpoint.
|
||||
*/
|
||||
async onHealth() {
|
||||
return { status: "ok", message: HEALTH_MESSAGE };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# @paperclipai/plugin-kitchen-sink-example
|
||||
|
||||
Kitchen Sink is the first-party reference plugin that demonstrates nearly the full currently implemented Paperclip plugin surface in one package.
|
||||
|
||||
It is intentionally broad:
|
||||
|
||||
- full plugin page
|
||||
- dashboard widget
|
||||
- project and issue surfaces
|
||||
- comment surfaces
|
||||
- sidebar surfaces
|
||||
- settings page
|
||||
- worker bridge data/actions
|
||||
- events, jobs, webhooks, tools, streams
|
||||
- state, entities, assets, metrics, activity
|
||||
- local workspace and process demos
|
||||
|
||||
This plugin is for local development, contributor onboarding, and runtime regression testing. It is not meant as a production plugin template to ship unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-kitchen-sink-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
|
||||
```
|
||||
|
||||
Or install it from the Paperclip plugin manager as a bundled example once this repo is built.
|
||||
|
||||
## Notes
|
||||
|
||||
- Local workspace and process demos are trusted-only and default to safe, curated commands.
|
||||
- The plugin settings page lets you toggle optional demo surfaces and local runtime behavior.
|
||||
- Some SDK-defined host surfaces still depend on the Paperclip host wiring them visibly; this package aims to exercise the currently mounted ones and make the rest obvious.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-kitchen-sink-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Reference plugin that demonstrates the full Paperclip plugin surface area in one package",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
|
||||
export const PLUGIN_VERSION = "0.1.0";
|
||||
export const PAGE_ROUTE = "kitchensink";
|
||||
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
settingsPage: "kitchen-sink-settings-page",
|
||||
dashboardWidget: "kitchen-sink-dashboard-widget",
|
||||
sidebar: "kitchen-sink-sidebar-link",
|
||||
sidebarPanel: "kitchen-sink-sidebar-panel",
|
||||
projectSidebarItem: "kitchen-sink-project-link",
|
||||
projectTab: "kitchen-sink-project-tab",
|
||||
issueTab: "kitchen-sink-issue-tab",
|
||||
taskDetailView: "kitchen-sink-task-detail",
|
||||
toolbarButton: "kitchen-sink-toolbar-action",
|
||||
contextMenuItem: "kitchen-sink-context-action",
|
||||
commentAnnotation: "kitchen-sink-comment-annotation",
|
||||
commentContextMenuItem: "kitchen-sink-comment-action",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_NAMES = {
|
||||
page: "KitchenSinkPage",
|
||||
settingsPage: "KitchenSinkSettingsPage",
|
||||
dashboardWidget: "KitchenSinkDashboardWidget",
|
||||
sidebar: "KitchenSinkSidebarLink",
|
||||
sidebarPanel: "KitchenSinkSidebarPanel",
|
||||
projectSidebarItem: "KitchenSinkProjectSidebarItem",
|
||||
projectTab: "KitchenSinkProjectTab",
|
||||
issueTab: "KitchenSinkIssueTab",
|
||||
taskDetailView: "KitchenSinkTaskDetailView",
|
||||
toolbarButton: "KitchenSinkToolbarButton",
|
||||
contextMenuItem: "KitchenSinkContextMenuItem",
|
||||
commentAnnotation: "KitchenSinkCommentAnnotation",
|
||||
commentContextMenuItem: "KitchenSinkCommentContextMenuItem",
|
||||
launcherModal: "KitchenSinkLauncherModal",
|
||||
} as const;
|
||||
|
||||
export const JOB_KEYS = {
|
||||
heartbeat: "demo-heartbeat",
|
||||
} as const;
|
||||
|
||||
export const WEBHOOK_KEYS = {
|
||||
demo: "demo-ingest",
|
||||
} as const;
|
||||
|
||||
export const TOOL_NAMES = {
|
||||
echo: "echo",
|
||||
companySummary: "company-summary",
|
||||
createIssue: "create-issue",
|
||||
} as const;
|
||||
|
||||
export const STREAM_CHANNELS = {
|
||||
progress: "progress",
|
||||
agentChat: "agent-chat",
|
||||
} as const;
|
||||
|
||||
export const SAFE_COMMANDS = [
|
||||
{
|
||||
key: "pwd",
|
||||
label: "Print workspace path",
|
||||
command: "pwd",
|
||||
args: [] as string[],
|
||||
description: "Prints the current workspace directory.",
|
||||
},
|
||||
{
|
||||
key: "ls",
|
||||
label: "List workspace files",
|
||||
command: "ls",
|
||||
args: ["-la"] as string[],
|
||||
description: "Lists files in the selected workspace.",
|
||||
},
|
||||
{
|
||||
key: "git-status",
|
||||
label: "Git status",
|
||||
command: "git",
|
||||
args: ["status", "--short", "--branch"] as string[],
|
||||
description: "Shows git status for the selected workspace.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SafeCommandKey = (typeof SAFE_COMMANDS)[number]["key"];
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showSidebarEntry: true,
|
||||
showSidebarPanel: true,
|
||||
showProjectSidebarItem: true,
|
||||
showCommentAnnotation: true,
|
||||
showCommentContextMenuItem: true,
|
||||
enableWorkspaceDemos: true,
|
||||
enableProcessDemos: false,
|
||||
secretRefExample: "",
|
||||
httpDemoUrl: "https://httpbin.org/anything",
|
||||
allowedCommands: SAFE_COMMANDS.map((command) => command.key),
|
||||
workspaceScratchFile: ".paperclip-kitchen-sink-demo.txt",
|
||||
} as const;
|
||||
|
||||
export const RUNTIME_LAUNCHER: PluginLauncherRegistration = {
|
||||
id: "kitchen-sink-runtime-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
description: "Demonstrates runtime launcher registration from the worker.",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
EXPORT_NAMES,
|
||||
JOB_KEYS,
|
||||
PAGE_ROUTE,
|
||||
PLUGIN_ID,
|
||||
PLUGIN_VERSION,
|
||||
SLOT_IDS,
|
||||
TOOL_NAMES,
|
||||
WEBHOOK_KEYS,
|
||||
} from "./constants.js";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Kitchen Sink (Example)",
|
||||
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, UI surfaces, bridge actions, events, jobs, webhooks, tools, local workspace access, and runtime diagnostics in one place.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui", "automation", "workspace", "connector"],
|
||||
capabilities: [
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"agents.read",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"agent.tools.register",
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showSidebarEntry: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Entry",
|
||||
default: DEFAULT_CONFIG.showSidebarEntry,
|
||||
},
|
||||
showSidebarPanel: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Panel",
|
||||
default: DEFAULT_CONFIG.showSidebarPanel,
|
||||
},
|
||||
showProjectSidebarItem: {
|
||||
type: "boolean",
|
||||
title: "Show Project Sidebar Item",
|
||||
default: DEFAULT_CONFIG.showProjectSidebarItem,
|
||||
},
|
||||
showCommentAnnotation: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Annotation",
|
||||
default: DEFAULT_CONFIG.showCommentAnnotation,
|
||||
},
|
||||
showCommentContextMenuItem: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Action",
|
||||
default: DEFAULT_CONFIG.showCommentContextMenuItem,
|
||||
},
|
||||
enableWorkspaceDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Workspace Demos",
|
||||
default: DEFAULT_CONFIG.enableWorkspaceDemos,
|
||||
},
|
||||
enableProcessDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Process Demos",
|
||||
default: DEFAULT_CONFIG.enableProcessDemos,
|
||||
description: "Allows curated local child-process demos in project workspaces.",
|
||||
},
|
||||
secretRefExample: {
|
||||
type: "string",
|
||||
title: "Secret Reference Example",
|
||||
default: DEFAULT_CONFIG.secretRefExample,
|
||||
},
|
||||
httpDemoUrl: {
|
||||
type: "string",
|
||||
title: "HTTP Demo URL",
|
||||
default: DEFAULT_CONFIG.httpDemoUrl,
|
||||
},
|
||||
allowedCommands: {
|
||||
type: "array",
|
||||
title: "Allowed Process Commands",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
default: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
workspaceScratchFile: {
|
||||
type: "string",
|
||||
title: "Workspace Scratch File",
|
||||
default: DEFAULT_CONFIG.workspaceScratchFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
jobKey: JOB_KEYS.heartbeat,
|
||||
displayName: "Demo Heartbeat",
|
||||
description: "Periodic demo job that records plugin runtime activity.",
|
||||
schedule: "*/15 * * * *",
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
endpointKey: WEBHOOK_KEYS.demo,
|
||||
displayName: "Demo Ingest",
|
||||
description: "Accepts arbitrary webhook payloads and records the latest delivery in plugin state.",
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAMES.echo,
|
||||
displayName: "Kitchen Sink Echo",
|
||||
description: "Returns the provided message and the current run context.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.companySummary,
|
||||
displayName: "Kitchen Sink Company Summary",
|
||||
description: "Summarizes the current company using the Paperclip domain APIs.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.createIssue,
|
||||
displayName: "Kitchen Sink Create Issue",
|
||||
description: "Creates an issue in the current project from an agent tool call.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: SLOT_IDS.page,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.page,
|
||||
routePath: PAGE_ROUTE,
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: SLOT_IDS.settingsPage,
|
||||
displayName: "Kitchen Sink Settings",
|
||||
exportName: EXPORT_NAMES.settingsPage,
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: SLOT_IDS.dashboardWidget,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.dashboardWidget,
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: SLOT_IDS.sidebar,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.sidebar,
|
||||
},
|
||||
{
|
||||
type: "sidebarPanel",
|
||||
id: SLOT_IDS.sidebarPanel,
|
||||
displayName: "Kitchen Sink Panel",
|
||||
exportName: EXPORT_NAMES.sidebarPanel,
|
||||
},
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: SLOT_IDS.projectSidebarItem,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectSidebarItem,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.projectTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectTab,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.issueTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.issueTab,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "taskDetailView",
|
||||
id: SLOT_IDS.taskDetailView,
|
||||
displayName: "Kitchen Sink Task View",
|
||||
exportName: EXPORT_NAMES.taskDetailView,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "toolbarButton",
|
||||
id: SLOT_IDS.toolbarButton,
|
||||
displayName: "Kitchen Sink Action",
|
||||
exportName: EXPORT_NAMES.toolbarButton,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "contextMenuItem",
|
||||
id: SLOT_IDS.contextMenuItem,
|
||||
displayName: "Kitchen Sink Context",
|
||||
exportName: EXPORT_NAMES.contextMenuItem,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: SLOT_IDS.commentAnnotation,
|
||||
displayName: "Kitchen Sink Comment Annotation",
|
||||
exportName: EXPORT_NAMES.commentAnnotation,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: SLOT_IDS.commentContextMenuItem,
|
||||
displayName: "Kitchen Sink Comment Action",
|
||||
exportName: EXPORT_NAMES.commentContextMenuItem,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
launchers: [
|
||||
{
|
||||
id: "kitchen-sink-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||
const TARGET_FPS = 24;
|
||||
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||
|
||||
const PAPERCLIP_SPRITES = [
|
||||
[
|
||||
" ╭────╮ ",
|
||||
" ╭╯╭──╮│ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ ╰──╯│ ",
|
||||
" ╰─────╯ ",
|
||||
],
|
||||
[
|
||||
" ╭─────╮ ",
|
||||
" │╭──╮╰╮ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" │╰──╯ │ ",
|
||||
" ╰────╯ ",
|
||||
],
|
||||
] as const;
|
||||
|
||||
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||
|
||||
interface Clip {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
drift: number;
|
||||
sprite: PaperclipSprite;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "M";
|
||||
span.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||
container.appendChild(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
container.removeChild(span);
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||
let width = 0;
|
||||
for (const row of sprite) width = Math.max(width, row.length);
|
||||
return { width, height: sprite.length };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let isVisible = document.visibilityState !== "hidden";
|
||||
let loopActive = false;
|
||||
let lastRenderAt = 0;
|
||||
let tick = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let charW = 7;
|
||||
let charH = 11;
|
||||
let trail = new Float32Array(0);
|
||||
let colWave = new Float32Array(0);
|
||||
let rowWave = new Float32Array(0);
|
||||
let clipMask = new Uint16Array(0);
|
||||
let clips: Clip[] = [];
|
||||
let lastOutput = "";
|
||||
|
||||
function toGlyph(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(0.999, value));
|
||||
const idx = Math.floor(clamped * CHARS.length);
|
||||
return CHARS[idx] ?? " ";
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||
if (nextCols === cols && nextRows === rows) return;
|
||||
|
||||
cols = nextCols;
|
||||
rows = nextRows;
|
||||
const cellCount = cols * rows;
|
||||
trail = new Float32Array(cellCount);
|
||||
colWave = new Float32Array(cols);
|
||||
rowWave = new Float32Array(rows);
|
||||
clipMask = new Uint16Array(cellCount);
|
||||
clips = clips.filter((clip) => {
|
||||
return (
|
||||
clip.x > -clip.width - 2 &&
|
||||
clip.x < cols + 2 &&
|
||||
clip.y > -clip.height - 2 &&
|
||||
clip.y < rows + 2
|
||||
);
|
||||
});
|
||||
lastOutput = "";
|
||||
}
|
||||
|
||||
function drawStaticFrame() {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
preEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||
grid[r]![c] = toGlyph(ambient);
|
||||
}
|
||||
}
|
||||
|
||||
const gapX = 18;
|
||||
const gapY = 13;
|
||||
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||
for (let sr = 0; sr < sprite.length; sr++) {
|
||||
const line = sprite[sr]!;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const row = baseRow + sr;
|
||||
const col = baseCol + sc;
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||
grid[row]![col] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = grid.map((line) => line.join("")).join("\n");
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
|
||||
function spawnClip() {
|
||||
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||
const size = spriteSize(sprite);
|
||||
const edge = Math.random();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (edge < 0.68) {
|
||||
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||
y = Math.random() * Math.max(1, rows - size.height);
|
||||
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||
vy = (Math.random() - 0.5) * 0.014;
|
||||
} else {
|
||||
x = Math.random() * Math.max(1, cols - size.width);
|
||||
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.014;
|
||||
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||
}
|
||||
|
||||
clips.push({
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life: 0,
|
||||
maxLife: 260 + Math.random() * 220,
|
||||
drift: (Math.random() - 0.5) * 1.2,
|
||||
sprite,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function stampClip(clip: Clip, alpha: number) {
|
||||
const baseCol = Math.round(clip.x);
|
||||
const baseRow = Math.round(clip.y);
|
||||
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||
const line = clip.sprite[sr]!;
|
||||
const row = baseRow + sr;
|
||||
if (row < 0 || row >= rows) continue;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const col = baseCol + sc;
|
||||
if (col < 0 || col >= cols) continue;
|
||||
const idx = row * cols + col;
|
||||
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||
clipMask[idx] = ch.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(time: number) {
|
||||
if (!loopActive) return;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||
|
||||
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||
lastRenderAt = time;
|
||||
tick += delta;
|
||||
|
||||
const cellCount = cols * rows;
|
||||
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||
while (clips.length < targetCount) spawnClip();
|
||||
|
||||
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||
clipMask.fill(0);
|
||||
|
||||
for (let i = clips.length - 1; i >= 0; i--) {
|
||||
const clip = clips[i]!;
|
||||
clip.life += delta;
|
||||
|
||||
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||
|
||||
clip.x += clip.vx * delta;
|
||||
clip.y += clip.vy * delta;
|
||||
|
||||
if (
|
||||
clip.life >= clip.maxLife ||
|
||||
clip.x < -clip.width - 2 ||
|
||||
clip.x > cols + 2 ||
|
||||
clip.y < -clip.height - 2 ||
|
||||
clip.y > rows + 2
|
||||
) {
|
||||
clips.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const life = clip.life / clip.maxLife;
|
||||
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||
stampClip(clip, alpha);
|
||||
}
|
||||
|
||||
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
const clipChar = clipMask[idx];
|
||||
if (clipChar > 0) {
|
||||
output += String.fromCharCode(clipChar);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05;
|
||||
output += toGlyph((trail[idx] ?? 0) + ambient);
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
if (output !== lastOutput) {
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
drawStaticFrame();
|
||||
}
|
||||
});
|
||||
|
||||
function startLoop() {
|
||||
if (loopActive) return;
|
||||
loopActive = true;
|
||||
lastRenderAt = 0;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMode() {
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
stopLoop();
|
||||
drawStaticFrame();
|
||||
} else {
|
||||
startLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
isVisible = document.visibilityState !== "hidden";
|
||||
syncMode();
|
||||
}
|
||||
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
resizeObserver.observe(preEl);
|
||||
motionMedia.addEventListener("change", syncMode);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
syncMode();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
resizeObserver.disconnect();
|
||||
motionMedia.removeEventListener("change", syncMode);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "320px",
|
||||
minHeight: "320px",
|
||||
maxHeight: "350px",
|
||||
background: "#1d1d1d",
|
||||
color: "#f2efe6",
|
||||
overflow: "hidden",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "14px",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "pre",
|
||||
userSelect: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1041
packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts
Normal file
1041
packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
873
packages/plugins/sdk/README.md
Normal file
873
packages/plugins/sdk/README.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# `@paperclipai/plugin-sdk`
|
||||
|
||||
Official TypeScript SDK for Paperclip plugin authors.
|
||||
|
||||
- **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle
|
||||
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
|
||||
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
|
||||
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
|
||||
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
|
||||
|
||||
Reference: `doc/plugins/PLUGIN_SPEC.md`
|
||||
|
||||
## Package surface
|
||||
|
||||
| Import | Purpose |
|
||||
|--------|--------|
|
||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
|
||||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||||
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
||||
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
|
||||
| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
|
||||
| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
|
||||
|
||||
## Manifest entrypoints
|
||||
|
||||
In your plugin manifest you declare:
|
||||
|
||||
- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
|
||||
- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @paperclipai/plugin-sdk
|
||||
```
|
||||
|
||||
## Current deployment caveats
|
||||
|
||||
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
|
||||
|
||||
- Plugin workers and plugin UI should both be treated as trusted code today.
|
||||
- Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
|
||||
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
|
||||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||||
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
|
||||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||||
|
||||
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
||||
|
||||
## Worker quick start
|
||||
|
||||
```ts
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
ctx.logger.info("Issue created", { issueId: event.entityId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => ({ status: "ok" }));
|
||||
ctx.actions.register("ping", async () => ({ pong: true }));
|
||||
|
||||
ctx.tools.register("calculator", {
|
||||
displayName: "Calculator",
|
||||
description: "Basic math",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: { a: { type: "number" }, b: { type: "number" } },
|
||||
required: ["a", "b"]
|
||||
}
|
||||
}, async (params) => {
|
||||
const { a, b } = params as { a: number; b: number };
|
||||
return { content: `Result: ${a + b}`, data: { result: a + b } };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
```
|
||||
|
||||
**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.
|
||||
|
||||
### Worker lifecycle and context
|
||||
|
||||
**Lifecycle (definePlugin):**
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|--------|
|
||||
| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
|
||||
| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
|
||||
| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
|
||||
| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
|
||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||
|
||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
|
||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||
|
||||
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
||||
|
||||
## Events
|
||||
|
||||
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
|
||||
|
||||
**Core domain events (subscribe with `events.subscribe`):**
|
||||
|
||||
| Event | Typical entity |
|
||||
|-------|-----------------|
|
||||
| `company.created`, `company.updated` | company |
|
||||
| `project.created`, `project.updated` | project |
|
||||
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
|
||||
| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
|
||||
| `agent.created`, `agent.updated`, `agent.status_changed` | agent |
|
||||
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
|
||||
| `goal.created`, `goal.updated` | goal |
|
||||
| `approval.created`, `approval.decided` | approval |
|
||||
| `cost_event.created` | cost |
|
||||
| `activity.logged` | activity |
|
||||
|
||||
**Plugin-to-plugin:** Subscribe to `plugin.<pluginId>.<eventName>` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically.
|
||||
|
||||
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
|
||||
|
||||
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
|
||||
|
||||
## Scheduled (recurring) jobs
|
||||
|
||||
Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
|
||||
|
||||
1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
|
||||
2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
|
||||
3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
|
||||
|
||||
**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
|
||||
|
||||
| Field | Values | Example |
|
||||
|-------------|----------|---------|
|
||||
| minute | 0–59 | `0`, `*/15` |
|
||||
| hour | 0–23 | `2`, `*` |
|
||||
| day of month | 1–31 | `1`, `*` |
|
||||
| month | 1–12 | `*` |
|
||||
| day of week | 0–6 (Sun=0) | `*`, `1-5` |
|
||||
|
||||
Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
|
||||
|
||||
**Job handler context** (`PluginJobContext`):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------|-------------|
|
||||
| `jobKey` | string | Matches the manifest declaration. |
|
||||
| `runId` | string | UUID for this run. |
|
||||
| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
|
||||
| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
|
||||
|
||||
Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
|
||||
|
||||
Example:
|
||||
|
||||
**Manifest** — include `jobs.schedule` and declare the job:
|
||||
|
||||
```ts
|
||||
// In your manifest (e.g. manifest.ts):
|
||||
const manifest = {
|
||||
// ...
|
||||
capabilities: ["jobs.schedule", "plugin.state.write"],
|
||||
jobs: [
|
||||
{
|
||||
jobKey: "heartbeat",
|
||||
displayName: "Heartbeat",
|
||||
description: "Runs every 5 minutes",
|
||||
schedule: "*/5 * * * *",
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Worker** — register the handler in `setup()`:
|
||||
|
||||
```ts
|
||||
ctx.jobs.register("heartbeat", async (job) => {
|
||||
ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
|
||||
await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
|
||||
});
|
||||
```
|
||||
|
||||
## UI slots and launchers
|
||||
|
||||
Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).
|
||||
|
||||
### Slot types / launcher placement zones
|
||||
|
||||
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
|
||||
|
||||
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
||||
|----------------------------|-------|---------------------------------------|
|
||||
| `page` | Global | — |
|
||||
| `sidebar` | Global | — |
|
||||
| `sidebarPanel` | Global | — |
|
||||
| `settingsPage` | Global | — |
|
||||
| `dashboardWidget` | Global | — |
|
||||
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
|
||||
| `taskDetailView` | Entity | (task/issue context) |
|
||||
| `commentAnnotation` | Entity | `comment` |
|
||||
| `commentContextMenuItem` | Entity | `comment` |
|
||||
| `projectSidebarItem` | Entity | `project` |
|
||||
| `toolbarButton` | Entity | varies by host surface |
|
||||
| `contextMenuItem` | Entity | varies by host surface |
|
||||
|
||||
**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue).
|
||||
|
||||
**Entity types** (for `entityTypes` on slots): `project` \| `issue` \| `agent` \| `goal` \| `run` \| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
### Slot component descriptions
|
||||
|
||||
#### `page`
|
||||
|
||||
A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability.
|
||||
|
||||
#### `sidebar`
|
||||
|
||||
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `sidebarPanel`
|
||||
|
||||
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `settingsPage`
|
||||
|
||||
Replaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, "Test Connection" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`).
|
||||
|
||||
#### `dashboardWidget`
|
||||
|
||||
A card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Paperclip information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability.
|
||||
|
||||
#### `detailTab`
|
||||
|
||||
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
#### `taskDetailView`
|
||||
|
||||
A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
#### `projectSidebarItem`
|
||||
|
||||
A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `toolbarButton`
|
||||
|
||||
A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `contextMenuItem`
|
||||
|
||||
An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `commentAnnotation`
|
||||
|
||||
A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.
|
||||
|
||||
#### `commentContextMenuItem`
|
||||
|
||||
A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.
|
||||
|
||||
### Launcher actions and render options
|
||||
|
||||
| Launcher action | Description |
|
||||
|-----------------|-------------|
|
||||
| `navigate` | Navigate to a route (plugin or host). |
|
||||
| `openModal` | Open a modal. |
|
||||
| `openDrawer` | Open a drawer. |
|
||||
| `openPopover` | Open a popover. |
|
||||
| `performAction` | Run an action (e.g. call plugin). |
|
||||
| `deepLink` | Deep link to plugin or external URL. |
|
||||
|
||||
| Render option | Values | Description |
|
||||
|---------------|--------|-------------|
|
||||
| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
|
||||
| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
|
||||
|
||||
### Capabilities
|
||||
|
||||
Declare in `manifest.capabilities`. Grouped by scope:
|
||||
|
||||
| Scope | Capability |
|
||||
|-------|------------|
|
||||
| **Company** | `companies.read` |
|
||||
| | `projects.read` |
|
||||
| | `project.workspaces.read` |
|
||||
| | `issues.read` |
|
||||
| | `issue.comments.read` |
|
||||
| | `agents.read` |
|
||||
| | `goals.read` |
|
||||
| | `goals.create` |
|
||||
| | `goals.update` |
|
||||
| | `activity.read` |
|
||||
| | `costs.read` |
|
||||
| | `issues.create` |
|
||||
| | `issues.update` |
|
||||
| | `issue.comments.create` |
|
||||
| | `activity.log.write` |
|
||||
| | `metrics.write` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
| | `plugin.state.read` |
|
||||
| | `plugin.state.write` |
|
||||
| **Runtime** | `events.subscribe` |
|
||||
| | `events.emit` |
|
||||
| | `jobs.schedule` |
|
||||
| | `webhooks.receive` |
|
||||
| | `http.outbound` |
|
||||
| | `secrets.read-ref` |
|
||||
| **Agent** | `agent.tools.register` |
|
||||
| | `agents.invoke` |
|
||||
| | `agent.sessions.create` |
|
||||
| | `agent.sessions.list` |
|
||||
| | `agent.sessions.send` |
|
||||
| | `agent.sessions.close` |
|
||||
| **UI** | `ui.sidebar.register` |
|
||||
| | `ui.page.register` |
|
||||
| | `ui.detailTab.register` |
|
||||
| | `ui.dashboardWidget.register` |
|
||||
| | `ui.commentAnnotation.register` |
|
||||
| | `ui.action.register` |
|
||||
|
||||
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
## UI quick start
|
||||
|
||||
```tsx
|
||||
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function DashboardWidget() {
|
||||
const { data } = usePluginData<{ status: string }>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<strong>Health</strong>
|
||||
<div>{data?.status ?? "unknown"}</div>
|
||||
<button onClick={() => void ping()}>Ping</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks reference
|
||||
|
||||
#### `usePluginData<T>(key, params?)`
|
||||
|
||||
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
|
||||
|
||||
```tsx
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface SyncStatus {
|
||||
lastSyncAt: string;
|
||||
syncedCount: number;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export function SyncStatusWidget({ context }: PluginWidgetProps) {
|
||||
const { data, loading, error, refresh } = usePluginData<SyncStatus>("sync-status", {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
|
||||
<p>Synced {data!.syncedCount} items</p>
|
||||
<p>Last sync: {data!.lastSyncAt}</p>
|
||||
<button onClick={refresh}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `usePluginAction(key)`
|
||||
|
||||
Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function ResyncButton({ context }: PluginWidgetProps) {
|
||||
const resync = usePluginAction("resync");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resync({ companyId: context.companyId });
|
||||
} catch (err) {
|
||||
setError((err as PluginBridgeError).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleClick} disabled={busy}>
|
||||
{busy ? "Syncing..." : "Resync Now"}
|
||||
</button>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `useHostContext()`
|
||||
|
||||
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
|
||||
|
||||
```tsx
|
||||
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||||
const { companyId, entityId, entityType } = context;
|
||||
const { data } = usePluginData<{ url: string }>("linear-link", {
|
||||
companyId,
|
||||
issueId: entityId,
|
||||
});
|
||||
|
||||
if (!data?.url) return <p>No linked Linear issue.</p>;
|
||||
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
|
||||
}
|
||||
```
|
||||
|
||||
#### `usePluginStream<T>(channel, options?)`
|
||||
|
||||
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
||||
|
||||
```tsx
|
||||
import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface ChatToken {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ context }: PluginWidgetProps) {
|
||||
const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
|
||||
companyId: context.companyId ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
{connected && <span className="pulse" />}
|
||||
<button onClick={close}>Stop</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
|
||||
|
||||
### UI authoring note
|
||||
|
||||
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
|
||||
|
||||
### Slot component props
|
||||
|
||||
Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`.
|
||||
|
||||
| Slot type | Props interface | `context` extras |
|
||||
|-----------|----------------|------------------|
|
||||
| `page` | `PluginPageProps` | — |
|
||||
| `sidebar` | `PluginSidebarProps` | — |
|
||||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||||
| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
|
||||
| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
|
||||
|
||||
Example detail tab with entity context:
|
||||
|
||||
```tsx
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
||||
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
|
||||
agentId: context.entityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (!data) return <p>No metrics available.</p>;
|
||||
|
||||
return (
|
||||
<dl>
|
||||
{Object.entries(data).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Launcher surfaces and modals
|
||||
|
||||
V1 does not provide a dedicated `modal` slot. Plugins can either:
|
||||
|
||||
- declare concrete UI mount points in `ui.slots`
|
||||
- declare host-rendered entry points in `ui.launchers`
|
||||
|
||||
Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
|
||||
|
||||
Declarative launcher example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"launchers": [
|
||||
{
|
||||
"id": "sync-project",
|
||||
"displayName": "Sync",
|
||||
"placementZone": "toolbarButton",
|
||||
"entityTypes": ["project"],
|
||||
"action": {
|
||||
"type": "openDrawer",
|
||||
"target": "sync-project"
|
||||
},
|
||||
"render": {
|
||||
"environment": "hostOverlay",
|
||||
"bounds": "wide"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
|
||||
|
||||
When a launcher opens a host-owned overlay or page, `useHostContext()`,
|
||||
`usePluginData()`, and `usePluginAction()` receive the current
|
||||
`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
|
||||
full-page layouts without adding custom route parsing in the plugin.
|
||||
|
||||
## Project sidebar item
|
||||
|
||||
Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"slots": [
|
||||
{
|
||||
"type": "projectSidebarItem",
|
||||
"id": "files",
|
||||
"displayName": "Files",
|
||||
"exportName": "FilesLink",
|
||||
"entityTypes": ["project"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
|
||||
}
|
||||
```
|
||||
|
||||
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
||||
|
||||
```tsx
|
||||
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const projectId = context.entityId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectRef = projectId; // or resolve from host; entityId is project id
|
||||
return (
|
||||
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.
|
||||
|
||||
## Toolbar launcher with a local modal
|
||||
|
||||
For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"slots": [
|
||||
{
|
||||
"type": "toolbarButton",
|
||||
"id": "sync-toolbar-button",
|
||||
"displayName": "Sync",
|
||||
"exportName": "SyncToolbarButton"
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": ["ui.action.register"]
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useHostContext,
|
||||
usePluginAction,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function SyncToolbarButton() {
|
||||
const context = useHostContext();
|
||||
const syncProject = usePluginAction("sync-project");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function confirm() {
|
||||
if (!context.projectId) return;
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await syncProject({ projectId: context.projectId });
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Sync failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Sync
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => !submitting && setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-base font-semibold">Sync this project?</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Queue a sync for <code>{context.projectId}</code>.
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<p className="mt-2 text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
||||
{submitting ? "Running…" : "Run sync"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
|
||||
|
||||
## Real-time streaming (`ctx.streams`)
|
||||
|
||||
Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
|
||||
|
||||
### Worker side
|
||||
|
||||
In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
|
||||
|
||||
```ts
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("chat", async (params) => {
|
||||
const companyId = params.companyId as string;
|
||||
ctx.streams.open("chat-stream", companyId);
|
||||
|
||||
for await (const token of streamFromLLM(params.prompt as string)) {
|
||||
ctx.streams.emit("chat-stream", { text: token });
|
||||
}
|
||||
|
||||
ctx.streams.close("chat-stream");
|
||||
return { ok: true };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
|
||||
| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
|
||||
| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
|
||||
|
||||
Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
|
||||
|
||||
### UI side
|
||||
|
||||
Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
|
||||
|
||||
### Host-side architecture
|
||||
|
||||
The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
|
||||
|
||||
1. Worker emits `streams.emit` notification via stdout
|
||||
2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
|
||||
3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
|
||||
|
||||
The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
|
||||
|
||||
### Streaming agent responses to the UI
|
||||
|
||||
`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
|
||||
|
||||
```
|
||||
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
|
||||
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
|
||||
```
|
||||
|
||||
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
|
||||
|
||||
**Worker:**
|
||||
|
||||
```ts
|
||||
ctx.actions.register("ask-agent", async (params) => {
|
||||
const { agentId, companyId, prompt } = params as {
|
||||
agentId: string; companyId: string; prompt: string;
|
||||
};
|
||||
|
||||
const channel = `agent:${agentId}`;
|
||||
ctx.streams.open(channel, companyId);
|
||||
|
||||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||
|
||||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||
prompt,
|
||||
onEvent: (event) => {
|
||||
ctx.streams.emit(channel, {
|
||||
type: event.eventType, // "chunk" | "done" | "error"
|
||||
text: event.message ?? "",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
ctx.streams.close(channel);
|
||||
return { sessionId: session.sessionId };
|
||||
});
|
||||
```
|
||||
|
||||
**UI:**
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface AgentEvent {
|
||||
type: "chunk" | "done" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
|
||||
const askAgent = usePluginAction("ask-agent");
|
||||
const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
async function send() {
|
||||
setPrompt("");
|
||||
await askAgent({ agentId, companyId, prompt });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
|
||||
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button onClick={send}>Send</button>
|
||||
{connected && <button onClick={close}>Stop</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Agent sessions (two-way chat)
|
||||
|
||||
Plugins can hold multi-turn conversational sessions with agents:
|
||||
|
||||
```ts
|
||||
// Create a session
|
||||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||
|
||||
// Send a message and stream the response
|
||||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||
prompt: "Help me triage this issue",
|
||||
onEvent: (event) => {
|
||||
if (event.eventType === "chunk") console.log(event.message);
|
||||
if (event.eventType === "done") console.log("Stream complete");
|
||||
},
|
||||
});
|
||||
|
||||
// List active sessions
|
||||
const sessions = await ctx.agents.sessions.list(agentId, companyId);
|
||||
|
||||
// Close when done
|
||||
await ctx.agents.sessions.close(session.sessionId, companyId);
|
||||
```
|
||||
|
||||
Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
|
||||
|
||||
Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
|
||||
|
||||
## Testing utilities
|
||||
|
||||
```ts
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import plugin from "../src/worker.js";
|
||||
import manifest from "../src/manifest.js";
|
||||
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
```
|
||||
|
||||
## Bundler presets
|
||||
|
||||
```ts
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
|
||||
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
|
||||
```
|
||||
|
||||
## Local dev server (hot-reload events)
|
||||
|
||||
```bash
|
||||
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
|
||||
```
|
||||
|
||||
Or programmatically:
|
||||
|
||||
```ts
|
||||
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
|
||||
const server = await startPluginDevServer({ rootDir: process.cwd() });
|
||||
```
|
||||
|
||||
Dev server endpoints:
|
||||
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
||||
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
|
||||
116
packages/plugins/sdk/package.json
Normal file
116
packages/plugins/sdk/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "Stable public API for Paperclip plugins — worker-side context and UI bridge hooks",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./dist/protocol.d.ts",
|
||||
"import": "./dist/protocol.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./ui/hooks": {
|
||||
"types": "./dist/ui/hooks.d.ts",
|
||||
"import": "./dist/ui/hooks.js"
|
||||
},
|
||||
"./ui/types": {
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
},
|
||||
"./bundlers": {
|
||||
"types": "./dist/bundlers.d.ts",
|
||||
"import": "./dist/bundlers.js"
|
||||
},
|
||||
"./dev-server": {
|
||||
"types": "./dist/dev-server.d.ts",
|
||||
"import": "./dist/dev-server.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"paperclip-plugin-dev-server": "./dist/dev-cli.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./dist/protocol.d.ts",
|
||||
"import": "./dist/protocol.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./ui/hooks": {
|
||||
"types": "./dist/ui/hooks.d.ts",
|
||||
"import": "./dist/ui/hooks.js"
|
||||
},
|
||||
"./ui/types": {
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
},
|
||||
"./bundlers": {
|
||||
"types": "./dist/bundlers.d.ts",
|
||||
"import": "./dist/bundlers.js"
|
||||
},
|
||||
"./dev-server": {
|
||||
"types": "./dist/dev-server.d.ts",
|
||||
"import": "./dist/dev-server.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm --filter @paperclipai/shared build && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit",
|
||||
"dev:server": "tsx src/dev-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/plugins/sdk/src/bundlers.ts
Normal file
160
packages/plugins/sdk/src/bundlers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bundling presets for Paperclip plugins.
|
||||
*
|
||||
* These helpers return plain config objects so plugin authors can use them
|
||||
* with esbuild or rollup without re-implementing host contract defaults.
|
||||
*/
|
||||
|
||||
export interface PluginBundlerPresetInput {
|
||||
pluginRoot?: string;
|
||||
manifestEntry?: string;
|
||||
workerEntry?: string;
|
||||
uiEntry?: string;
|
||||
outdir?: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
}
|
||||
|
||||
export interface EsbuildLikeOptions {
|
||||
entryPoints: string[];
|
||||
outdir: string;
|
||||
bundle: boolean;
|
||||
format: "esm";
|
||||
platform: "node" | "browser";
|
||||
target: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
external?: string[];
|
||||
}
|
||||
|
||||
export interface RollupLikeConfig {
|
||||
input: string;
|
||||
output: {
|
||||
dir: string;
|
||||
format: "es";
|
||||
sourcemap?: boolean;
|
||||
entryFileNames?: string;
|
||||
};
|
||||
external?: string[];
|
||||
plugins?: unknown[];
|
||||
}
|
||||
|
||||
export interface PluginBundlerPresets {
|
||||
esbuild: {
|
||||
worker: EsbuildLikeOptions;
|
||||
ui?: EsbuildLikeOptions;
|
||||
manifest: EsbuildLikeOptions;
|
||||
};
|
||||
rollup: {
|
||||
worker: RollupLikeConfig;
|
||||
ui?: RollupLikeConfig;
|
||||
manifest: RollupLikeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
|
||||
*
|
||||
* The presets intentionally externalize host/runtime deps (`react`, SDK packages)
|
||||
* to match the Paperclip plugin loader contract.
|
||||
*/
|
||||
export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
|
||||
const uiExternal = [
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
"@paperclipai/plugin-sdk/ui/hooks",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
];
|
||||
|
||||
const outdir = input.outdir ?? "dist";
|
||||
const workerEntry = input.workerEntry ?? "src/worker.ts";
|
||||
const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
|
||||
const uiEntry = input.uiEntry;
|
||||
const sourcemap = input.sourcemap ?? true;
|
||||
const minify = input.minify ?? false;
|
||||
|
||||
const esbuildWorker: EsbuildLikeOptions = {
|
||||
entryPoints: [workerEntry],
|
||||
outdir,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const esbuildManifest: EsbuildLikeOptions = {
|
||||
entryPoints: [manifestEntry],
|
||||
outdir,
|
||||
bundle: false,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
};
|
||||
|
||||
const esbuildUi = uiEntry
|
||||
? {
|
||||
entryPoints: [uiEntry],
|
||||
outdir: `${outdir}/ui`,
|
||||
bundle: true,
|
||||
format: "esm" as const,
|
||||
platform: "browser" as const,
|
||||
target: "es2022",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const rollupWorker: RollupLikeConfig = {
|
||||
input: workerEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "worker.js",
|
||||
},
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const rollupManifest: RollupLikeConfig = {
|
||||
input: manifestEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "manifest.js",
|
||||
},
|
||||
external: ["@paperclipai/plugin-sdk"],
|
||||
};
|
||||
|
||||
const rollupUi = uiEntry
|
||||
? {
|
||||
input: uiEntry,
|
||||
output: {
|
||||
dir: `${outdir}/ui`,
|
||||
format: "es" as const,
|
||||
sourcemap,
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
esbuild: {
|
||||
worker: esbuildWorker,
|
||||
manifest: esbuildManifest,
|
||||
...(esbuildUi ? { ui: esbuildUi } : {}),
|
||||
},
|
||||
rollup: {
|
||||
worker: rollupWorker,
|
||||
manifest: rollupManifest,
|
||||
...(rollupUi ? { ui: rollupUi } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
|
||||
*
|
||||
* Plugin authors call `definePlugin()` and export the result as the default
|
||||
* export from their worker entrypoint. The host imports the worker module,
|
||||
* calls `setup()` with a `PluginContext`, and from that point the plugin
|
||||
* responds to events, jobs, webhooks, and UI requests through the context.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // dist/worker.ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Linear sync plugin starting");
|
||||
*
|
||||
* // Subscribe to events
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* const config = await ctx.config.get();
|
||||
* await ctx.http.fetch(`https://api.linear.app/...`, {
|
||||
* method: "POST",
|
||||
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
|
||||
* body: JSON.stringify({ title: event.payload.title }),
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* // Register a job handler
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Running full-sync job", { runId: job.runId });
|
||||
* // ... sync logic
|
||||
* });
|
||||
*
|
||||
* // Register data for the UI
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { PluginContext } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
export interface PluginHealthDiagnostics {
|
||||
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
|
||||
status: "ok" | "degraded" | "error";
|
||||
/** Human-readable description of the current health state. */
|
||||
message?: string;
|
||||
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config validation result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result returned from the `validateConfig()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
export interface PluginConfigValidationResult {
|
||||
/** Whether the config is valid. */
|
||||
ok: boolean;
|
||||
/** Non-fatal warnings about the config. */
|
||||
warnings?: string[];
|
||||
/** Validation errors (populated when `ok` is `false`). */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook handler input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input received by the plugin worker's `handleWebhook` handler.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
export interface PluginWebhookInput {
|
||||
/** Endpoint key matching the manifest declaration. */
|
||||
endpointKey: string;
|
||||
/** Inbound request headers. */
|
||||
headers: Record<string, string | string[]>;
|
||||
/** Raw request body as a UTF-8 string. */
|
||||
rawBody: string;
|
||||
/** Parsed JSON body (if applicable and parseable). */
|
||||
parsedBody?: unknown;
|
||||
/** Unique request identifier for idempotency checks. */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The plugin definition shape passed to `definePlugin()`.
|
||||
*
|
||||
* The only required field is `setup`, which receives the `PluginContext` and
|
||||
* is where the plugin registers its handlers (events, jobs, data, actions,
|
||||
* tools, etc.).
|
||||
*
|
||||
* All other lifecycle hooks are optional. If a hook is not implemented the
|
||||
* host applies default behaviour (e.g. restarting the worker on config change
|
||||
* instead of calling `onConfigChanged`).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
*/
|
||||
export interface PluginDefinition {
|
||||
/**
|
||||
* Called once when the plugin worker starts up, after `initialize` completes.
|
||||
*
|
||||
* This is where the plugin registers all its handlers: event subscriptions,
|
||||
* job handlers, data/action handlers, and tool registrations. Registration
|
||||
* must be synchronous after `setup` resolves — do not register handlers
|
||||
* inside async callbacks that may resolve after `setup` returns.
|
||||
*
|
||||
* @param ctx - The full plugin context provided by the host
|
||||
*/
|
||||
setup(ctx: PluginContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host wants to know if the plugin is healthy.
|
||||
*
|
||||
* The host polls this on a regular interval and surfaces the result in the
|
||||
* plugin health dashboard. If not implemented, the host infers health from
|
||||
* worker process liveness.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
onHealth?(): Promise<PluginHealthDiagnostics>;
|
||||
|
||||
/**
|
||||
* Called when the operator updates the plugin's instance configuration at
|
||||
* runtime, without restarting the worker.
|
||||
*
|
||||
* If not implemented, the host restarts the worker to apply the new config.
|
||||
*
|
||||
* @param newConfig - The newly resolved configuration
|
||||
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||||
*/
|
||||
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host is about to shut down the plugin worker.
|
||||
*
|
||||
* The worker has at most 10 seconds (configurable via plugin config) to
|
||||
* finish in-flight work and resolve this promise. After the deadline the
|
||||
* host sends SIGTERM, then SIGKILL.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||
*/
|
||||
onShutdown?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called to validate the current plugin configuration.
|
||||
*
|
||||
* The host calls this:
|
||||
* - after the plugin starts (to surface config errors immediately)
|
||||
* - after the operator saves a new config (to validate before persisting)
|
||||
* - via the "Test Connection" button in the settings UI
|
||||
*
|
||||
* @param config - The configuration to validate
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
|
||||
|
||||
/**
|
||||
* Called to handle an inbound webhook delivery.
|
||||
*
|
||||
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
|
||||
* this handler. The plugin is responsible for signature verification using
|
||||
* a resolved secret ref.
|
||||
*
|
||||
* If not implemented but webhooks are declared in the manifest, the host
|
||||
* returns HTTP 501 for webhook deliveries.
|
||||
*
|
||||
* @param input - Webhook delivery metadata and payload
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
onWebhook?(input: PluginWebhookInput): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaperclipPlugin — the sealed object returned by definePlugin()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The sealed plugin object returned by `definePlugin()`.
|
||||
*
|
||||
* Plugin authors export this as the default export from their worker
|
||||
* entrypoint. The host imports it and calls the lifecycle methods.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
*/
|
||||
export interface PaperclipPlugin {
|
||||
/** The original plugin definition passed to `definePlugin()`. */
|
||||
readonly definition: PluginDefinition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// definePlugin — top-level factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Define a Paperclip plugin.
|
||||
*
|
||||
* Call this function in your worker entrypoint and export the result as the
|
||||
* default export. The host will import the module and call lifecycle methods
|
||||
* on the returned object.
|
||||
*
|
||||
* @param definition - Plugin lifecycle handlers
|
||||
* @returns A sealed `PaperclipPlugin` object for the host to consume
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin started");
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* // handle event
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*/
|
||||
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
|
||||
return Object.freeze({ definition });
|
||||
}
|
||||
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { startPluginDevServer } from "./dev-server.js";
|
||||
|
||||
function parseArg(flag: string): string | undefined {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index < 0) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI entrypoint for the local plugin UI preview server.
|
||||
*
|
||||
* This is intentionally minimal and delegates all serving behavior to
|
||||
* `startPluginDevServer` so tests and programmatic usage share one path.
|
||||
*/
|
||||
async function main() {
|
||||
const rootDir = parseArg("--root") ?? process.cwd();
|
||||
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
|
||||
const host = parseArg("--host") ?? "127.0.0.1";
|
||||
const rawPort = parseArg("--port") ?? "4177";
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid --port value: ${rawPort}`);
|
||||
}
|
||||
|
||||
const server = await startPluginDevServer({
|
||||
rootDir: path.resolve(rootDir),
|
||||
uiDir,
|
||||
host,
|
||||
port,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Paperclip plugin dev server listening at ${server.url}`);
|
||||
|
||||
const shutdown = async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
228
packages/plugins/sdk/src/dev-server.ts
Normal file
228
packages/plugins/sdk/src/dev-server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { createReadStream, existsSync, statSync, watch } from "node:fs";
|
||||
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PluginDevServerOptions {
|
||||
/** Plugin project root. Defaults to `process.cwd()`. */
|
||||
rootDir?: string;
|
||||
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
|
||||
uiDir?: string;
|
||||
/** Bind port for local preview server. Defaults to `4177`. */
|
||||
port?: number;
|
||||
/** Bind host. Defaults to `127.0.0.1`. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PluginDevServer {
|
||||
url: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface Closeable {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (filePath.endsWith(".svg")) return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeFilePath(baseDir: string, reqPath: string): string {
|
||||
const pathname = reqPath.split("?")[0] || "/";
|
||||
const resolved = pathname === "/" ? "/index.js" : pathname;
|
||||
const absolute = path.resolve(baseDir, `.${resolved}`);
|
||||
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
|
||||
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
|
||||
throw new Error("path traversal blocked");
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function send404(res: ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, value: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function ensureUiDir(uiDir: string): Promise<void> {
|
||||
if (existsSync(uiDir)) return;
|
||||
await mkdir(uiDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function listFilesRecursive(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await listFilesRecursive(abs));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(abs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
|
||||
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
|
||||
}
|
||||
|
||||
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
|
||||
try {
|
||||
// macOS/Windows support recursive native watching.
|
||||
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
|
||||
if (!filename) return;
|
||||
onReload(path.join(uiDir, filename));
|
||||
});
|
||||
return watcher;
|
||||
} catch {
|
||||
// Linux may reject recursive watch. Fall back to polling snapshots.
|
||||
let previous = snapshotSignature(
|
||||
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
|
||||
file: row.file,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
);
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
|
||||
const next = snapshotSignature(nextRows);
|
||||
if (next === previous) return;
|
||||
previous = next;
|
||||
onReload("__snapshot__");
|
||||
} catch {
|
||||
// Ignore transient read errors while bundlers are writing files.
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
close() {
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local static server for plugin UI assets with SSE reload events.
|
||||
*
|
||||
* Endpoint summary:
|
||||
* - `GET /__paperclip__/health` for diagnostics
|
||||
* - `GET /__paperclip__/events` for hot-reload stream
|
||||
* - Any other path serves files from the configured UI build directory
|
||||
*/
|
||||
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
|
||||
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const port = options.port ?? 4177;
|
||||
|
||||
await ensureUiDir(uiDir);
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url ?? "/";
|
||||
|
||||
if (url === "/__paperclip__/health") {
|
||||
sendJson(res, { ok: true, rootDir, uiDir });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === "/__paperclip__/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(`event: connected\ndata: {"ok":true}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on("close", () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = normalizeFilePath(uiDir, url);
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
send404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType(filePath));
|
||||
createReadStream(filePath).pipe(res);
|
||||
} catch {
|
||||
send404(res);
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
|
||||
const notifyReload = (filePath: string) => {
|
||||
const rel = path.relative(uiDir, filePath);
|
||||
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: reload\ndata: ${payload}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const watcher = await startUiWatcher(uiDir, notifyReload);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
|
||||
|
||||
return {
|
||||
url: `http://${host}:${actualPort}`,
|
||||
async close() {
|
||||
watcher.close();
|
||||
for (const client of sseClients) {
|
||||
client.end();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable file+mtime snapshot for a built plugin UI directory.
|
||||
*
|
||||
* Used by the polling watcher fallback and useful for tests that need to assert
|
||||
* whether a UI build has changed between runs.
|
||||
*/
|
||||
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
|
||||
const baseDir = path.resolve(rootDir, uiDir);
|
||||
if (!existsSync(baseDir)) return [];
|
||||
const files = await listFilesRecursive(baseDir);
|
||||
const rows = await Promise.all(files.map(async (filePath) => {
|
||||
const fileStat = await stat(filePath);
|
||||
return {
|
||||
file: path.relative(baseDir, filePath),
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
};
|
||||
}));
|
||||
return rows.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Host-side client factory — creates capability-gated handler maps for
|
||||
* servicing worker→host JSON-RPC calls.
|
||||
*
|
||||
* When a plugin worker calls `ctx.state.get(...)` inside its process, the
|
||||
* SDK serializes the call as a JSON-RPC request over stdio. On the host side,
|
||||
* the `PluginWorkerManager` receives the request and dispatches it to the
|
||||
* handler registered for that method. This module provides a factory that
|
||||
* creates those handlers for all `WorkerToHostMethods`, with automatic
|
||||
* capability enforcement.
|
||||
*
|
||||
* ## Design
|
||||
*
|
||||
* 1. **Capability gating**: Each handler checks the plugin's declared
|
||||
* capabilities before executing. If the plugin lacks a required capability,
|
||||
* the handler throws a `CapabilityDeniedError` (which the worker manager
|
||||
* translates into a JSON-RPC error response with code
|
||||
* `CAPABILITY_DENIED`).
|
||||
*
|
||||
* 2. **Service adapters**: The caller provides a `HostServices` object with
|
||||
* concrete implementations of each platform service. The factory wires
|
||||
* each handler to the appropriate service method.
|
||||
*
|
||||
* 3. **Type safety**: The returned handler map is typed as
|
||||
* `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
|
||||
* directly into `WorkerStartOptions.hostHandlers`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const handlers = createHostClientHandlers({
|
||||
* pluginId: "acme.linear",
|
||||
* capabilities: manifest.capabilities,
|
||||
* services: {
|
||||
* config: { get: () => registry.getConfig(pluginId) },
|
||||
* state: { get: ..., set: ..., delete: ... },
|
||||
* entities: { upsert: ..., list: ... },
|
||||
* // ... all services
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* await workerManager.startWorker("acme.linear", {
|
||||
* // ...
|
||||
* hostHandlers: handlers,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown when a plugin calls a host method it does not have the capability for.
|
||||
*
|
||||
* The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
|
||||
* the worker manager can propagate it as the correct JSON-RPC error code.
|
||||
*/
|
||||
export class CapabilityDeniedError extends Error {
|
||||
override readonly name = "CapabilityDeniedError";
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, capability: PluginCapability) {
|
||||
super(
|
||||
`Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Service adapters that the host must provide. Each property maps to a group
|
||||
* of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
|
||||
* function signatures.
|
||||
*
|
||||
* All methods return promises to support async I/O (database, HTTP, etc.).
|
||||
*/
|
||||
export interface HostServices {
|
||||
/** Provides `config.get`. */
|
||||
config: {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||
state: {
|
||||
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
|
||||
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `entities.upsert`, `entities.list`. */
|
||||
entities: {
|
||||
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
|
||||
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `events.emit`. */
|
||||
events: {
|
||||
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `http.fetch`. */
|
||||
http: {
|
||||
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `secrets.resolve`. */
|
||||
secrets: {
|
||||
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
||||
};
|
||||
|
||||
/** Provides `activity.log`. */
|
||||
activity: {
|
||||
log(params: {
|
||||
companyId: string;
|
||||
message: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `metrics.write`. */
|
||||
metrics: {
|
||||
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `log`. */
|
||||
logger: {
|
||||
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `companies.list`, `companies.get`. */
|
||||
companies: {
|
||||
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
|
||||
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
|
||||
projects: {
|
||||
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
|
||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
|
||||
issues: {
|
||||
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
||||
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
||||
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
||||
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
|
||||
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
|
||||
agents: {
|
||||
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
|
||||
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
|
||||
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||
agentSessions: {
|
||||
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
|
||||
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
|
||||
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
|
||||
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
|
||||
goals: {
|
||||
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
|
||||
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
|
||||
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
|
||||
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for `createHostClientHandlers`.
|
||||
*/
|
||||
export interface HostClientFactoryOptions {
|
||||
/** The plugin ID. Used for error messages and logging. */
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The capabilities declared by the plugin in its manifest. The factory
|
||||
* enforces these at runtime before delegating to the service adapter.
|
||||
*/
|
||||
capabilities: readonly PluginCapability[];
|
||||
|
||||
/**
|
||||
* Concrete implementations of host platform services. Each handler in the
|
||||
* returned map delegates to the corresponding service method.
|
||||
*/
|
||||
services: HostServices;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A handler function for a specific worker→host method.
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
* A complete map of all worker→host method handlers.
|
||||
*
|
||||
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
|
||||
* but makes every handler required (the factory always provides all handlers).
|
||||
*/
|
||||
export type HostClientHandlers = {
|
||||
[M in WorkerToHostMethodName]: HostHandler<M>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability → method mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps each worker→host RPC method to the capability required to invoke it.
|
||||
* Methods without a capability requirement (e.g. `config.get`, `log`) are
|
||||
* mapped to `null`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
|
||||
// Config — always allowed
|
||||
"config.get": null,
|
||||
|
||||
// State
|
||||
"state.get": "plugin.state.read",
|
||||
"state.set": "plugin.state.write",
|
||||
"state.delete": "plugin.state.write",
|
||||
|
||||
// Entities — no specific capability required (plugin-scoped by design)
|
||||
"entities.upsert": null,
|
||||
"entities.list": null,
|
||||
|
||||
// Events
|
||||
"events.emit": "events.emit",
|
||||
|
||||
// HTTP
|
||||
"http.fetch": "http.outbound",
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": "secrets.read-ref",
|
||||
|
||||
// Activity
|
||||
"activity.log": "activity.log.write",
|
||||
|
||||
// Metrics
|
||||
"metrics.write": "metrics.write",
|
||||
|
||||
// Logger — always allowed
|
||||
"log": null,
|
||||
|
||||
// Companies
|
||||
"companies.list": "companies.read",
|
||||
"companies.get": "companies.read",
|
||||
|
||||
// Projects
|
||||
"projects.list": "projects.read",
|
||||
"projects.get": "projects.read",
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
|
||||
// Issues
|
||||
"issues.list": "issues.read",
|
||||
"issues.get": "issues.read",
|
||||
"issues.create": "issues.create",
|
||||
"issues.update": "issues.update",
|
||||
"issues.listComments": "issue.comments.read",
|
||||
"issues.createComment": "issue.comments.create",
|
||||
|
||||
// Agents
|
||||
"agents.list": "agents.read",
|
||||
"agents.get": "agents.read",
|
||||
"agents.pause": "agents.pause",
|
||||
"agents.resume": "agents.resume",
|
||||
"agents.invoke": "agents.invoke",
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": "agent.sessions.create",
|
||||
"agents.sessions.list": "agent.sessions.list",
|
||||
"agents.sessions.sendMessage": "agent.sessions.send",
|
||||
"agents.sessions.close": "agent.sessions.close",
|
||||
|
||||
// Goals
|
||||
"goals.list": "goals.read",
|
||||
"goals.get": "goals.read",
|
||||
"goals.create": "goals.create",
|
||||
"goals.update": "goals.update",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a complete handler map for all worker→host JSON-RPC methods.
|
||||
*
|
||||
* Each handler:
|
||||
* 1. Checks the plugin's declared capabilities against the required capability
|
||||
* for the method (if any).
|
||||
* 2. Delegates to the corresponding service adapter method.
|
||||
* 3. Returns the service result, which is serialized as the JSON-RPC response
|
||||
* by the worker manager.
|
||||
*
|
||||
* If a capability check fails, the handler throws a `CapabilityDeniedError`
|
||||
* with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
|
||||
* JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
|
||||
* in the plugin's SDK client.
|
||||
*
|
||||
* @param options - Plugin ID, capabilities, and service adapters
|
||||
* @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
|
||||
*/
|
||||
export function createHostClientHandlers(
|
||||
options: HostClientFactoryOptions,
|
||||
): HostClientHandlers {
|
||||
const { pluginId, services } = options;
|
||||
const capabilitySet = new Set<PluginCapability>(options.capabilities);
|
||||
|
||||
/**
|
||||
* Assert that the plugin has the required capability for a method.
|
||||
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||
*/
|
||||
function requireCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): void {
|
||||
const required = METHOD_CAPABILITY_MAP[method];
|
||||
if (required === null) return; // No capability required
|
||||
if (capabilitySet.has(required)) return;
|
||||
throw new CapabilityDeniedError(pluginId, method, required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability-gated proxy handler for a method.
|
||||
*
|
||||
* @param method - The RPC method name (used for capability lookup)
|
||||
* @param handler - The actual handler implementation
|
||||
* @returns A wrapper that checks capabilities before delegating
|
||||
*/
|
||||
function gated<M extends WorkerToHostMethodName>(
|
||||
method: M,
|
||||
handler: HostHandler<M>,
|
||||
): HostHandler<M> {
|
||||
return async (params: WorkerToHostMethods[M][0]) => {
|
||||
requireCapability(method);
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Build the complete handler map
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// Config
|
||||
"config.get": gated("config.get", async () => {
|
||||
return services.config.get();
|
||||
}),
|
||||
|
||||
// State
|
||||
"state.get": gated("state.get", async (params) => {
|
||||
return services.state.get(params);
|
||||
}),
|
||||
"state.set": gated("state.set", async (params) => {
|
||||
return services.state.set(params);
|
||||
}),
|
||||
"state.delete": gated("state.delete", async (params) => {
|
||||
return services.state.delete(params);
|
||||
}),
|
||||
|
||||
// Entities
|
||||
"entities.upsert": gated("entities.upsert", async (params) => {
|
||||
return services.entities.upsert(params);
|
||||
}),
|
||||
"entities.list": gated("entities.list", async (params) => {
|
||||
return services.entities.list(params);
|
||||
}),
|
||||
|
||||
// Events
|
||||
"events.emit": gated("events.emit", async (params) => {
|
||||
return services.events.emit(params);
|
||||
}),
|
||||
|
||||
// HTTP
|
||||
"http.fetch": gated("http.fetch", async (params) => {
|
||||
return services.http.fetch(params);
|
||||
}),
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": gated("secrets.resolve", async (params) => {
|
||||
return services.secrets.resolve(params);
|
||||
}),
|
||||
|
||||
// Activity
|
||||
"activity.log": gated("activity.log", async (params) => {
|
||||
return services.activity.log(params);
|
||||
}),
|
||||
|
||||
// Metrics
|
||||
"metrics.write": gated("metrics.write", async (params) => {
|
||||
return services.metrics.write(params);
|
||||
}),
|
||||
|
||||
// Logger
|
||||
"log": gated("log", async (params) => {
|
||||
return services.logger.log(params);
|
||||
}),
|
||||
|
||||
// Companies
|
||||
"companies.list": gated("companies.list", async (params) => {
|
||||
return services.companies.list(params);
|
||||
}),
|
||||
"companies.get": gated("companies.get", async (params) => {
|
||||
return services.companies.get(params);
|
||||
}),
|
||||
|
||||
// Projects
|
||||
"projects.list": gated("projects.list", async (params) => {
|
||||
return services.projects.list(params);
|
||||
}),
|
||||
"projects.get": gated("projects.get", async (params) => {
|
||||
return services.projects.get(params);
|
||||
}),
|
||||
"projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
|
||||
return services.projects.listWorkspaces(params);
|
||||
}),
|
||||
"projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
|
||||
return services.projects.getPrimaryWorkspace(params);
|
||||
}),
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
|
||||
// Issues
|
||||
"issues.list": gated("issues.list", async (params) => {
|
||||
return services.issues.list(params);
|
||||
}),
|
||||
"issues.get": gated("issues.get", async (params) => {
|
||||
return services.issues.get(params);
|
||||
}),
|
||||
"issues.create": gated("issues.create", async (params) => {
|
||||
return services.issues.create(params);
|
||||
}),
|
||||
"issues.update": gated("issues.update", async (params) => {
|
||||
return services.issues.update(params);
|
||||
}),
|
||||
"issues.listComments": gated("issues.listComments", async (params) => {
|
||||
return services.issues.listComments(params);
|
||||
}),
|
||||
"issues.createComment": gated("issues.createComment", async (params) => {
|
||||
return services.issues.createComment(params);
|
||||
}),
|
||||
|
||||
// Agents
|
||||
"agents.list": gated("agents.list", async (params) => {
|
||||
return services.agents.list(params);
|
||||
}),
|
||||
"agents.get": gated("agents.get", async (params) => {
|
||||
return services.agents.get(params);
|
||||
}),
|
||||
"agents.pause": gated("agents.pause", async (params) => {
|
||||
return services.agents.pause(params);
|
||||
}),
|
||||
"agents.resume": gated("agents.resume", async (params) => {
|
||||
return services.agents.resume(params);
|
||||
}),
|
||||
"agents.invoke": gated("agents.invoke", async (params) => {
|
||||
return services.agents.invoke(params);
|
||||
}),
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
||||
return services.agentSessions.create(params);
|
||||
}),
|
||||
"agents.sessions.list": gated("agents.sessions.list", async (params) => {
|
||||
return services.agentSessions.list(params);
|
||||
}),
|
||||
"agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
|
||||
return services.agentSessions.sendMessage(params);
|
||||
}),
|
||||
"agents.sessions.close": gated("agents.sessions.close", async (params) => {
|
||||
return services.agentSessions.close(params);
|
||||
}),
|
||||
|
||||
// Goals
|
||||
"goals.list": gated("goals.list", async (params) => {
|
||||
return services.goals.list(params);
|
||||
}),
|
||||
"goals.get": gated("goals.get", async (params) => {
|
||||
return services.goals.get(params);
|
||||
}),
|
||||
"goals.create": gated("goals.create", async (params) => {
|
||||
return services.goals.create(params);
|
||||
}),
|
||||
"goals.update": gated("goals.update", async (params) => {
|
||||
return services.goals.update(params);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: getRequiredCapability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the capability required for a given worker→host method, or `null` if
|
||||
* no capability is required.
|
||||
*
|
||||
* Useful for inspecting capability requirements without calling the factory.
|
||||
*
|
||||
* @param method - The worker→host method name
|
||||
* @returns The required capability, or `null`
|
||||
*/
|
||||
export function getRequiredCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): PluginCapability | null {
|
||||
return METHOD_CAPABILITY_MAP[method];
|
||||
}
|
||||
286
packages/plugins/sdk/src/index.ts
Normal file
286
packages/plugins/sdk/src/index.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
|
||||
*
|
||||
* This is the main entrypoint for plugin worker code. For plugin UI bundles,
|
||||
* import from `@paperclipai/plugin-sdk/ui` instead.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Plugin worker entrypoint (dist/worker.ts)
|
||||
* import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const plugin = definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin starting up");
|
||||
*
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* ctx.logger.info("Issue created", { issueId: event.entityId });
|
||||
* });
|
||||
*
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Starting full sync", { runId: job.runId });
|
||||
* // ... sync implementation
|
||||
* });
|
||||
*
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync-at",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* export default plugin;
|
||||
* runWorker(plugin, import.meta.url);
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { definePlugin } from "./define-plugin.js";
|
||||
export { createTestHarness } from "./testing.js";
|
||||
export { createPluginBundlerPresets } from "./bundlers.js";
|
||||
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
|
||||
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
|
||||
export {
|
||||
createHostClientHandlers,
|
||||
getRequiredCapability,
|
||||
CapabilityDeniedError,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol helpers and constants
|
||||
export {
|
||||
JSONRPC_VERSION,
|
||||
JSONRPC_ERROR_CODES,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
HOST_TO_WORKER_REQUIRED_METHODS,
|
||||
HOST_TO_WORKER_OPTIONAL_METHODS,
|
||||
MESSAGE_DELIMITER,
|
||||
createRequest,
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
createNotification,
|
||||
isJsonRpcRequest,
|
||||
isJsonRpcNotification,
|
||||
isJsonRpcResponse,
|
||||
isJsonRpcSuccessResponse,
|
||||
isJsonRpcErrorResponse,
|
||||
serializeMessage,
|
||||
parseMessage,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
_resetIdCounter,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Plugin definition and lifecycle types
|
||||
export type {
|
||||
PluginDefinition,
|
||||
PaperclipPlugin,
|
||||
PluginHealthDiagnostics,
|
||||
PluginConfigValidationResult,
|
||||
PluginWebhookInput,
|
||||
} from "./define-plugin.js";
|
||||
export type {
|
||||
TestHarness,
|
||||
TestHarnessOptions,
|
||||
TestHarnessLogEntry,
|
||||
} from "./testing.js";
|
||||
export type {
|
||||
PluginBundlerPresetInput,
|
||||
PluginBundlerPresets,
|
||||
EsbuildLikeOptions,
|
||||
RollupLikeConfig,
|
||||
} from "./bundlers.js";
|
||||
export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
|
||||
export type {
|
||||
WorkerRpcHostOptions,
|
||||
WorkerRpcHost,
|
||||
RunWorkerOptions,
|
||||
} from "./worker-rpc-host.js";
|
||||
export type {
|
||||
HostServices,
|
||||
HostClientFactoryOptions,
|
||||
HostClientHandlers,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
JsonRpcErrorResponse,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
ValidateConfigParams,
|
||||
OnEventParams,
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
ExecuteToolParams,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
HostToWorkerMethods,
|
||||
HostToWorkerMethodName,
|
||||
WorkerToHostMethods,
|
||||
WorkerToHostMethodName,
|
||||
HostToWorkerRequest,
|
||||
HostToWorkerResponse,
|
||||
WorkerToHostRequest,
|
||||
WorkerToHostResponse,
|
||||
WorkerToHostNotifications,
|
||||
WorkerToHostNotificationName,
|
||||
} from "./protocol.js";
|
||||
|
||||
// Plugin context and all client interfaces
|
||||
export type {
|
||||
PluginContext,
|
||||
PluginConfigClient,
|
||||
PluginEventsClient,
|
||||
PluginJobsClient,
|
||||
PluginLaunchersClient,
|
||||
PluginHttpClient,
|
||||
PluginSecretsClient,
|
||||
PluginActivityClient,
|
||||
PluginActivityLogEntry,
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
AgentSessionSendResult,
|
||||
PluginGoalsClient,
|
||||
PluginDataClient,
|
||||
PluginActionsClient,
|
||||
PluginStreamsClient,
|
||||
PluginToolsClient,
|
||||
PluginMetricsClient,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// Supporting types for context clients
|
||||
export type {
|
||||
ScopeKey,
|
||||
EventFilter,
|
||||
PluginEvent,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginEntityUpsert,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
// Plugin authors import manifest types from here so they have a single
|
||||
// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
|
||||
export type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginRecord,
|
||||
PluginConfig,
|
||||
JsonSchema,
|
||||
PluginStatus,
|
||||
PluginCategory,
|
||||
PluginCapability,
|
||||
PluginUiSlotType,
|
||||
PluginUiSlotEntityType,
|
||||
PluginLauncherPlacementZone,
|
||||
PluginLauncherAction,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginStateScopeKind,
|
||||
PluginJobStatus,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginWebhookDeliveryStatus,
|
||||
PluginEventType,
|
||||
PluginBridgeErrorCode,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod re-export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod is re-exported for plugin authors to use when defining their
|
||||
* `instanceConfigSchema` and tool `parametersSchema`.
|
||||
*
|
||||
* Plugin authors do not need to add a separate `zod` dependency.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const configSchema = z.object({
|
||||
* apiKey: z.string().describe("Your API key"),
|
||||
* workspace: z.string().optional(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants re-exports (for plugin code that needs to check values at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
PLUGIN_JOB_RUN_TRIGGERS,
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
} from "@paperclipai/shared";
|
||||
1028
packages/plugins/sdk/src/protocol.ts
Normal file
1028
packages/plugins/sdk/src/protocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
705
packages/plugins/sdk/src/testing.ts
Normal file
705
packages/plugins/sdk/src/testing.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginCapability,
|
||||
PluginEventType,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
EventFilter,
|
||||
PluginContext,
|
||||
PluginEntityRecord,
|
||||
PluginEntityUpsert,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
PluginEvent,
|
||||
ScopeKey,
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
||||
capabilities?: PluginCapability[];
|
||||
/** Initial config returned by `ctx.config.get()`. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessLogEntry {
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
issues?: Issue[];
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
||||
/** Execute a previously-registered scheduled job handler. */
|
||||
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
getState(input: ScopeKey): unknown;
|
||||
/** Simulate a streaming event arriving for an active session. */
|
||||
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
||||
logs: TestHarnessLogEntry[];
|
||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
name: PluginEventType | `plugin.${string}`;
|
||||
filter?: EventFilter;
|
||||
fn: (event: PluginEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
||||
return {
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId,
|
||||
namespace: input.namespace ?? "default",
|
||||
stateKey: input.stateKey,
|
||||
};
|
||||
}
|
||||
|
||||
function stateMapKey(input: ScopeKey): string {
|
||||
const normalized = normalizeScope(input);
|
||||
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
||||
}
|
||||
|
||||
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
||||
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
||||
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
||||
if (allowed.has(capability)) return;
|
||||
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
||||
}
|
||||
|
||||
function requireCompanyId(companyId?: string): string {
|
||||
if (!companyId) throw new Error("companyId is required for this operation");
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function isInCompany<T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
companyId: string,
|
||||
): record is T {
|
||||
return Boolean(record && record.companyId === companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory host harness for plugin worker tests.
|
||||
*
|
||||
* The harness enforces declared capabilities and simulates host APIs, so tests
|
||||
* can validate plugin behavior without spinning up the Paperclip server runtime.
|
||||
*/
|
||||
export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const manifest = options.manifest;
|
||||
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
||||
let currentConfig = { ...(options.config ?? {}) };
|
||||
|
||||
const logs: TestHarnessLogEntry[] = [];
|
||||
const activity: TestHarness["activity"] = [];
|
||||
const metrics: TestHarness["metrics"] = [];
|
||||
|
||||
const state = new Map<string, unknown>();
|
||||
const entities = new Map<string, PluginEntityRecord>();
|
||||
const entityExternalIndex = new Map<string, string>();
|
||||
const companies = new Map<string, Company>();
|
||||
const projects = new Map<string, Project>();
|
||||
const issues = new Map<string, Issue>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||
|
||||
const events: EventRegistration[] = [];
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
manifest,
|
||||
config: {
|
||||
async get() {
|
||||
return { ...currentConfig };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
let registration: EventRegistration;
|
||||
if (typeof filterOrFn === "function") {
|
||||
registration = { name, fn: filterOrFn };
|
||||
} else {
|
||||
if (!maybeFn) throw new Error("event handler is required");
|
||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||
}
|
||||
events.push(registration);
|
||||
return () => {
|
||||
const idx = events.indexOf(registration);
|
||||
if (idx !== -1) events.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
async emit(name, companyId, payload) {
|
||||
requireCapability(manifest, capabilitySet, "events.emit");
|
||||
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
register(key, fn) {
|
||||
requireCapability(manifest, capabilitySet, "jobs.schedule");
|
||||
jobs.set(key, fn);
|
||||
},
|
||||
},
|
||||
launchers: {
|
||||
register(launcher) {
|
||||
launchers.set(launcher.id, launcher);
|
||||
},
|
||||
},
|
||||
http: {
|
||||
async fetch(url, init) {
|
||||
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||
return fetch(url, init);
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
async resolve(secretRef) {
|
||||
requireCapability(manifest, capabilitySet, "secrets.read-ref");
|
||||
return `resolved:${secretRef}`;
|
||||
},
|
||||
},
|
||||
activity: {
|
||||
async log(entry) {
|
||||
requireCapability(manifest, capabilitySet, "activity.log.write");
|
||||
activity.push(entry);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
async get(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.read");
|
||||
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
|
||||
},
|
||||
async set(input, value) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.set(stateMapKey(input), value);
|
||||
},
|
||||
async delete(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.delete(stateMapKey(input));
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
async upsert(input: PluginEntityUpsert) {
|
||||
const externalKey = input.externalId
|
||||
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
|
||||
: null;
|
||||
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
|
||||
const existing = existingId ? entities.get(existingId) : undefined;
|
||||
const now = new Date().toISOString();
|
||||
const previousExternalKey = existing?.externalId
|
||||
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
|
||||
: null;
|
||||
const record: PluginEntityRecord = existing
|
||||
? {
|
||||
...existing,
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
id: randomUUID(),
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
if (previousExternalKey && previousExternalKey !== externalKey) {
|
||||
entityExternalIndex.delete(previousExternalKey);
|
||||
}
|
||||
if (externalKey) entityExternalIndex.set(externalKey, record.id);
|
||||
return record;
|
||||
},
|
||||
async list(query) {
|
||||
let out = [...entities.values()];
|
||||
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
|
||||
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
|
||||
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
|
||||
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
|
||||
if (query.offset) out = out.slice(query.offset);
|
||||
if (query.limit) out = out.slice(0, query.limit);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...projects.values()];
|
||||
out = out.filter((project) => project.companyId === companyId);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const project = projects.get(projectId);
|
||||
return isInCompany(project, companyId) ? project : null;
|
||||
},
|
||||
async listWorkspaces(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return [];
|
||||
return projectWorkspaces.get(projectId) ?? [];
|
||||
},
|
||||
async getPrimaryWorkspace(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
async getWorkspaceForIssue(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
const issue = issues.get(issueId);
|
||||
if (!isInCompany(issue, companyId)) return null;
|
||||
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
|
||||
if (!projectId) return null;
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
},
|
||||
companies: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
let out = [...companies.values()];
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
return companies.get(companyId) ?? null;
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...issues.values()];
|
||||
out = out.filter((issue) => issue.companyId === companyId);
|
||||
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const issue = issues.get(issueId);
|
||||
return isInCompany(issue, companyId) ? issue : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.create");
|
||||
const now = new Date();
|
||||
const record: Issue = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: "todo",
|
||||
priority: input.priority ?? "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
issues.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(issueId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.update");
|
||||
const record = issues.get(issueId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
||||
const updated: Issue = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
issues.set(issueId, updated);
|
||||
return updated;
|
||||
},
|
||||
async listComments(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return issueComments.get(issueId) ?? [];
|
||||
},
|
||||
async createComment(issueId, body, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
throw new Error(`Issue not found: ${issueId}`);
|
||||
}
|
||||
const now = new Date();
|
||||
const comment: IssueComment = {
|
||||
id: randomUUID(),
|
||||
companyId: parentIssue.companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const current = issueComments.get(issueId) ?? [];
|
||||
current.push(comment);
|
||||
issueComments.set(issueId, current);
|
||||
return comment;
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...agents.values()];
|
||||
out = out.filter((agent) => agent.companyId === companyId);
|
||||
if (input?.status) out = out.filter((agent) => agent.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const agent = agents.get(agentId);
|
||||
return isInCompany(agent, companyId) ? agent : null;
|
||||
},
|
||||
async pause(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.pause");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
|
||||
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async resume(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.resume");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
|
||||
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
|
||||
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async invoke(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agents.invoke");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (
|
||||
agent!.status === "paused" ||
|
||||
agent!.status === "terminated" ||
|
||||
agent!.status === "pending_approval"
|
||||
) {
|
||||
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
sessions: {
|
||||
async create(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
const session: AgentSession = {
|
||||
sessionId: randomUUID(),
|
||||
agentId,
|
||||
companyId: cid,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(session.sessionId, session);
|
||||
return session;
|
||||
},
|
||||
async list(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.list");
|
||||
const cid = requireCompanyId(companyId);
|
||||
return [...sessions.values()].filter(
|
||||
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
|
||||
);
|
||||
},
|
||||
async sendMessage(sessionId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.send");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (opts.onEvent) {
|
||||
sessionEventCallbacks.set(sessionId, opts.onEvent);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
async close(sessionId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.close");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
session.status = "closed";
|
||||
sessionEventCallbacks.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...goals.values()];
|
||||
out = out.filter((goal) => goal.companyId === companyId);
|
||||
if (input?.level) out = out.filter((goal) => goal.level === input.level);
|
||||
if (input?.status) out = out.filter((goal) => goal.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(goalId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const goal = goals.get(goalId);
|
||||
return isInCompany(goal, companyId) ? goal : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.create");
|
||||
const now = new Date();
|
||||
const record: Goal = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
level: input.level ?? "task",
|
||||
status: input.status ?? "planned",
|
||||
parentId: input.parentId ?? null,
|
||||
ownerAgentId: input.ownerAgentId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
goals.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(goalId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.update");
|
||||
const record = goals.get(goalId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
|
||||
const updated: Goal = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
goals.set(goalId, updated);
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
data: {
|
||||
register(key, handler) {
|
||||
dataHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
register(key, handler) {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
streams: (() => {
|
||||
const channelCompanyMap = new Map<string, string>();
|
||||
return {
|
||||
open(channel: string, companyId: string) {
|
||||
channelCompanyMap.set(channel, companyId);
|
||||
},
|
||||
emit(_channel: string, _event: unknown) {
|
||||
// No-op in test harness — events are not forwarded
|
||||
},
|
||||
close(channel: string) {
|
||||
channelCompanyMap.delete(channel);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
tools: {
|
||||
register(name, _decl, fn) {
|
||||
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||
toolHandlers.set(name, fn);
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
async write(name, value, tags) {
|
||||
requireCapability(manifest, capabilitySet, "metrics.write");
|
||||
metrics.push({ name, value, tags });
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info(message, meta) {
|
||||
logs.push({ level: "info", message, meta });
|
||||
},
|
||||
warn(message, meta) {
|
||||
logs.push({ level: "warn", message, meta });
|
||||
},
|
||||
error(message, meta) {
|
||||
logs.push({ level: "error", message, meta });
|
||||
},
|
||||
debug(message, meta) {
|
||||
logs.push({ level: "debug", message, meta });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const harness: TestHarness = {
|
||||
ctx,
|
||||
seed(input) {
|
||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||
for (const row of input.issues ?? []) issues.set(row.id, row);
|
||||
for (const row of input.issueComments ?? []) {
|
||||
const list = issueComments.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
issueComments.set(row.issueId, list);
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
},
|
||||
async emit(eventType, payload, base) {
|
||||
const event: PluginEvent = {
|
||||
eventId: base?.eventId ?? randomUUID(),
|
||||
eventType,
|
||||
companyId: base?.companyId ?? "test-company",
|
||||
occurredAt: base?.occurredAt ?? new Date().toISOString(),
|
||||
actorId: base?.actorId,
|
||||
actorType: base?.actorType,
|
||||
entityId: base?.entityId,
|
||||
entityType: base?.entityType,
|
||||
payload,
|
||||
};
|
||||
|
||||
for (const handler of events) {
|
||||
const exactMatch = handler.name === event.eventType;
|
||||
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
|
||||
const wildcardPluginOne = String(handler.name).endsWith(".*")
|
||||
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
|
||||
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
|
||||
if (!allowsEvent(handler.filter, event)) continue;
|
||||
await handler.fn(event);
|
||||
}
|
||||
},
|
||||
async runJob(jobKey, partial = {}) {
|
||||
const handler = jobs.get(jobKey);
|
||||
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
|
||||
await handler({
|
||||
jobKey,
|
||||
runId: partial.runId ?? randomUUID(),
|
||||
trigger: partial.trigger ?? "manual",
|
||||
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = dataHandlers.get(key);
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
||||
const ctxToPass: ToolRunContext = {
|
||||
agentId: runCtx.agentId ?? "agent-test",
|
||||
runId: runCtx.runId ?? randomUUID(),
|
||||
companyId: runCtx.companyId ?? "company-test",
|
||||
projectId: runCtx.projectId ?? "project-test",
|
||||
};
|
||||
return await handler(params, ctxToPass) as T;
|
||||
},
|
||||
getState(input) {
|
||||
return state.get(stateMapKey(input));
|
||||
},
|
||||
simulateSessionEvent(sessionId, event) {
|
||||
const cb = sessionEventCallbacks.get(sessionId);
|
||||
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
|
||||
cb({ ...event, sessionId });
|
||||
},
|
||||
logs,
|
||||
activity,
|
||||
metrics,
|
||||
};
|
||||
|
||||
return harness;
|
||||
}
|
||||
1085
packages/plugins/sdk/src/types.ts
Normal file
1085
packages/plugins/sdk/src/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
310
packages/plugins/sdk/src/ui/components.ts
Normal file
310
packages/plugins/sdk/src/ui/components.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Shared UI component declarations for plugin frontends.
|
||||
*
|
||||
* These components are exported from `@paperclipai/plugin-sdk/ui` and are
|
||||
* provided by the host at runtime. They match the host's design tokens and
|
||||
* visual language, reducing the boilerplate needed to build consistent plugin UIs.
|
||||
*
|
||||
* **Plugins are not required to use these components.** They exist to reduce
|
||||
* boilerplate and keep visual consistency. A plugin may render entirely custom
|
||||
* UI using any React component library.
|
||||
*
|
||||
* Component implementations are provided by the host — plugin bundles contain
|
||||
* only the type declarations; the runtime implementations are injected via the
|
||||
* host module registry.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { renderSdkUiComponent } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A trend value that can accompany a metric.
|
||||
* Positive values indicate upward trends; negative values indicate downward trends.
|
||||
*/
|
||||
export interface MetricTrend {
|
||||
/** Direction of the trend. */
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Percentage change value (e.g. `12.5` for 12.5%). */
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
/** Props for `MetricCard`. */
|
||||
export interface MetricCardProps {
|
||||
/** Short label describing the metric (e.g. `"Synced Issues"`). */
|
||||
label: string;
|
||||
/** The metric value to display. */
|
||||
value: number | string;
|
||||
/** Optional trend indicator. */
|
||||
trend?: MetricTrend;
|
||||
/** Optional sparkline data (array of numbers, latest last). */
|
||||
sparkline?: number[];
|
||||
/** Optional unit suffix (e.g. `"%"`, `"ms"`). */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** Status variants for `StatusBadge`. */
|
||||
export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
/** Props for `StatusBadge`. */
|
||||
export interface StatusBadgeProps {
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Visual variant determining colour. */
|
||||
status: StatusBadgeVariant;
|
||||
}
|
||||
|
||||
/** A single column definition for `DataTable`. */
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
/** Column key, matching a field on the row object. */
|
||||
key: keyof T & string;
|
||||
/** Column header label. */
|
||||
header: string;
|
||||
/** Optional custom cell renderer. */
|
||||
render?: (value: unknown, row: T) => React.ReactNode;
|
||||
/** Whether this column is sortable. */
|
||||
sortable?: boolean;
|
||||
/** CSS width (e.g. `"120px"`, `"20%"`). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/** Props for `DataTable`. */
|
||||
export interface DataTableProps<T = Record<string, unknown>> {
|
||||
/** Column definitions. */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Row data. Each row should have a stable `id` field. */
|
||||
rows: T[];
|
||||
/** Whether the table is currently loading. */
|
||||
loading?: boolean;
|
||||
/** Message shown when `rows` is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Total row count for pagination (if different from `rows.length`). */
|
||||
totalCount?: number;
|
||||
/** Current page (0-based, for pagination). */
|
||||
page?: number;
|
||||
/** Rows per page (for pagination). */
|
||||
pageSize?: number;
|
||||
/** Callback when page changes. */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** Callback when a column header is clicked to sort. */
|
||||
onSort?: (key: string, direction: "asc" | "desc") => void;
|
||||
}
|
||||
|
||||
/** A single data point for `TimeseriesChart`. */
|
||||
export interface TimeseriesDataPoint {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Numeric value. */
|
||||
value: number;
|
||||
/** Optional label for the point. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `TimeseriesChart`. */
|
||||
export interface TimeseriesChartProps {
|
||||
/** Series data. */
|
||||
data: TimeseriesDataPoint[];
|
||||
/** Chart title. */
|
||||
title?: string;
|
||||
/** Y-axis label. */
|
||||
yLabel?: string;
|
||||
/** Chart type. Defaults to `"line"`. */
|
||||
type?: "line" | "bar";
|
||||
/** Height of the chart in pixels. Defaults to `200`. */
|
||||
height?: number;
|
||||
/** Whether the chart is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `MarkdownBlock`. */
|
||||
export interface MarkdownBlockProps {
|
||||
/** Markdown content to render. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A single key-value pair for `KeyValueList`. */
|
||||
export interface KeyValuePair {
|
||||
/** Label for the key. */
|
||||
label: string;
|
||||
/** Value to display. May be a string, number, or a React node. */
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Props for `KeyValueList`. */
|
||||
export interface KeyValueListProps {
|
||||
/** Pairs to render in the list. */
|
||||
pairs: KeyValuePair[];
|
||||
}
|
||||
|
||||
/** A single action button for `ActionBar`. */
|
||||
export interface ActionBarItem {
|
||||
/** Button label. */
|
||||
label: string;
|
||||
/** Action key to call via the plugin bridge. */
|
||||
actionKey: string;
|
||||
/** Optional parameters to pass to the action handler. */
|
||||
params?: Record<string, unknown>;
|
||||
/** Button variant. Defaults to `"default"`. */
|
||||
variant?: "default" | "primary" | "destructive";
|
||||
/** Whether to show a confirmation dialog before executing. */
|
||||
confirm?: boolean;
|
||||
/** Text for the confirmation dialog (used when `confirm` is true). */
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
/** Props for `ActionBar`. */
|
||||
export interface ActionBarProps {
|
||||
/** Action definitions. */
|
||||
actions: ActionBarItem[];
|
||||
/** Called after an action succeeds. Use to trigger data refresh. */
|
||||
onSuccess?: (actionKey: string, result: unknown) => void;
|
||||
/** Called when an action fails. */
|
||||
onError?: (actionKey: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
/** A single log line for `LogView`. */
|
||||
export interface LogViewEntry {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Log level. */
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
/** Log message. */
|
||||
message: string;
|
||||
/** Optional structured metadata. */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Props for `LogView`. */
|
||||
export interface LogViewProps {
|
||||
/** Log entries to display. */
|
||||
entries: LogViewEntry[];
|
||||
/** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
|
||||
maxHeight?: string;
|
||||
/** Whether to auto-scroll to the latest entry. */
|
||||
autoScroll?: boolean;
|
||||
/** Whether the log is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `JsonTree`. */
|
||||
export interface JsonTreeProps {
|
||||
/** The data to render as a collapsible JSON tree. */
|
||||
data: unknown;
|
||||
/** Initial depth to expand. Defaults to `2`. */
|
||||
defaultExpandDepth?: number;
|
||||
}
|
||||
|
||||
/** Props for `Spinner`. */
|
||||
export interface SpinnerProps {
|
||||
/** Size of the spinner. Defaults to `"md"`. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Accessible label for the spinner (used as `aria-label`). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `ErrorBoundary`. */
|
||||
export interface ErrorBoundaryProps {
|
||||
/** Content to render inside the error boundary. */
|
||||
children: React.ReactNode;
|
||||
/** Optional custom fallback to render when an error is caught. */
|
||||
fallback?: React.ReactNode;
|
||||
/** Called when an error is caught, for logging or reporting. */
|
||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component declarations (provided by host at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// These are declared as ambient values so plugin TypeScript code can import
|
||||
// and use them with full type-checking. The host's module registry provides
|
||||
// the concrete React component implementations at bundle load time.
|
||||
|
||||
/**
|
||||
* Displays a single metric with an optional trend indicator and sparkline.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
function createSdkUiComponent<TProps>(name: string): React.ComponentType<TProps> {
|
||||
return function PaperclipSdkUiComponent(props: TProps) {
|
||||
return renderSdkUiComponent(name, props) as React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const MetricCard = createSdkUiComponent<MetricCardProps>("MetricCard");
|
||||
|
||||
/**
|
||||
* Displays an inline status badge (ok / warning / error / info / pending).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const StatusBadge = createSdkUiComponent<StatusBadgeProps>("StatusBadge");
|
||||
|
||||
/**
|
||||
* Sortable, paginated data table.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const DataTable = createSdkUiComponent<DataTableProps>("DataTable");
|
||||
|
||||
/**
|
||||
* Line or bar chart for time-series data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("TimeseriesChart");
|
||||
|
||||
/**
|
||||
* Renders Markdown text as HTML.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||
|
||||
/**
|
||||
* Renders a definition-list of label/value pairs.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const KeyValueList = createSdkUiComponent<KeyValueListProps>("KeyValueList");
|
||||
|
||||
/**
|
||||
* Row of action buttons wired to the plugin bridge's `performAction` handlers.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const ActionBar = createSdkUiComponent<ActionBarProps>("ActionBar");
|
||||
|
||||
/**
|
||||
* Scrollable, timestamped log output viewer.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const LogView = createSdkUiComponent<LogViewProps>("LogView");
|
||||
|
||||
/**
|
||||
* Collapsible JSON tree for debugging or raw data inspection.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const JsonTree = createSdkUiComponent<JsonTreeProps>("JsonTree");
|
||||
|
||||
/**
|
||||
* Loading indicator.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||
|
||||
/**
|
||||
* React error boundary that prevents plugin rendering errors from crashing
|
||||
* the host page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginHostContext,
|
||||
PluginStreamResult,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
import { getSdkUiRuntimeValue } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch data from the plugin worker's registered `getData` handler.
|
||||
*
|
||||
* Calls `ctx.data.register(key, handler)` in the worker and returns the
|
||||
* result as reactive state. Re-fetches when `params` changes.
|
||||
*
|
||||
* @template T The expected shape of the returned data
|
||||
* @param key - The data key matching the handler registered with `ctx.data.register()`
|
||||
* @param params - Optional parameters forwarded to the handler
|
||||
* @returns `PluginDataResult<T>` with `data`, `loading`, `error`, and `refresh`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SyncWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData<SyncHealth>("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
* return <div>Synced Issues: {data!.syncedCount}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.8 — `getData`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginData<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
): PluginDataResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextKey: string, nextParams?: Record<string, unknown>) => PluginDataResult<T>
|
||||
>("usePluginData");
|
||||
return impl(key, params);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a callable function that invokes the plugin worker's registered
|
||||
* `performAction` handler.
|
||||
*
|
||||
* The returned function is async and throws a `PluginBridgeError` on failure.
|
||||
*
|
||||
* @param key - The action key matching the handler registered with `ctx.actions.register()`
|
||||
* @returns An async function that sends the action to the worker and resolves with the result
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ResyncButton({ context }: PluginWidgetProps) {
|
||||
* const resync = usePluginAction("resync");
|
||||
* const [error, setError] = useState<string | null>(null);
|
||||
*
|
||||
* async function handleClick() {
|
||||
* try {
|
||||
* await resync({ companyId: context.companyId });
|
||||
* } catch (err) {
|
||||
* setError((err as PluginBridgeError).message);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return <button onClick={handleClick}>Resync Now</button>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginAction(key: string): PluginActionFn {
|
||||
const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction");
|
||||
return impl(key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the current host context (active company, project, entity, user).
|
||||
*
|
||||
* Use this to know which context the plugin component is being rendered in
|
||||
* so you can scope data requests and actions accordingly.
|
||||
*
|
||||
* @returns The current `PluginHostContext`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function IssueTab() {
|
||||
* const { companyId, entityId } = useHostContext();
|
||||
* const { data } = usePluginData("linear-link", { issueId: entityId });
|
||||
* return <div>{data?.linearIssueUrl}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export function useHostContext(): PluginHostContext {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext");
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscribe to a real-time event stream pushed from the plugin worker.
|
||||
*
|
||||
* Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel`
|
||||
* and accumulates events as they arrive. The worker pushes events using
|
||||
* `ctx.streams.emit(channel, event)`.
|
||||
*
|
||||
* @template T The expected shape of each streamed event
|
||||
* @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`)
|
||||
* @param options - Optional configuration for the stream
|
||||
* @returns `PluginStreamResult<T>` with `events`, `lastEvent`, connection status, and `close()`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ChatMessages() {
|
||||
* const { events, connected, close } = usePluginStream<ChatToken>("chat-stream");
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
* {connected && <span className="pulse" />}
|
||||
* <button onClick={close}>Stop</button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export function usePluginStream<T = unknown>(
|
||||
channel: string,
|
||||
options?: { companyId?: string },
|
||||
): PluginStreamResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult<T>
|
||||
>("usePluginStream");
|
||||
return impl(channel, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger a host toast notification from plugin UI.
|
||||
*
|
||||
* This lets plugin pages and widgets surface user-facing feedback through the
|
||||
* same toast system as the host app without reaching into host internals.
|
||||
*/
|
||||
export function usePluginToast(): PluginToastFn {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginToastFn>("usePluginToast");
|
||||
return impl();
|
||||
}
|
||||
87
packages/plugins/sdk/src/ui/index.ts
Normal file
87
packages/plugins/sdk/src/ui/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk/ui` — Paperclip plugin UI SDK.
|
||||
*
|
||||
* Import this subpath from plugin UI bundles (React components that run in
|
||||
* the host frontend). Do **not** import this from plugin worker code.
|
||||
*
|
||||
* The worker-side SDK is available from `@paperclipai/plugin-sdk` (root).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
||||
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
*
|
||||
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
* const resync = usePluginAction("resync");
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* return (
|
||||
* <div style={{ display: "grid", gap: 8 }}>
|
||||
* <strong>Synced Issues</strong>
|
||||
* <div>{data!.syncedCount}</div>
|
||||
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bridge hooks for plugin UI components to communicate with the plugin worker.
|
||||
*
|
||||
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||
*/
|
||||
export {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
// Bridge error and host context types
|
||||
export type {
|
||||
PluginBridgeError,
|
||||
PluginBridgeErrorCode,
|
||||
PluginHostContext,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginRenderCloseHandler,
|
||||
PluginRenderCloseLifecycle,
|
||||
PluginRenderEnvironmentContext,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginStreamResult,
|
||||
PluginToastTone,
|
||||
PluginToastAction,
|
||||
PluginToastInput,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
PluginSettingsPageProps,
|
||||
} from "./types.js";
|
||||
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type PluginBridgeRegistry = {
|
||||
react?: {
|
||||
createElement?: (type: unknown, props?: Record<string, unknown> | null) => unknown;
|
||||
} | null;
|
||||
sdkUi?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type GlobalBridge = typeof globalThis & {
|
||||
__paperclipPluginBridge__?: PluginBridgeRegistry;
|
||||
};
|
||||
|
||||
function getBridgeRegistry(): PluginBridgeRegistry | undefined {
|
||||
return (globalThis as GlobalBridge).__paperclipPluginBridge__;
|
||||
}
|
||||
|
||||
function missingBridgeValueError(name: string): Error {
|
||||
return new Error(
|
||||
`Paperclip plugin UI runtime is not initialized for "${name}". ` +
|
||||
'Ensure the host loaded the plugin bridge before rendering this UI module.',
|
||||
);
|
||||
}
|
||||
|
||||
export function getSdkUiRuntimeValue<T>(name: string): T {
|
||||
const value = getBridgeRegistry()?.sdkUi?.[name];
|
||||
if (value === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function renderSdkUiComponent<TProps>(
|
||||
name: string,
|
||||
props: TProps,
|
||||
): unknown {
|
||||
const registry = getBridgeRegistry();
|
||||
const component = registry?.sdkUi?.[name];
|
||||
if (component === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
|
||||
const createElement = registry?.react?.createElement;
|
||||
if (typeof createElement === "function") {
|
||||
return createElement(component, props as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (typeof component === "function") {
|
||||
return component(props);
|
||||
}
|
||||
|
||||
throw new Error(`Paperclip plugin UI component "${name}" is not callable`);
|
||||
}
|
||||
381
packages/plugins/sdk/src/ui/types.ts
Normal file
381
packages/plugins/sdk/src/ui/types.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Paperclip plugin UI SDK — types for plugin frontend components.
|
||||
*
|
||||
* Plugin UI bundles import from `@paperclipai/plugin-sdk/ui`. This subpath
|
||||
* provides the bridge hooks, component prop interfaces, and error types that
|
||||
* plugin React components use to communicate with the host.
|
||||
*
|
||||
* Plugin UI bundles are loaded as ES modules into designated extension slots.
|
||||
* All communication with the plugin worker goes through the host bridge — plugin
|
||||
* components must NOT access host internals or call host APIs directly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// Re-export PluginBridgeErrorCode for plugin UI authors
|
||||
export type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
export type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structured error returned by the bridge when a UI → worker call fails.
|
||||
*
|
||||
* Plugin components receive this in `usePluginData()` as the `error` field
|
||||
* and may encounter it as a thrown value from `usePluginAction()`.
|
||||
*
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginBridgeError {
|
||||
/** Machine-readable error code. */
|
||||
code: PluginBridgeErrorCode;
|
||||
/** Human-readable error message. */
|
||||
message: string;
|
||||
/**
|
||||
* Original error details from the worker, if available.
|
||||
* Only present when `code === "WORKER_ERROR"`.
|
||||
*/
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host context available to all plugin components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read-only host context passed to every plugin component via `useHostContext()`.
|
||||
*
|
||||
* Plugin components use this to know which company, project, or entity is
|
||||
* currently active so they can scope their data requests accordingly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface PluginHostContext {
|
||||
/** UUID of the currently active company, if any. */
|
||||
companyId: string | null;
|
||||
/** URL prefix for the current company (e.g. `"my-company"`). */
|
||||
companyPrefix: string | null;
|
||||
/** UUID of the currently active project, if any. */
|
||||
projectId: string | null;
|
||||
/** UUID of the current entity (for detail tab contexts), if any. */
|
||||
entityId: string | null;
|
||||
/** Type of the current entity (e.g. `"issue"`, `"agent"`). */
|
||||
entityType: string | null;
|
||||
/**
|
||||
* UUID of the parent entity when rendering nested slots.
|
||||
* For `commentAnnotation` slots this is the issue ID containing the comment.
|
||||
*/
|
||||
parentEntityId?: string | null;
|
||||
/** UUID of the current authenticated user. */
|
||||
userId: string | null;
|
||||
/** Runtime metadata for the host container currently rendering this plugin UI. */
|
||||
renderEnvironment?: PluginRenderEnvironmentContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async-capable callback invoked during a host-managed close lifecycle.
|
||||
*/
|
||||
export type PluginRenderCloseHandler = (
|
||||
event: PluginRenderCloseEvent,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Close lifecycle hooks available when the plugin UI is rendered inside a
|
||||
* host-managed launcher environment.
|
||||
*/
|
||||
export interface PluginRenderCloseLifecycle {
|
||||
/** Register a callback before the host closes the current environment. */
|
||||
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
/** Register a callback after the host closes the current environment. */
|
||||
onClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime information about the host container currently rendering a plugin UI.
|
||||
*/
|
||||
export interface PluginRenderEnvironmentContext
|
||||
extends PluginLauncherRenderContextSnapshot {
|
||||
/** Optional host callback for requesting new bounds while a modal is open. */
|
||||
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
|
||||
/** Optional close lifecycle callbacks for host-managed overlays. */
|
||||
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Props passed to a plugin page component.
|
||||
*
|
||||
* A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.1 — Global Operator Routes
|
||||
* @see PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||
*/
|
||||
export interface PluginPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin dashboard widget component.
|
||||
*
|
||||
* A dashboard widget is rendered as a card or section on the main dashboard.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets
|
||||
*/
|
||||
export interface PluginWidgetProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin detail tab component.
|
||||
*
|
||||
* A detail tab is rendered as an additional tab on a project, issue, agent,
|
||||
* goal, or run detail page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export interface PluginDetailTabProps {
|
||||
/** The current host context, always including `entityId` and `entityType`. */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin sidebar component.
|
||||
*
|
||||
* A sidebar entry adds a link or section to the application sidebar.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||
*/
|
||||
export interface PluginSidebarProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin project sidebar item component.
|
||||
*
|
||||
* A project sidebar item is rendered **once per project** under that project's
|
||||
* row in the sidebar Projects list. The host passes the current project's id
|
||||
* in `context.entityId` and `context.entityType` is `"project"`.
|
||||
*
|
||||
* Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to
|
||||
* the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items
|
||||
*/
|
||||
export interface PluginProjectSidebarItemProps {
|
||||
/** Host context plus entityId (project id) and entityType "project". */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: "project";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment annotation component.
|
||||
*
|
||||
* A comment annotation is rendered below each individual comment in the
|
||||
* issue detail timeline. The host passes the comment ID as `entityId`
|
||||
* and `"comment"` as `entityType`, plus the parent issue ID as
|
||||
* `parentEntityId` so the plugin can scope data fetches to both.
|
||||
*
|
||||
* Use this slot to augment comments with parsed file links, sentiment
|
||||
* badges, inline actions, or any per-comment metadata.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Comment Annotations
|
||||
*/
|
||||
export interface PluginCommentAnnotationProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment being annotated. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment annotation slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment context menu item component.
|
||||
*
|
||||
* A comment context menu item is rendered in a "more" dropdown menu on
|
||||
* each comment in the issue detail timeline. The host passes the comment
|
||||
* ID as `entityId` and `"comment"` as `entityType`, plus the parent
|
||||
* issue ID as `parentEntityId`.
|
||||
*
|
||||
* Use this slot to add per-comment actions such as "Create sub-issue from
|
||||
* comment", "Translate", "Flag for review", or any custom plugin action.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items
|
||||
*/
|
||||
export interface PluginCommentContextMenuItemProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment this menu item acts on. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment context menu item slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin settings page component.
|
||||
*
|
||||
* Overrides the auto-generated JSON Schema form when the plugin declares
|
||||
* a `settingsPage` UI slot. The component is responsible for reading and
|
||||
* writing config through the bridge.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI
|
||||
*/
|
||||
export interface PluginSettingsPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginData(key, params)`.
|
||||
*
|
||||
* Mirrors a standard async data-fetching hook pattern:
|
||||
* exactly one of `data` or `error` is non-null at any time (unless `loading`).
|
||||
*
|
||||
* @template T The type of the data returned by the worker handler
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginDataResult<T = unknown> {
|
||||
/** The data returned by the worker's `getData` handler. `null` while loading or on error. */
|
||||
data: T | null;
|
||||
/** `true` while the initial request or a refresh is in flight. */
|
||||
loading: boolean;
|
||||
/** Bridge error if the request failed. `null` on success or while loading. */
|
||||
error: PluginBridgeError | null;
|
||||
/**
|
||||
* Manually trigger a data refresh.
|
||||
* Useful for poll-based updates or post-action refreshes.
|
||||
*/
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast hook types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PluginToastTone = "info" | "success" | "warn" | "error";
|
||||
|
||||
export interface PluginToastAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface PluginToastInput {
|
||||
id?: string;
|
||||
dedupeKey?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
tone?: PluginToastTone;
|
||||
ttlMs?: number;
|
||||
action?: PluginToastAction;
|
||||
}
|
||||
|
||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginStream<T>(channel)`.
|
||||
*
|
||||
* Provides a growing array of events pushed from the plugin worker via SSE,
|
||||
* plus connection status metadata.
|
||||
*
|
||||
* @template T The type of each event emitted by the worker
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export interface PluginStreamResult<T = unknown> {
|
||||
/** All events received so far, in arrival order. */
|
||||
events: T[];
|
||||
/** The most recently received event, or `null` if none yet. */
|
||||
lastEvent: T | null;
|
||||
/** `true` while the SSE connection is being established. */
|
||||
connecting: boolean;
|
||||
/** `true` once the SSE connection is open and receiving events. */
|
||||
connected: boolean;
|
||||
/** Error if the SSE connection failed or was interrupted. `null` otherwise. */
|
||||
error: Error | null;
|
||||
/** Close the SSE connection and stop receiving events. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginAction(key)`.
|
||||
*
|
||||
* Returns an async function that, when called, sends an action request
|
||||
* to the worker's `performAction` handler and returns the result.
|
||||
*
|
||||
* On failure, the async function throws a `PluginBridgeError`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const resync = usePluginAction("resync");
|
||||
* <button onClick={() => resync({ companyId }).catch(err => console.error(err))}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;
|
||||
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/plugins/sdk/tsconfig.json
Normal file
9
packages/plugins/sdk/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "react"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
@@ -213,6 +212,9 @@ export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.log",
|
||||
"agent.status",
|
||||
"activity.logged",
|
||||
"plugin.ui.updated",
|
||||
"plugin.worker.crashed",
|
||||
"plugin.worker.restarted",
|
||||
] as const;
|
||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||
|
||||
@@ -246,3 +248,336 @@ export const PERMISSION_KEYS = [
|
||||
"joins:approve",
|
||||
] as const;
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The current version of the Plugin API contract.
|
||||
*
|
||||
* Increment this value whenever a breaking change is made to the plugin API
|
||||
* so that the host can reject incompatible plugin manifests.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §4 — Versioning
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Lifecycle statuses for an installed plugin.
|
||||
*
|
||||
* State machine: installed → ready | error, ready → disabled | error | upgrade_pending | uninstalled,
|
||||
* disabled → ready | uninstalled, error → ready | uninstalled,
|
||||
* upgrade_pending → ready | error | uninstalled, uninstalled → installed (reinstall).
|
||||
*
|
||||
* @see {@link PluginStatus} — inferred union type
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugins.status`
|
||||
*/
|
||||
export const PLUGIN_STATUSES = [
|
||||
"installed",
|
||||
"ready",
|
||||
"disabled",
|
||||
"error",
|
||||
"upgrade_pending",
|
||||
"uninstalled",
|
||||
] as const;
|
||||
export type PluginStatus = (typeof PLUGIN_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Plugin classification categories. A plugin declares one or more categories
|
||||
* in its manifest to describe its primary purpose.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §6.2
|
||||
*/
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"connector",
|
||||
"workspace",
|
||||
"automation",
|
||||
"ui",
|
||||
] as const;
|
||||
export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Named permissions the host grants to a plugin. Plugins declare required
|
||||
* capabilities in their manifest; the host enforces them at runtime via the
|
||||
* plugin capability validator.
|
||||
*
|
||||
* Grouped into: Data Read, Data Write, Plugin State, Runtime/Integration,
|
||||
* Agent Tools, and UI.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
export const PLUGIN_CAPABILITIES = [
|
||||
// Data Read
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issue.comments.read",
|
||||
"agents.read",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.read",
|
||||
"costs.read",
|
||||
// Data Write
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.create",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
// Runtime / Integration
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
] as const;
|
||||
export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number];
|
||||
|
||||
/**
|
||||
* UI extension slot types. Each slot type corresponds to a mount point in the
|
||||
* Paperclip UI where plugin components can be rendered.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Reserved company-scoped route segments that plugin page routes may not claim.
|
||||
*
|
||||
* These map to first-class host pages under `/:companyPrefix/...`.
|
||||
*/
|
||||
export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
|
||||
"dashboard",
|
||||
"onboarding",
|
||||
"companies",
|
||||
"company",
|
||||
"settings",
|
||||
"plugins",
|
||||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
"issues",
|
||||
"goals",
|
||||
"approvals",
|
||||
"costs",
|
||||
"activity",
|
||||
"inbox",
|
||||
"design-guide",
|
||||
"tests",
|
||||
] as const;
|
||||
export type PluginReservedCompanyRouteSegment =
|
||||
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
|
||||
|
||||
/**
|
||||
* Launcher placement zones describe where a plugin-owned launcher can appear
|
||||
* in the host UI. These are intentionally aligned with current slot surfaces
|
||||
* so manifest authors can describe launch intent without coupling to a single
|
||||
* component implementation detail.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_PLACEMENT_ZONES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginLauncherPlacementZone = (typeof PLUGIN_LAUNCHER_PLACEMENT_ZONES)[number];
|
||||
|
||||
/**
|
||||
* Launcher action kinds describe what the launcher does when activated.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_ACTIONS = [
|
||||
"navigate",
|
||||
"openModal",
|
||||
"openDrawer",
|
||||
"openPopover",
|
||||
"performAction",
|
||||
"deepLink",
|
||||
] as const;
|
||||
export type PluginLauncherAction = (typeof PLUGIN_LAUNCHER_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* Optional size hints the host can use when rendering plugin-owned launcher
|
||||
* destinations such as overlays, drawers, or full page handoffs.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_BOUNDS = [
|
||||
"inline",
|
||||
"compact",
|
||||
"default",
|
||||
"wide",
|
||||
"full",
|
||||
] as const;
|
||||
export type PluginLauncherBounds = (typeof PLUGIN_LAUNCHER_BOUNDS)[number];
|
||||
|
||||
/**
|
||||
* Render environments describe the container a launcher expects after it is
|
||||
* activated. The current host may map these to concrete UI primitives.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS = [
|
||||
"hostInline",
|
||||
"hostOverlay",
|
||||
"hostRoute",
|
||||
"external",
|
||||
"iframe",
|
||||
] as const;
|
||||
export type PluginLauncherRenderEnvironment =
|
||||
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number];
|
||||
|
||||
/**
|
||||
* Entity types that a `detailTab` UI slot can attach to.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
|
||||
"project",
|
||||
"issue",
|
||||
"agent",
|
||||
"goal",
|
||||
"run",
|
||||
"comment",
|
||||
] as const;
|
||||
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Scope kinds for plugin state storage. Determines the granularity at which
|
||||
* a plugin stores key-value state data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugin_state.scope_kind`
|
||||
*/
|
||||
export const PLUGIN_STATE_SCOPE_KINDS = [
|
||||
"instance",
|
||||
"company",
|
||||
"project",
|
||||
"project_workspace",
|
||||
"agent",
|
||||
"issue",
|
||||
"goal",
|
||||
"run",
|
||||
] as const;
|
||||
export type PluginStateScopeKind = (typeof PLUGIN_STATE_SCOPE_KINDS)[number];
|
||||
|
||||
/** Statuses for a plugin's scheduled job definition. */
|
||||
export const PLUGIN_JOB_STATUSES = [
|
||||
"active",
|
||||
"paused",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginJobStatus = (typeof PLUGIN_JOB_STATUSES)[number];
|
||||
|
||||
/** Statuses for individual job run executions. */
|
||||
export const PLUGIN_JOB_RUN_STATUSES = [
|
||||
"pending",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type PluginJobRunStatus = (typeof PLUGIN_JOB_RUN_STATUSES)[number];
|
||||
|
||||
/** What triggered a particular job run. */
|
||||
export const PLUGIN_JOB_RUN_TRIGGERS = [
|
||||
"schedule",
|
||||
"manual",
|
||||
"retry",
|
||||
] as const;
|
||||
export type PluginJobRunTrigger = (typeof PLUGIN_JOB_RUN_TRIGGERS)[number];
|
||||
|
||||
/** Statuses for inbound webhook deliveries. */
|
||||
export const PLUGIN_WEBHOOK_DELIVERY_STATUSES = [
|
||||
"pending",
|
||||
"success",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginWebhookDeliveryStatus = (typeof PLUGIN_WEBHOOK_DELIVERY_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Core domain event types that plugins can subscribe to via the
|
||||
* `events.subscribe` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §16 — Event System
|
||||
*/
|
||||
export const PLUGIN_EVENT_TYPES = [
|
||||
"company.created",
|
||||
"company.updated",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.workspace_created",
|
||||
"project.workspace_updated",
|
||||
"project.workspace_deleted",
|
||||
"issue.created",
|
||||
"issue.updated",
|
||||
"issue.comment.created",
|
||||
"agent.created",
|
||||
"agent.updated",
|
||||
"agent.status_changed",
|
||||
"agent.run.started",
|
||||
"agent.run.finished",
|
||||
"agent.run.failed",
|
||||
"agent.run.cancelled",
|
||||
"goal.created",
|
||||
"goal.updated",
|
||||
"approval.created",
|
||||
"approval.decided",
|
||||
"cost_event.created",
|
||||
"activity.logged",
|
||||
] as const;
|
||||
export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Error codes returned by the plugin bridge when a UI → worker call fails.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const PLUGIN_BRIDGE_ERROR_CODES = [
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CAPABILITY_DENIED",
|
||||
"WORKER_ERROR",
|
||||
"TIMEOUT",
|
||||
"UNKNOWN",
|
||||
] as const;
|
||||
export type PluginBridgeErrorCode = (typeof PLUGIN_BRIDGE_ERROR_CODES)[number];
|
||||
|
||||
@@ -31,6 +31,23 @@ export {
|
||||
JOIN_REQUEST_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
PERMISSION_KEYS,
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
PLUGIN_JOB_RUN_TRIGGERS,
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
type CompanyStatus,
|
||||
type DeploymentMode,
|
||||
type DeploymentExposure,
|
||||
@@ -61,6 +78,22 @@ export {
|
||||
type JoinRequestType,
|
||||
type JoinRequestStatus,
|
||||
type PermissionKey,
|
||||
type PluginStatus,
|
||||
type PluginCategory,
|
||||
type PluginCapability,
|
||||
type PluginUiSlotType,
|
||||
type PluginUiSlotEntityType,
|
||||
type PluginLauncherPlacementZone,
|
||||
type PluginLauncherAction,
|
||||
type PluginLauncherBounds,
|
||||
type PluginLauncherRenderEnvironment,
|
||||
type PluginStateScopeKind,
|
||||
type PluginJobStatus,
|
||||
type PluginJobRunStatus,
|
||||
type PluginJobRunTrigger,
|
||||
type PluginWebhookDeliveryStatus,
|
||||
type PluginEventType,
|
||||
type PluginBridgeErrorCode,
|
||||
} from "./constants.js";
|
||||
|
||||
export type {
|
||||
@@ -134,6 +167,26 @@ export type {
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./types/index.js";
|
||||
|
||||
export {
|
||||
@@ -248,6 +301,39 @@ export {
|
||||
type CompanyPortabilityExport,
|
||||
type CompanyPortabilityPreview,
|
||||
type CompanyPortabilityImport,
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
|
||||
@@ -84,3 +84,25 @@ export type {
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
} from "./company-portability.js";
|
||||
export type {
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./plugin.js";
|
||||
|
||||
489
packages/shared/src/types/plugin.ts
Normal file
489
packages/shared/src/types/plugin.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import type {
|
||||
PluginStatus,
|
||||
PluginCategory,
|
||||
PluginCapability,
|
||||
PluginUiSlotType,
|
||||
PluginUiSlotEntityType,
|
||||
PluginStateScopeKind,
|
||||
PluginLauncherPlacementZone,
|
||||
PluginLauncherAction,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "../constants.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A JSON Schema object used for plugin config schemas and tool parameter schemas.
|
||||
* Plugins provide these as plain JSON Schema compatible objects.
|
||||
*/
|
||||
export type JsonSchema = Record<string, unknown>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Declares a scheduled job a plugin can run.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
*/
|
||||
export interface PluginJobDeclaration {
|
||||
/** Stable identifier for this job, unique within the plugin. */
|
||||
jobKey: string;
|
||||
/** Human-readable name shown in the operator UI. */
|
||||
displayName: string;
|
||||
/** Optional description of what the job does. */
|
||||
description?: string;
|
||||
/** Cron expression for the schedule (e.g. "star/15 star star star star" or "0 * * * *"). */
|
||||
schedule?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a webhook endpoint the plugin can receive.
|
||||
* Route: `POST /api/plugins/:pluginId/webhooks/:endpointKey`
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||
*/
|
||||
export interface PluginWebhookDeclaration {
|
||||
/** Stable identifier for this endpoint, unique within the plugin. */
|
||||
endpointKey: string;
|
||||
/** Human-readable name shown in the operator UI. */
|
||||
displayName: string;
|
||||
/** Optional description of what this webhook handles. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares an agent tool contributed by the plugin. Tools are namespaced
|
||||
* by plugin ID at runtime (e.g. `linear:search-issues`).
|
||||
*
|
||||
* Requires the `agent.tools.register` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
*/
|
||||
export interface PluginToolDeclaration {
|
||||
/** Tool name, unique within the plugin. Namespaced by plugin ID at runtime. */
|
||||
name: string;
|
||||
/** Human-readable name shown to agents and in the UI. */
|
||||
displayName: string;
|
||||
/** Description provided to the agent so it knows when to use this tool. */
|
||||
description: string;
|
||||
/** JSON Schema describing the tool's input parameters. */
|
||||
parametersSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a UI extension slot the plugin fills with a React component.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface PluginUiSlotDeclaration {
|
||||
/** The type of UI mount point (page, detailTab, taskDetailView, toolbarButton, etc.). */
|
||||
type: PluginUiSlotType;
|
||||
/** Unique slot identifier within the plugin. */
|
||||
id: string;
|
||||
/** Human-readable name shown in navigation or tab labels. */
|
||||
displayName: string;
|
||||
/** Which export name in the UI bundle provides this component. */
|
||||
exportName: string;
|
||||
/**
|
||||
* Entity targets for context-sensitive slots.
|
||||
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/**
|
||||
* Optional company-scoped route segment for page slots.
|
||||
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||
*/
|
||||
routePath?: string;
|
||||
/**
|
||||
* Optional ordering hint within a slot surface. Lower numbers appear first.
|
||||
* Defaults to host-defined ordering if omitted.
|
||||
*/
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the action triggered by a plugin launcher surface.
|
||||
*/
|
||||
export interface PluginLauncherActionDeclaration {
|
||||
/** What kind of launch behavior the host should perform. */
|
||||
type: PluginLauncherAction;
|
||||
/**
|
||||
* Stable target identifier or URL. The meaning depends on `type`
|
||||
* (for example a route, tab key, action key, or external URL).
|
||||
*/
|
||||
target: string;
|
||||
/** Optional arbitrary parameters passed along to the target. */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional render metadata for the destination opened by a launcher.
|
||||
*/
|
||||
export interface PluginLauncherRenderDeclaration {
|
||||
/** High-level container the launcher expects the host to use. */
|
||||
environment: PluginLauncherRenderEnvironment;
|
||||
/** Optional size hint for the destination surface. */
|
||||
bounds?: PluginLauncherBounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable runtime snapshot of the host launcher/container environment.
|
||||
*/
|
||||
export interface PluginLauncherRenderContextSnapshot {
|
||||
/** The current launcher/container environment selected by the host. */
|
||||
environment: PluginLauncherRenderEnvironment | null;
|
||||
/** Launcher id that opened this surface, if any. */
|
||||
launcherId: string | null;
|
||||
/** Current host-applied bounds hint for the environment, if any. */
|
||||
bounds: PluginLauncherBounds | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a plugin launcher surface independent of the low-level slot
|
||||
* implementation that mounts it.
|
||||
*/
|
||||
export interface PluginLauncherDeclaration {
|
||||
/** Stable identifier for this launcher, unique within the plugin. */
|
||||
id: string;
|
||||
/** Human-readable label shown for the launcher. */
|
||||
displayName: string;
|
||||
/** Optional description for operator-facing docs or future UI affordances. */
|
||||
description?: string;
|
||||
/** Where in the host UI this launcher should be placed. */
|
||||
placementZone: PluginLauncherPlacementZone;
|
||||
/** Optional export name in the UI bundle when the launcher has custom UI. */
|
||||
exportName?: string;
|
||||
/**
|
||||
* Optional entity targeting for context-sensitive launcher zones.
|
||||
* Reuses the same entity union as UI slots for consistency.
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/** Optional ordering hint within the placement zone. */
|
||||
order?: number;
|
||||
/** What should happen when the launcher is activated. */
|
||||
action: PluginLauncherActionDeclaration;
|
||||
/** Optional render/container hints for the launched destination. */
|
||||
render?: PluginLauncherRenderDeclaration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-bound semver requirement for the Paperclip host.
|
||||
*
|
||||
* The host should reject installation when its running version is lower than
|
||||
* the declared minimum.
|
||||
*/
|
||||
export type PluginMinimumHostVersion = string;
|
||||
|
||||
/**
|
||||
* Groups plugin UI declarations that are served from the shared UI bundle
|
||||
* root declared in `entrypoints.ui`.
|
||||
*/
|
||||
export interface PluginUiDeclaration {
|
||||
/** UI extension slots this plugin fills. */
|
||||
slots?: PluginUiSlotDeclaration[];
|
||||
/** Declarative launcher metadata for host-mounted plugin entry points. */
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The manifest shape every plugin package must export.
|
||||
* See PLUGIN_SPEC.md §10.1 for the normative definition.
|
||||
*/
|
||||
export interface PaperclipPluginManifestV1 {
|
||||
/** Globally unique plugin identifier (e.g. `"acme.linear-sync"`). Must be lowercase alphanumeric with dots, hyphens, or underscores. */
|
||||
id: string;
|
||||
/** Plugin API version. Must be `1` for the current spec. */
|
||||
apiVersion: 1;
|
||||
/** Semver version of the plugin package (e.g. `"1.2.0"`). */
|
||||
version: string;
|
||||
/** Human-readable name (max 100 chars). */
|
||||
displayName: string;
|
||||
/** Short description (max 500 chars). */
|
||||
description: string;
|
||||
/** Author name (max 200 chars). May include email in angle brackets, e.g. `"Jane Doe <jane@example.com>"`. */
|
||||
author: string;
|
||||
/** One or more categories classifying this plugin. */
|
||||
categories: PluginCategory[];
|
||||
/**
|
||||
* Minimum host version required (semver lower bound).
|
||||
* Preferred generic field for new manifests.
|
||||
*/
|
||||
minimumHostVersion?: PluginMinimumHostVersion;
|
||||
/**
|
||||
* Legacy alias for `minimumHostVersion`.
|
||||
* Kept for backwards compatibility with existing manifests and docs.
|
||||
*/
|
||||
minimumPaperclipVersion?: PluginMinimumHostVersion;
|
||||
/** Capabilities this plugin requires from the host. Enforced at runtime. */
|
||||
capabilities: PluginCapability[];
|
||||
/** Entrypoint paths relative to the package root. */
|
||||
entrypoints: {
|
||||
/** Path to the worker entrypoint (required). */
|
||||
worker: string;
|
||||
/** Path to the UI bundle directory (required when `ui.slots` is declared). */
|
||||
ui?: string;
|
||||
};
|
||||
/** JSON Schema for operator-editable instance configuration. */
|
||||
instanceConfigSchema?: JsonSchema;
|
||||
/** Scheduled jobs this plugin declares. Requires `jobs.schedule` capability. */
|
||||
jobs?: PluginJobDeclaration[];
|
||||
/** Webhook endpoints this plugin declares. Requires `webhooks.receive` capability. */
|
||||
webhooks?: PluginWebhookDeclaration[];
|
||||
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
|
||||
tools?: PluginToolDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
*/
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
/** UI bundle declarations. Requires `entrypoints.ui` when populated. */
|
||||
ui?: PluginUiDeclaration;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Record – represents a row in the `plugins` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an installed plugin as persisted in the `plugins` table.
|
||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||
*/
|
||||
export interface PluginRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** Unique key derived from `manifest.id`. Used for lookups. */
|
||||
pluginKey: string;
|
||||
/** npm package name (e.g. `"@acme/plugin-linear"`). */
|
||||
packageName: string;
|
||||
/** Installed semver version. */
|
||||
version: string;
|
||||
/** Plugin API version from the manifest. */
|
||||
apiVersion: number;
|
||||
/** Plugin categories from the manifest. */
|
||||
categories: PluginCategory[];
|
||||
/** Full manifest snapshot persisted at install/upgrade time. */
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
/** Current lifecycle status. */
|
||||
status: PluginStatus;
|
||||
/** Deterministic load order (null if not yet assigned). */
|
||||
installOrder: number | null;
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: string | null;
|
||||
/** Most recent error message, or operator-provided disable reason. */
|
||||
lastError: string | null;
|
||||
/** Timestamp when the plugin was first installed. */
|
||||
installedAt: Date;
|
||||
/** Timestamp of the most recent status or metadata change. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin State – represents a row in the `plugin_state` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a single scoped key-value entry in the `plugin_state` table.
|
||||
* Plugins read and write these entries through `ctx.state` in the SDK.
|
||||
*
|
||||
* The five-part composite key `(pluginId, scopeKind, scopeId, namespace, stateKey)`
|
||||
* uniquely identifies a state entry.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export interface PluginStateRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Granularity of the scope. */
|
||||
scopeKind: PluginStateScopeKind;
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* `null` for `instance` scope (no associated entity).
|
||||
*/
|
||||
scopeId: string | null;
|
||||
/**
|
||||
* Sub-namespace within the scope to avoid key collisions.
|
||||
* Defaults to `"default"` if not explicitly set by the plugin.
|
||||
*/
|
||||
namespace: string;
|
||||
/** The key for this state entry within the namespace. */
|
||||
stateKey: string;
|
||||
/** Stored JSON value. May be any JSON-serializable type. */
|
||||
valueJson: unknown;
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Config – represents a row in the `plugin_config` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a plugin's instance configuration as persisted in the
|
||||
* `plugin_config` table.
|
||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
|
||||
pluginId: string;
|
||||
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
||||
configJson: Record<string, unknown>;
|
||||
/** Most recent config validation error, if any. */
|
||||
lastError: string | null;
|
||||
/** Timestamp when the config row was created. */
|
||||
createdAt: Date;
|
||||
/** Timestamp of the most recent config update. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter for `ctx.entities.list`.
|
||||
*/
|
||||
export interface PluginEntityQuery {
|
||||
/** Optional filter by entity type (e.g. 'project', 'issue'). */
|
||||
entityType?: string;
|
||||
/** Optional filter by external system identifier. */
|
||||
externalId?: string;
|
||||
/** Maximum number of records to return. Defaults to 100. */
|
||||
limit?: number;
|
||||
/** Number of records to skip. Defaults to 0. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Entity – represents a row in the `plugin_entities` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an external entity mapping as persisted in the `plugin_entities` table.
|
||||
*/
|
||||
export interface PluginEntityRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Plugin-defined entity type. */
|
||||
entityType: string;
|
||||
/** Scope where this entity lives. */
|
||||
scopeKind: PluginStateScopeKind;
|
||||
/** UUID or text identifier for the scoped object. */
|
||||
scopeId: string | null;
|
||||
/** External identifier in the remote system. */
|
||||
externalId: string | null;
|
||||
/** Human-readable title. */
|
||||
title: string | null;
|
||||
/** Optional status string. */
|
||||
status: string | null;
|
||||
/** Full entity data blob. */
|
||||
data: Record<string, unknown>;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
/** ISO 8601 last-updated timestamp. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Job – represents a row in the `plugin_jobs` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a registered plugin job as persisted in the `plugin_jobs` table.
|
||||
*/
|
||||
export interface PluginJobRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Job key matching the manifest declaration. */
|
||||
jobKey: string;
|
||||
/** Cron expression for the schedule. */
|
||||
schedule: string;
|
||||
/** Current job status. */
|
||||
status: "active" | "paused" | "failed";
|
||||
/** Last time the job was executed. */
|
||||
lastRunAt: Date | null;
|
||||
/** Next scheduled execution time. */
|
||||
nextRunAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
/** ISO 8601 last-updated timestamp. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Job Run – represents a row in the `plugin_job_runs` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a job execution history record.
|
||||
*/
|
||||
export interface PluginJobRunRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugin_jobs.id`. */
|
||||
jobId: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** What triggered this run. */
|
||||
trigger: "schedule" | "manual" | "retry";
|
||||
/** Current run status. */
|
||||
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
/** Run duration in milliseconds. */
|
||||
durationMs: number | null;
|
||||
/** Error message if the run failed. */
|
||||
error: string | null;
|
||||
/** Run logs. */
|
||||
logs: string[];
|
||||
/** ISO 8601 start timestamp. */
|
||||
startedAt: Date | null;
|
||||
/** ISO 8601 finish timestamp. */
|
||||
finishedAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Webhook Delivery – represents a row in the `plugin_webhook_deliveries` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an inbound webhook delivery record.
|
||||
*/
|
||||
export interface PluginWebhookDeliveryRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Webhook endpoint key matching the manifest. */
|
||||
webhookKey: string;
|
||||
/** External identifier from the remote system. */
|
||||
externalId: string | null;
|
||||
/** Delivery status. */
|
||||
status: "pending" | "success" | "failed";
|
||||
/** Processing duration in milliseconds. */
|
||||
durationMs: number | null;
|
||||
/** Error message if processing failed. */
|
||||
error: string | null;
|
||||
/** Webhook payload. */
|
||||
payload: Record<string, unknown>;
|
||||
/** Webhook headers. */
|
||||
headers: Record<string, string>;
|
||||
/** ISO 8601 start timestamp. */
|
||||
startedAt: Date | null;
|
||||
/** ISO 8601 finish timestamp. */
|
||||
finishedAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -142,3 +142,39 @@ export {
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
export {
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./plugin.js";
|
||||
|
||||
670
packages/shared/src/validators/plugin.ts
Normal file
670
packages/shared/src/validators/plugin.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
} from "../constants.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Permissive validator for JSON Schema objects. Accepts any `Record<string, unknown>`
|
||||
* that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`).
|
||||
* Empty objects are also accepted.
|
||||
*
|
||||
* Used to validate `instanceConfigSchema` and `parametersSchema` fields in the
|
||||
* plugin manifest without fully parsing JSON Schema.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||
*/
|
||||
export const jsonSchemaSchema = z.record(z.unknown()).refine(
|
||||
(val) => {
|
||||
// Must have a "type" field if non-empty, or be a valid JSON Schema object
|
||||
if (Object.keys(val).length === 0) return true;
|
||||
return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined;
|
||||
},
|
||||
{ message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest sub-type schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginJobDeclaration} — a scheduled job declared in the
|
||||
* plugin manifest. Requires `jobKey` and `displayName`; `description` and
|
||||
* `schedule` (cron expression) are optional.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
*/
|
||||
/**
|
||||
* Validates a cron expression has exactly 5 whitespace-separated fields,
|
||||
* each containing only valid cron characters (digits, *, /, -, ,).
|
||||
*
|
||||
* Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists.
|
||||
*/
|
||||
const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/;
|
||||
|
||||
function isValidCronExpression(expression: string): boolean {
|
||||
const trimmed = expression.trim();
|
||||
if (!trimmed) return false;
|
||||
const fields = trimmed.split(/\s+/);
|
||||
if (fields.length !== 5) return false;
|
||||
return fields.every((f) => CRON_FIELD_PATTERN.test(f));
|
||||
}
|
||||
|
||||
export const pluginJobDeclarationSchema = z.object({
|
||||
jobKey: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
schedule: z.string().refine(
|
||||
(val) => isValidCronExpression(val),
|
||||
{ message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" },
|
||||
).optional(),
|
||||
});
|
||||
|
||||
export type PluginJobDeclarationInput = z.infer<typeof pluginJobDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared
|
||||
* in the plugin manifest. Requires `endpointKey` and `displayName`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||
*/
|
||||
export const pluginWebhookDeclarationSchema = z.object({
|
||||
endpointKey: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PluginWebhookDeclarationInput = z.infer<typeof pluginWebhookDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginToolDeclaration} — an agent tool contributed by the
|
||||
* plugin. Requires `name`, `displayName`, `description`, and a valid
|
||||
* `parametersSchema`. Requires the `agent.tools.register` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
*/
|
||||
export const pluginToolDeclarationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
parametersSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||
* requirements such as `entityTypes` for context-sensitive slots.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export const pluginUiSlotDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_UI_SLOT_TYPES),
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
exportName: z.string().min(1),
|
||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
|
||||
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
|
||||
}).optional(),
|
||||
order: z.number().int().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
// context-sensitive slots require explicit entity targeting.
|
||||
const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"];
|
||||
if (
|
||||
entityScopedTypes.includes(value.type)
|
||||
&& (!value.entityTypes || value.entityTypes.length === 0)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.type} slots require at least one entityType`,
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// projectSidebarItem only makes sense for entityType "project".
|
||||
if (value.type === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "projectSidebarItem slots require entityTypes to include \"project\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// commentAnnotation only makes sense for entityType "comment".
|
||||
if (value.type === "commentAnnotation" && value.entityTypes && !value.entityTypes.includes("comment")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "commentAnnotation slots require entityTypes to include \"comment\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// commentContextMenuItem only makes sense for entityType "comment".
|
||||
if (value.type === "commentContextMenuItem" && value.entityTypes && !value.entityTypes.includes("comment")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "commentContextMenuItem slots require entityTypes to include \"comment\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `routePath "${value.routePath}" is reserved by the host`,
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
|
||||
|
||||
const entityScopedLauncherPlacementZones = [
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"projectSidebarItem",
|
||||
] as const;
|
||||
|
||||
const launcherBoundsByEnvironment: Record<
|
||||
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number],
|
||||
readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][]
|
||||
> = {
|
||||
hostInline: ["inline", "compact", "default"],
|
||||
hostOverlay: ["compact", "default", "wide", "full"],
|
||||
hostRoute: ["default", "wide", "full"],
|
||||
external: [],
|
||||
iframe: ["compact", "default", "wide", "full"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the action payload for a declarative plugin launcher.
|
||||
*/
|
||||
export const pluginLauncherActionDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
|
||||
target: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.type === "performAction" && value.target.includes("/")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "performAction launchers must target an action key, not a route or URL",
|
||||
path: ["target"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type === "navigate" && /^https?:\/\//.test(value.target)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "navigate launchers must target a host route, not an absolute URL",
|
||||
path: ["target"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherActionDeclarationInput =
|
||||
z.infer<typeof pluginLauncherActionDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates optional render hints for a plugin launcher destination.
|
||||
*/
|
||||
export const pluginLauncherRenderDeclarationSchema = z.object({
|
||||
environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS),
|
||||
bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const supportedBounds = launcherBoundsByEnvironment[value.environment];
|
||||
if (!supportedBounds.includes(value.bounds)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`,
|
||||
path: ["bounds"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherRenderDeclarationInput =
|
||||
z.infer<typeof pluginLauncherRenderDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates declarative launcher metadata in a plugin manifest.
|
||||
*/
|
||||
export const pluginLauncherDeclarationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES),
|
||||
exportName: z.string().min(1).optional(),
|
||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||
order: z.number().int().optional(),
|
||||
action: pluginLauncherActionDeclarationSchema,
|
||||
render: pluginLauncherRenderDeclarationSchema.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (
|
||||
entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone)
|
||||
&& (!value.entityTypes || value.entityTypes.length === 0)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.placementZone} launchers require at least one entityType`,
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value.placementZone === "projectSidebarItem"
|
||||
&& value.entityTypes
|
||||
&& !value.entityTypes.includes("project")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "projectSidebarItem launchers require entityTypes to include \"project\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "performAction" && value.render) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "performAction launchers cannot declare render hints",
|
||||
path: ["render"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
["openModal", "openDrawer", "openPopover"].includes(value.action.type)
|
||||
&& !value.render
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.action.type} launchers require render metadata`,
|
||||
path: ["render"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "openModal" && value.render?.environment === "hostInline") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openModal launchers cannot use the hostInline render environment",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value.action.type === "openDrawer"
|
||||
&& value.render
|
||||
&& !["hostOverlay", "iframe"].includes(value.render.environment)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openDrawer launchers must use hostOverlay or iframe render environments",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openPopover launchers cannot use the hostRoute render environment",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1 schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime
|
||||
* validator for plugin manifests read at install time.
|
||||
*
|
||||
* Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules):
|
||||
*
|
||||
* | Field | Type | Constraints |
|
||||
* |--------------------------|------------|----------------------------------------------|
|
||||
* | `id` | string | `^[a-z0-9][a-z0-9._-]*$` |
|
||||
* | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` |
|
||||
* | `version` | string | semver (`\d+\.\d+\.\d+`) |
|
||||
* | `displayName` | string | 1–100 chars |
|
||||
* | `description` | string | 1–500 chars |
|
||||
* | `author` | string | 1–200 chars |
|
||||
* | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES |
|
||||
* | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`|
|
||||
* | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` |
|
||||
* | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES|
|
||||
* | `entrypoints.worker` | string | min 1 char |
|
||||
* | `entrypoints.ui` | string? | required when `ui.slots` is declared |
|
||||
*
|
||||
* Cross-field rules enforced via `superRefine`:
|
||||
* - `entrypoints.ui` required when `ui.slots` declared
|
||||
* - `agent.tools.register` capability required when `tools` declared
|
||||
* - `jobs.schedule` capability required when `jobs` declared
|
||||
* - `webhooks.receive` capability required when `webhooks` declared
|
||||
* - duplicate `jobs[].jobKey` values are rejected
|
||||
* - duplicate `webhooks[].endpointKey` values are rejected
|
||||
* - duplicate `tools[].name` values are rejected
|
||||
* - duplicate `ui.slots[].id` values are rejected
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||
* @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type
|
||||
*/
|
||||
export const pluginManifestV1Schema = z.object({
|
||||
id: z.string().min(1).regex(
|
||||
/^[a-z0-9][a-z0-9._-]*$/,
|
||||
"Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
|
||||
),
|
||||
apiVersion: z.literal(1),
|
||||
version: z.string().min(1).regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)",
|
||||
),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(200),
|
||||
categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1),
|
||||
minimumHostVersion: z.string().regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"minimumHostVersion must follow semver (e.g. 1.0.0)",
|
||||
).optional(),
|
||||
minimumPaperclipVersion: z.string().regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
|
||||
).optional(),
|
||||
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
|
||||
entrypoints: z.object({
|
||||
worker: z.string().min(1),
|
||||
ui: z.string().min(1).optional(),
|
||||
}),
|
||||
instanceConfigSchema: jsonSchemaSchema.optional(),
|
||||
jobs: z.array(pluginJobDeclarationSchema).optional(),
|
||||
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
|
||||
tools: z.array(pluginToolDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
}).optional(),
|
||||
}).superRefine((manifest, ctx) => {
|
||||
// ── Entrypoint ↔ UI slot consistency ──────────────────────────────────
|
||||
// Plugins that declare UI slots must also declare a UI entrypoint so the
|
||||
// host knows where to load the bundle from (PLUGIN_SPEC.md §10.1).
|
||||
const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0;
|
||||
const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0;
|
||||
if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "entrypoints.ui is required when ui.slots or ui.launchers are declared",
|
||||
path: ["entrypoints", "ui"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
manifest.minimumHostVersion
|
||||
&& manifest.minimumPaperclipVersion
|
||||
&& manifest.minimumHostVersion !== manifest.minimumPaperclipVersion
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared",
|
||||
path: ["minimumHostVersion"],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Capability ↔ feature declaration consistency ───────────────────────
|
||||
// The host enforces capabilities at install and runtime. A plugin must
|
||||
// declare every capability it needs up-front; silently having more features
|
||||
// than capabilities would cause runtime rejections.
|
||||
|
||||
// tools require agent.tools.register (PLUGIN_SPEC.md §11)
|
||||
if (manifest.tools && manifest.tools.length > 0) {
|
||||
if (!manifest.capabilities.includes("agent.tools.register")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'agent.tools.register' is required when tools are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'jobs.schedule' is required when jobs are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// webhooks require webhooks.receive (PLUGIN_SPEC.md §18)
|
||||
if (manifest.webhooks && manifest.webhooks.length > 0) {
|
||||
if (!manifest.capabilities.includes("webhooks.receive")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'webhooks.receive' is required when webhooks are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Uniqueness checks ──────────────────────────────────────────────────
|
||||
// Duplicate keys within a plugin's own manifest are always a bug. The host
|
||||
// would not know which declaration takes precedence, so we reject early.
|
||||
|
||||
// job keys must be unique within the plugin (used as identifiers in the DB)
|
||||
if (manifest.jobs) {
|
||||
const jobKeys = manifest.jobs.map((j) => j.jobKey);
|
||||
const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["jobs"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// webhook endpoint keys must be unique within the plugin (used in routes)
|
||||
if (manifest.webhooks) {
|
||||
const endpointKeys = manifest.webhooks.map((w) => w.endpointKey);
|
||||
const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["webhooks"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// tool names must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.tools) {
|
||||
const toolNames = manifest.tools.map((t) => t.name);
|
||||
const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["tools"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
const slotIds = manifest.ui.slots.map((s) => s.id);
|
||||
const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["ui", "slots"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launcher ids must be unique within the plugin
|
||||
const allLaunchers = [
|
||||
...(manifest.launchers ?? []),
|
||||
...(manifest.ui?.launchers ?? []),
|
||||
];
|
||||
if (allLaunchers.length > 0) {
|
||||
const launcherIds = allLaunchers.map((launcher) => launcher.id);
|
||||
const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginManifestV1Input = z.infer<typeof pluginManifestV1Schema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin installation / registration request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for installing (registering) a plugin.
|
||||
* The server receives the packageName and resolves the manifest from the
|
||||
* installed package.
|
||||
*/
|
||||
export const installPluginSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
version: z.string().min(1).optional(),
|
||||
/** Set by loader for local-path installs so the worker can be resolved. */
|
||||
packagePath: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type InstallPlugin = z.infer<typeof installPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin config (instance configuration) schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for creating or updating a plugin's instance configuration.
|
||||
* configJson is validated permissively here; runtime validation against
|
||||
* the plugin's instanceConfigSchema is done at the service layer.
|
||||
*/
|
||||
export const upsertPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
|
||||
|
||||
/**
|
||||
* Schema for partially updating a plugin's instance configuration.
|
||||
* Allows a partial merge of config values.
|
||||
*/
|
||||
export const patchPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin status update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for updating a plugin's lifecycle status. Used by the lifecycle
|
||||
* manager to persist state transitions.
|
||||
*
|
||||
* @see {@link PLUGIN_STATUSES} for the valid status values
|
||||
*/
|
||||
export const updatePluginStatusSchema = z.object({
|
||||
status: z.enum(PLUGIN_STATUSES),
|
||||
lastError: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdatePluginStatus = z.infer<typeof updatePluginStatusSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Schema for the uninstall request. `removeData` controls hard vs soft delete. */
|
||||
export const uninstallPluginSchema = z.object({
|
||||
removeData: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type UninstallPlugin = z.infer<typeof uninstallPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin state (key-value storage) schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for a plugin state scope key — identifies the exact location where
|
||||
* state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` SDK methods.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugin_state`
|
||||
*/
|
||||
export const pluginStateScopeKeySchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
stateKey: z.string().min(1),
|
||||
});
|
||||
|
||||
export type PluginStateScopeKey = z.infer<typeof pluginStateScopeKeySchema>;
|
||||
|
||||
/**
|
||||
* Schema for setting a plugin state value.
|
||||
*/
|
||||
export const setPluginStateSchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
stateKey: z.string().min(1),
|
||||
/** JSON-serializable value to store. */
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export type SetPluginState = z.infer<typeof setPluginStateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for querying plugin state entries. All fields are optional to allow
|
||||
* flexible list queries (e.g. all state for a plugin within a scope).
|
||||
*/
|
||||
export const listPluginStateSchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type ListPluginState = z.infer<typeof listPluginStateSchema>;
|
||||
@@ -1,6 +1,8 @@
|
||||
packages:
|
||||
- packages/*
|
||||
- packages/adapters/*
|
||||
- packages/plugins/*
|
||||
- packages/plugins/examples/*
|
||||
- server
|
||||
- ui
|
||||
- cli
|
||||
|
||||
46
scripts/ensure-plugin-build-deps.mjs
Normal file
46
scripts/ensure-plugin-build-deps.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, "..");
|
||||
const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc");
|
||||
|
||||
const buildTargets = [
|
||||
{
|
||||
name: "@paperclipai/shared",
|
||||
output: path.join(rootDir, "packages/shared/dist/index.js"),
|
||||
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
|
||||
},
|
||||
{
|
||||
name: "@paperclipai/plugin-sdk",
|
||||
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
|
||||
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
|
||||
},
|
||||
];
|
||||
|
||||
if (!fs.existsSync(tscCliPath)) {
|
||||
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
|
||||
}
|
||||
|
||||
for (const target of buildTargets) {
|
||||
if (fs.existsSync(target.output)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
@@ -43,8 +43,12 @@
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "1.4.18",
|
||||
"chokidar": "^4.0.3",
|
||||
"detect-port": "^2.1.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
|
||||
68
server/src/__tests__/plugin-dev-watcher.test.ts
Normal file
68
server/src/__tests__/plugin-dev-watcher.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolvePluginWatchTargets } from "../services/plugin-dev-watcher.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempPluginDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-plugin-watch-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("resolvePluginWatchTargets", () => {
|
||||
it("watches package metadata plus concrete declared runtime files", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@acme/example",
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "worker.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "ui", "index.css"), "body {}\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.css"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "ui", "index.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "worker.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to dist when package metadata does not declare entrypoints", () => {
|
||||
const pluginDir = makeTempPluginDir();
|
||||
mkdirSync(path.join(pluginDir, "dist", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify({ name: "@acme/example" }));
|
||||
writeFileSync(path.join(pluginDir, "dist", "manifest.js"), "export default {};\n");
|
||||
writeFileSync(path.join(pluginDir, "dist", "nested", "chunk.js"), "export default {};\n");
|
||||
|
||||
const targets = resolvePluginWatchTargets(pluginDir);
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ path: path.join(pluginDir, "package.json"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "manifest.js"), recursive: false, kind: "file" },
|
||||
{ path: path.join(pluginDir, "dist", "nested", "chunk.js"), recursive: false, kind: "file" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
expect(
|
||||
formatWorkerFailureMessage(
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
),
|
||||
).toBe(
|
||||
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate stderr that is already present", () => {
|
||||
const message = [
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"",
|
||||
"Worker stderr:",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
||||
).toBe(message);
|
||||
});
|
||||
|
||||
it("keeps only the latest stderr excerpt", () => {
|
||||
let excerpt = "";
|
||||
excerpt = appendStderrExcerpt(excerpt, "first line");
|
||||
excerpt = appendStderrExcerpt(excerpt, "second line");
|
||||
|
||||
expect(excerpt).toContain("first line");
|
||||
expect(excerpt).toContain("second line");
|
||||
|
||||
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
||||
|
||||
expect(excerpt).not.toContain("first line");
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,23 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
||||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
||||
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
||||
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
||||
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
||||
import { pluginRegistryService } from "./services/plugin-registry.js";
|
||||
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
@@ -41,13 +57,20 @@ export async function createApp(
|
||||
bindHost: string;
|
||||
authReady: boolean;
|
||||
companyDeletionEnabled: boolean;
|
||||
instanceId?: string;
|
||||
hostVersion?: string;
|
||||
localPluginDir?: string;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
},
|
||||
) {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({
|
||||
verify: (req, _res, buf) => {
|
||||
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
||||
},
|
||||
}));
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled =
|
||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
|
||||
@@ -114,6 +137,68 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus();
|
||||
const jobStore = pluginJobStore(db);
|
||||
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||
const scheduler = createPluginJobScheduler({
|
||||
db,
|
||||
jobStore,
|
||||
workerManager,
|
||||
});
|
||||
const toolDispatcher = createPluginToolDispatcher({
|
||||
workerManager,
|
||||
lifecycleManager: lifecycle,
|
||||
db,
|
||||
});
|
||||
const jobCoordinator = createPluginJobCoordinator({
|
||||
db,
|
||||
lifecycle,
|
||||
scheduler,
|
||||
jobStore,
|
||||
});
|
||||
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
||||
const loader = pluginLoader(
|
||||
db,
|
||||
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
|
||||
{
|
||||
workerManager,
|
||||
eventBus,
|
||||
jobScheduler: scheduler,
|
||||
jobStore,
|
||||
toolDispatcher,
|
||||
lifecycleManager: lifecycle,
|
||||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
},
|
||||
buildHostHandlers: (pluginId, manifest) => {
|
||||
const notifyWorker = (method: string, params: unknown) => {
|
||||
const handle = workerManager.getWorker(pluginId);
|
||||
if (handle) handle.notify(method, params);
|
||||
};
|
||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker);
|
||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||
return createHostClientHandlers({
|
||||
pluginId,
|
||||
capabilities: manifest.capabilities,
|
||||
services,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
api.use(
|
||||
pluginRoutes(
|
||||
db,
|
||||
loader,
|
||||
{ scheduler, jobStore },
|
||||
{ workerManager },
|
||||
{ toolDispatcher },
|
||||
{ workerManager },
|
||||
),
|
||||
);
|
||||
api.use(
|
||||
accessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
@@ -126,6 +211,9 @@ export async function createApp(
|
||||
app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "API route not found" });
|
||||
});
|
||||
app.use(pluginUiStaticRoutes(db, {
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
}));
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (opts.uiMode === "static") {
|
||||
@@ -179,5 +267,35 @@ export async function createApp(
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
jobCoordinator.start();
|
||||
scheduler.start();
|
||||
void toolDispatcher.initialize().catch((err) => {
|
||||
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
|
||||
});
|
||||
const devWatcher = opts.uiMode === "vite-dev"
|
||||
? createPluginDevWatcher(
|
||||
lifecycle,
|
||||
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
||||
)
|
||||
: null;
|
||||
void loader.loadAll().then((result) => {
|
||||
if (!result) return;
|
||||
for (const loaded of result.results) {
|
||||
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
|
||||
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
process.once("exit", () => {
|
||||
devWatcher?.close();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void flushPluginLogBuffer();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
496
server/src/routes/plugin-ui-static.ts
Normal file
496
server/src/routes/plugin-ui-static.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @fileoverview Plugin UI static file serving route
|
||||
*
|
||||
* Serves plugin UI bundles from the plugin's dist/ui/ directory under the
|
||||
* `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md
|
||||
* §19.0.3 (Bundle Serving).
|
||||
*
|
||||
* Plugin UI bundles are pre-built ESM that the host serves as static assets.
|
||||
* The host dynamically imports the plugin's UI entry module from this path,
|
||||
* resolves the named export declared in `ui.slots[].exportName`, and mounts
|
||||
* it into the extension slot.
|
||||
*
|
||||
* Security:
|
||||
* - Path traversal is prevented by resolving the requested path and verifying
|
||||
* it stays within the plugin's UI directory.
|
||||
* - Only plugins in 'ready' status have their UI served.
|
||||
* - Only plugins that declare `entrypoints.ui` serve UI bundles.
|
||||
*
|
||||
* Cache Headers:
|
||||
* - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`)
|
||||
* receive `Cache-Control: public, max-age=31536000, immutable`.
|
||||
* - Other files receive `Cache-Control: public, max-age=0, must-revalidate`
|
||||
* with ETag-based conditional request support.
|
||||
*
|
||||
* @module server/routes/plugin-ui-static
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Regex to detect content-hashed filenames.
|
||||
*
|
||||
* Matches patterns like:
|
||||
* - `index-a1b2c3d4.js`
|
||||
* - `styles.abc123def.css`
|
||||
* - `chunk-ABCDEF01.mjs`
|
||||
*
|
||||
* The hash portion must be at least 8 hex characters to avoid false positives.
|
||||
*/
|
||||
const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/;
|
||||
|
||||
/**
|
||||
* Cache-Control header for content-hashed files.
|
||||
* These files are immutable by definition (the hash changes when content changes).
|
||||
*/
|
||||
/** 1 year in seconds — standard for content-hashed immutable resources. */
|
||||
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000
|
||||
const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`;
|
||||
|
||||
/**
|
||||
* Cache-Control header for non-hashed files.
|
||||
* These files must be revalidated on each request (ETag-based).
|
||||
*/
|
||||
const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";
|
||||
|
||||
/**
|
||||
* MIME types for common plugin UI bundle file extensions.
|
||||
*/
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".ico": "image/x-icon",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a plugin's UI directory from its package location.
|
||||
*
|
||||
* The plugin's `packageName` is stored in the DB. We resolve the package path
|
||||
* from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in
|
||||
* `node_modules`. If the plugin was installed from a local path, the manifest
|
||||
* `entrypoints.ui` path is resolved relative to the package directory.
|
||||
*
|
||||
* @param localPluginDir - The plugin installation directory
|
||||
* @param packageName - The npm package name
|
||||
* @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/")
|
||||
* @returns Absolute path to the UI directory, or null if not found
|
||||
*/
|
||||
export function resolvePluginUiDir(
|
||||
localPluginDir: string,
|
||||
packageName: string,
|
||||
entrypointsUi: string,
|
||||
packagePath?: string | null,
|
||||
): string | null {
|
||||
// For local-path installs, prefer the persisted package path.
|
||||
if (packagePath) {
|
||||
const resolvedPackagePath = path.resolve(packagePath);
|
||||
if (fs.existsSync(resolvedPackagePath)) {
|
||||
const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi);
|
||||
if (
|
||||
uiDirFromPackagePath.startsWith(resolvedPackagePath)
|
||||
&& fs.existsSync(uiDirFromPackagePath)
|
||||
) {
|
||||
return uiDirFromPackagePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the package root within the local plugin directory's node_modules.
|
||||
// npm installs go to <localPluginDir>/node_modules/<packageName>/
|
||||
let packageRoot: string;
|
||||
if (packageName.startsWith("@")) {
|
||||
// Scoped package: @scope/name -> node_modules/@scope/name
|
||||
packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
||||
} else {
|
||||
packageRoot = path.join(localPluginDir, "node_modules", packageName);
|
||||
}
|
||||
|
||||
// If the standard location doesn't exist, the plugin may have been installed
|
||||
// from a local path. Try to check if the package.json is accessible at the
|
||||
// computed path or if the package is found elsewhere.
|
||||
if (!fs.existsSync(packageRoot)) {
|
||||
// For local-path installs, the packageName may be a directory that doesn't
|
||||
// live inside node_modules. Check if the package exists directly at the
|
||||
// localPluginDir level.
|
||||
const directPath = path.join(localPluginDir, packageName);
|
||||
if (fs.existsSync(directPath)) {
|
||||
packageRoot = directPath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the UI directory relative to the package root
|
||||
const uiDir = path.resolve(packageRoot, entrypointsUi);
|
||||
|
||||
// Verify the resolved UI directory exists and is actually inside the package
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uiDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an ETag from file stat (size + mtime).
|
||||
* This is a lightweight approach that avoids reading the file content.
|
||||
*/
|
||||
function computeETag(size: number, mtimeMs: number): string {
|
||||
const ETAG_VERSION = "v2";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return `"${hash}"`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for the plugin UI static route.
|
||||
*/
|
||||
export interface PluginUiStaticRouteOptions {
|
||||
/**
|
||||
* The local plugin installation directory.
|
||||
* This is where plugins are installed via `npm install --prefix`.
|
||||
* Defaults to the standard `~/.paperclip/plugins/` location.
|
||||
*/
|
||||
localPluginDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Express router that serves plugin UI static files.
|
||||
*
|
||||
* This route handles `GET /_plugins/:pluginId/ui/*` requests by:
|
||||
* 1. Looking up the plugin in the registry by ID or key
|
||||
* 2. Verifying the plugin is in 'ready' status with UI declared
|
||||
* 3. Resolving the file path within the plugin's dist/ui/ directory
|
||||
* 4. Serving the file with appropriate cache headers
|
||||
*
|
||||
* @param db - Database connection for plugin registry lookups
|
||||
* @param options - Configuration options
|
||||
* @returns Express router
|
||||
*/
|
||||
export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) {
|
||||
const router = Router();
|
||||
const registry = pluginRegistryService(db);
|
||||
const log = logger.child({ service: "plugin-ui-static" });
|
||||
|
||||
/**
|
||||
* GET /_plugins/:pluginId/ui/*
|
||||
*
|
||||
* Serve a static file from a plugin's UI bundle directory.
|
||||
*
|
||||
* The :pluginId parameter accepts either:
|
||||
* - Database UUID
|
||||
* - Plugin key (e.g., "acme.linear")
|
||||
*
|
||||
* The wildcard captures the relative file path within the UI directory.
|
||||
*
|
||||
* Cache strategy:
|
||||
* - Content-hashed filenames → immutable, 1-year max-age
|
||||
* - Other files → must-revalidate with ETag
|
||||
*/
|
||||
router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
// Extract the relative file path from the named wildcard.
|
||||
// In Express 5 with path-to-regexp v8, named wildcards may return
|
||||
// an array of path segments or a single string.
|
||||
const rawParam = req.params.filePath;
|
||||
const rawFilePath = Array.isArray(rawParam)
|
||||
? rawParam.join("/")
|
||||
: rawParam as string | undefined;
|
||||
|
||||
if (!rawFilePath || rawFilePath.length === 0) {
|
||||
res.status(400).json({ error: "File path is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Look up the plugin
|
||||
let plugin = null;
|
||||
try {
|
||||
plugin = await registry.getById(pluginId);
|
||||
} catch (error) {
|
||||
const maybeCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (maybeCode !== "22P02") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!plugin) {
|
||||
plugin = await registry.getByKey(pluginId);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Verify the plugin is ready and has UI declared
|
||||
if (plugin.status !== "ready") {
|
||||
res.status(403).json({
|
||||
error: `Plugin UI is not available (status: ${plugin.status})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = plugin.manifestJson;
|
||||
if (!manifest?.entrypoints?.ui) {
|
||||
res.status(404).json({ error: "Plugin does not declare a UI bundle" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
|
||||
// when a plugin author has configured a dev server URL for hot-reload.
|
||||
// See PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
try {
|
||||
const configRow = await registry.getConfig(plugin.id);
|
||||
const devUiUrl =
|
||||
configRow &&
|
||||
typeof configRow === "object" &&
|
||||
"configJson" in configRow &&
|
||||
(configRow as { configJson: Record<string, unknown> }).configJson?.devUiUrl;
|
||||
|
||||
if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
|
||||
// Dev proxy is only available in development mode
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id },
|
||||
"plugin-ui-static: devUiUrl ignored in production",
|
||||
);
|
||||
// Fall through to static file serving below
|
||||
} else {
|
||||
// Guard against rawFilePath overriding the base URL via protocol
|
||||
// scheme (e.g. "https://evil.com/x") or protocol-relative paths
|
||||
// (e.g. "//evil.com/x") which cause `new URL(path, base)` to
|
||||
// ignore the base entirely.
|
||||
// Normalize percent-encoding so encoded slashes (%2F) can't bypass
|
||||
// the protocol/path checks below.
|
||||
let decodedPath: string;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(rawFilePath);
|
||||
} catch {
|
||||
res.status(400).json({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
decodedPath.includes("://") ||
|
||||
decodedPath.startsWith("//") ||
|
||||
decodedPath.startsWith("\\\\")
|
||||
) {
|
||||
res.status(400).json({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy the request to the dev server
|
||||
const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/");
|
||||
|
||||
// SSRF protection: only allow http/https and localhost targets for dev proxy
|
||||
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
||||
res.status(400).json({ error: "devUiUrl must use http or https protocol" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev proxy is restricted to loopback addresses only.
|
||||
// Validate the *constructed* targetUrl hostname (not the base) to
|
||||
// catch any path-based override that slipped past the checks above.
|
||||
const devHost = targetUrl.hostname;
|
||||
const isLoopback =
|
||||
devHost === "localhost" ||
|
||||
devHost === "127.0.0.1" ||
|
||||
devHost === "::1" ||
|
||||
devHost === "[::1]";
|
||||
if (!isLoopback) {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id, devUiUrl, host: devHost },
|
||||
"plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
|
||||
);
|
||||
res.status(400).json({ error: "devUiUrl must target localhost" });
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{ pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
|
||||
"plugin-ui-static: proxying to devUiUrl",
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await fetch(targetUrl.href, { signal: controller.signal });
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).json({
|
||||
error: `Dev server returned ${upstream.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get("content-type");
|
||||
if (contentType) res.set("Content-Type", contentType);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
const body = await upstream.arrayBuffer();
|
||||
res.send(Buffer.from(body));
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (proxyErr) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
devUiUrl,
|
||||
err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr),
|
||||
},
|
||||
"plugin-ui-static: failed to proxy to devUiUrl, falling back to static",
|
||||
);
|
||||
// Fall through to static serving below
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config lookup failure is non-fatal — fall through to static serving
|
||||
}
|
||||
|
||||
// Step 3: Resolve the plugin's UI directory
|
||||
const uiDir = resolvePluginUiDir(
|
||||
options.localPluginDir,
|
||||
plugin.packageName,
|
||||
manifest.entrypoints.ui,
|
||||
plugin.packagePath,
|
||||
);
|
||||
|
||||
if (!uiDir) {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName },
|
||||
"plugin-ui-static: UI directory not found on disk",
|
||||
);
|
||||
res.status(404).json({ error: "Plugin UI directory not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Resolve the requested file path and prevent traversal (including symlinks)
|
||||
const resolvedFilePath = path.resolve(uiDir, rawFilePath);
|
||||
|
||||
// Step 5: Check that the file exists and is a regular file
|
||||
let fileStat: fs.Stats;
|
||||
try {
|
||||
fileStat = fs.statSync(resolvedFilePath);
|
||||
} catch {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: resolve symlinks via realpathSync and verify containment.
|
||||
// This prevents symlink-based traversal that string-based startsWith misses.
|
||||
let realFilePath: string;
|
||||
let realUiDir: string;
|
||||
try {
|
||||
realFilePath = fs.realpathSync(resolvedFilePath);
|
||||
realUiDir = fs.realpathSync(uiDir);
|
||||
} catch {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const relative = path.relative(realUiDir, realFilePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
res.status(403).json({ error: "Access denied" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Determine cache strategy based on filename
|
||||
const basename = path.basename(resolvedFilePath);
|
||||
const isContentHashed = CONTENT_HASH_PATTERN.test(basename);
|
||||
|
||||
// Step 7: Set cache headers
|
||||
if (isContentHashed) {
|
||||
res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
|
||||
} else {
|
||||
res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);
|
||||
|
||||
// Compute and set ETag for conditional request support
|
||||
const etag = computeETag(fileStat.size, fileStat.mtimeMs);
|
||||
res.set("ETag", etag);
|
||||
|
||||
// Check If-None-Match for 304 Not Modified
|
||||
const ifNoneMatch = req.headers["if-none-match"];
|
||||
if (ifNoneMatch === etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Set Content-Type
|
||||
const ext = path.extname(resolvedFilePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext];
|
||||
if (contentType) {
|
||||
res.set("Content-Type", contentType);
|
||||
}
|
||||
|
||||
// Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev)
|
||||
res.set("Access-Control-Allow-Origin", "*");
|
||||
|
||||
// Step 10: Send the file
|
||||
// The plugin source can live in Git worktrees (e.g. ".worktrees/...").
|
||||
// `send` defaults to dotfiles:"ignore", which treats dot-directories as
|
||||
// not found. We already enforce traversal safety above, so allow dot paths.
|
||||
res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
{ err, pluginId: plugin.id, filePath: resolvedFilePath },
|
||||
"plugin-ui-static: error sending file",
|
||||
);
|
||||
// Only send error if headers haven't been sent yet
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to serve file" });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
2219
server/src/routes/plugins.ts
Normal file
2219
server/src/routes/plugins.ts
Normal file
File diff suppressed because it is too large
Load Diff
373
server/src/services/cron.ts
Normal file
373
server/src/services/cron.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Lightweight cron expression parser and next-run calculator.
|
||||
*
|
||||
* Supports standard 5-field cron expressions:
|
||||
*
|
||||
* ┌────────────── minute (0–59)
|
||||
* │ ┌──────────── hour (0–23)
|
||||
* │ │ ┌────────── day of month (1–31)
|
||||
* │ │ │ ┌──────── month (1–12)
|
||||
* │ │ │ │ ┌────── day of week (0–6, Sun=0)
|
||||
* │ │ │ │ │
|
||||
* * * * * *
|
||||
*
|
||||
* Supported syntax per field:
|
||||
* - `*` — any value
|
||||
* - `N` — exact value
|
||||
* - `N-M` — range (inclusive)
|
||||
* - `N/S` — start at N, step S (within field bounds)
|
||||
* - `* /S` — every S (from field min) [no space — shown to avoid comment termination]
|
||||
* - `N-M/S` — range with step
|
||||
* - `N,M,...` — list of values, ranges, or steps
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A parsed cron schedule. Each field is a sorted array of valid integer values
|
||||
* for that field.
|
||||
*/
|
||||
export interface ParsedCron {
|
||||
minutes: number[];
|
||||
hours: number[];
|
||||
daysOfMonth: number[];
|
||||
months: number[];
|
||||
daysOfWeek: number[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldSpec {
|
||||
min: number;
|
||||
max: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FIELD_SPECS: FieldSpec[] = [
|
||||
{ min: 0, max: 59, name: "minute" },
|
||||
{ min: 0, max: 23, name: "hour" },
|
||||
{ min: 1, max: 31, name: "day of month" },
|
||||
{ min: 1, max: 12, name: "month" },
|
||||
{ min: 0, max: 6, name: "day of week" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a single cron field token (e.g. `"5"`, `"1-3"`, `"* /10"`, `"1,3,5"`).
|
||||
*
|
||||
* @returns Sorted deduplicated array of matching integer values within bounds.
|
||||
* @throws {Error} on invalid syntax or out-of-range values.
|
||||
*/
|
||||
function parseField(token: string, spec: FieldSpec): number[] {
|
||||
const values = new Set<number>();
|
||||
|
||||
// Split on commas first — each part can be a value, range, or step
|
||||
const parts = token.split(",");
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === "") {
|
||||
throw new Error(`Empty element in cron ${spec.name} field`);
|
||||
}
|
||||
|
||||
// Check for step syntax: "X/S" where X is "*" or a range or a number
|
||||
const slashIdx = trimmed.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
const base = trimmed.slice(0, slashIdx);
|
||||
const stepStr = trimmed.slice(slashIdx + 1);
|
||||
const step = parseInt(stepStr, 10);
|
||||
if (isNaN(step) || step <= 0) {
|
||||
throw new Error(
|
||||
`Invalid step "${stepStr}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
|
||||
let rangeStart = spec.min;
|
||||
let rangeEnd = spec.max;
|
||||
|
||||
if (base === "*") {
|
||||
// */S — every S from field min
|
||||
} else if (base.includes("-")) {
|
||||
// N-M/S — range with step
|
||||
const [a, b] = base.split("-").map((s) => parseInt(s, 10));
|
||||
if (isNaN(a!) || isNaN(b!)) {
|
||||
throw new Error(
|
||||
`Invalid range "${base}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
rangeStart = a!;
|
||||
rangeEnd = b!;
|
||||
} else {
|
||||
// N/S — start at N, step S
|
||||
const start = parseInt(base, 10);
|
||||
if (isNaN(start)) {
|
||||
throw new Error(
|
||||
`Invalid start "${base}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
rangeStart = start;
|
||||
}
|
||||
|
||||
validateBounds(rangeStart, spec);
|
||||
validateBounds(rangeEnd, spec);
|
||||
|
||||
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for range syntax: "N-M"
|
||||
if (trimmed.includes("-")) {
|
||||
const [aStr, bStr] = trimmed.split("-");
|
||||
const a = parseInt(aStr!, 10);
|
||||
const b = parseInt(bStr!, 10);
|
||||
if (isNaN(a) || isNaN(b)) {
|
||||
throw new Error(
|
||||
`Invalid range "${trimmed}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
validateBounds(a, spec);
|
||||
validateBounds(b, spec);
|
||||
if (a > b) {
|
||||
throw new Error(
|
||||
`Invalid range ${a}-${b} in cron ${spec.name} field (start > end)`,
|
||||
);
|
||||
}
|
||||
for (let i = a; i <= b; i++) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wildcard
|
||||
if (trimmed === "*") {
|
||||
for (let i = spec.min; i <= spec.max; i++) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single value
|
||||
const val = parseInt(trimmed, 10);
|
||||
if (isNaN(val)) {
|
||||
throw new Error(
|
||||
`Invalid value "${trimmed}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
validateBounds(val, spec);
|
||||
values.add(val);
|
||||
}
|
||||
|
||||
if (values.size === 0) {
|
||||
throw new Error(`Empty result for cron ${spec.name} field`);
|
||||
}
|
||||
|
||||
return [...values].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function validateBounds(value: number, spec: FieldSpec): void {
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw new Error(
|
||||
`Value ${value} out of range [${spec.min}–${spec.max}] for cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a cron expression string into a structured {@link ParsedCron}.
|
||||
*
|
||||
* @param expression — A standard 5-field cron expression.
|
||||
* @returns Parsed cron with sorted valid values for each field.
|
||||
* @throws {Error} on invalid syntax.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const parsed = parseCron("0 * * * *"); // every hour at minute 0
|
||||
* // parsed.minutes === [0]
|
||||
* // parsed.hours === [0,1,2,...,23]
|
||||
* ```
|
||||
*/
|
||||
export function parseCron(expression: string): ParsedCron {
|
||||
const trimmed = expression.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Cron expression must not be empty");
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length !== 5) {
|
||||
throw new Error(
|
||||
`Cron expression must have exactly 5 fields, got ${tokens.length}: "${trimmed}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
minutes: parseField(tokens[0]!, FIELD_SPECS[0]!),
|
||||
hours: parseField(tokens[1]!, FIELD_SPECS[1]!),
|
||||
daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!),
|
||||
months: parseField(tokens[3]!, FIELD_SPECS[3]!),
|
||||
daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression string. Returns `null` if valid, or an error
|
||||
* message string if invalid.
|
||||
*
|
||||
* @param expression — A cron expression string to validate.
|
||||
* @returns `null` on success, error message on failure.
|
||||
*/
|
||||
export function validateCron(expression: string): string | null {
|
||||
try {
|
||||
parseCron(expression);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time after `after` for the given parsed cron schedule.
|
||||
*
|
||||
* Starts from the minute immediately following `after` and walks forward
|
||||
* until a matching minute is found (up to a safety limit of ~4 years to
|
||||
* prevent infinite loops on impossible schedules).
|
||||
*
|
||||
* @param cron — Parsed cron schedule.
|
||||
* @param after — The reference date. The returned date will be strictly after this.
|
||||
* @returns The next matching `Date`, or `null` if no match found within the search window.
|
||||
*/
|
||||
export function nextCronTick(cron: ParsedCron, after: Date): Date | null {
|
||||
// Work in local minutes — start from the minute after `after`
|
||||
const d = new Date(after.getTime());
|
||||
// Advance to the next whole minute
|
||||
d.setUTCSeconds(0, 0);
|
||||
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
||||
|
||||
// Safety: search up to 4 years worth of minutes (~2.1M iterations max).
|
||||
// Uses 366 to account for leap years.
|
||||
const MAX_CRON_SEARCH_YEARS = 4;
|
||||
const maxIterations = MAX_CRON_SEARCH_YEARS * 366 * 24 * 60;
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const month = d.getUTCMonth() + 1; // 1-12
|
||||
const dayOfMonth = d.getUTCDate(); // 1-31
|
||||
const dayOfWeek = d.getUTCDay(); // 0-6
|
||||
const hour = d.getUTCHours(); // 0-23
|
||||
const minute = d.getUTCMinutes(); // 0-59
|
||||
|
||||
// Check month
|
||||
if (!cron.months.includes(month)) {
|
||||
// Skip to the first day of the next matching month
|
||||
advanceToNextMonth(d, cron.months);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check day of month AND day of week (both must match)
|
||||
if (!cron.daysOfMonth.includes(dayOfMonth) || !cron.daysOfWeek.includes(dayOfWeek)) {
|
||||
// Advance one day
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check hour
|
||||
if (!cron.hours.includes(hour)) {
|
||||
// Advance to next matching hour within the day
|
||||
const nextHour = findNext(cron.hours, hour);
|
||||
if (nextHour !== null) {
|
||||
d.setUTCHours(nextHour, 0, 0, 0);
|
||||
} else {
|
||||
// No matching hour left today — advance to next day
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check minute
|
||||
if (!cron.minutes.includes(minute)) {
|
||||
const nextMin = findNext(cron.minutes, minute);
|
||||
if (nextMin !== null) {
|
||||
d.setUTCMinutes(nextMin, 0, 0);
|
||||
} else {
|
||||
// No matching minute left this hour — advance to next hour
|
||||
d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// All fields match!
|
||||
return new Date(d.getTime());
|
||||
}
|
||||
|
||||
// No match found within the search window
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: parse a cron expression and compute the next run time.
|
||||
*
|
||||
* @param expression — 5-field cron expression string.
|
||||
* @param after — Reference date (defaults to `new Date()`).
|
||||
* @returns The next matching Date, or `null` if no match within 4 years.
|
||||
* @throws {Error} if the cron expression is invalid.
|
||||
*/
|
||||
export function nextCronTickFromExpression(
|
||||
expression: string,
|
||||
after: Date = new Date(),
|
||||
): Date | null {
|
||||
const cron = parseCron(expression);
|
||||
return nextCronTick(cron, after);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the next value in `sortedValues` that is greater than `current`.
|
||||
* Returns `null` if no such value exists.
|
||||
*/
|
||||
function findNext(sortedValues: number[], current: number): number | null {
|
||||
for (const v of sortedValues) {
|
||||
if (v > current) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance `d` (mutated in place) to midnight UTC of the first day of the next
|
||||
* month whose 1-based month number is in `months`.
|
||||
*/
|
||||
function advanceToNextMonth(d: Date, months: number[]): void {
|
||||
let year = d.getUTCFullYear();
|
||||
let month = d.getUTCMonth() + 1; // 1-based
|
||||
|
||||
// Walk months forward until we find one in the set (max 48 iterations = 4 years)
|
||||
for (let i = 0; i < 48; i++) {
|
||||
month++;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year++;
|
||||
}
|
||||
if (months.includes(month)) {
|
||||
d.setUTCFullYear(year, month - 1, 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,21 @@ export function publishLiveEvent(input: {
|
||||
return event;
|
||||
}
|
||||
|
||||
export function publishGlobalLiveEvent(input: {
|
||||
type: LiveEventType;
|
||||
payload?: LiveEventPayload;
|
||||
}) {
|
||||
const event = toLiveEvent({ companyId: "*", type: input.type, payload: input.payload });
|
||||
emitter.emit("*", event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function subscribeCompanyLiveEvents(companyId: string, listener: LiveEventListener) {
|
||||
emitter.on(companyId, listener);
|
||||
return () => emitter.off(companyId, listener);
|
||||
}
|
||||
|
||||
export function subscribeGlobalLiveEvents(listener: LiveEventListener) {
|
||||
emitter.on("*", listener);
|
||||
return () => emitter.off("*", listener);
|
||||
}
|
||||
|
||||
447
server/src/services/plugin-capability-validator.ts
Normal file
447
server/src/services/plugin-capability-validator.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* PluginCapabilityValidator — enforces the capability model at both
|
||||
* install-time and runtime.
|
||||
*
|
||||
* Every plugin declares the capabilities it requires in its manifest
|
||||
* (`manifest.capabilities`). This service checks those declarations
|
||||
* against a mapping of operations → required capabilities so that:
|
||||
*
|
||||
* 1. **Install-time validation** — `validateManifestCapabilities()`
|
||||
* ensures that declared features (tools, jobs, webhooks, UI slots)
|
||||
* have matching capability entries, giving operators clear feedback
|
||||
* before a plugin is activated.
|
||||
*
|
||||
* 2. **Runtime gating** — `checkOperation()` / `assertOperation()` are
|
||||
* called on every worker→host bridge call to enforce least-privilege
|
||||
* access. If a plugin attempts an operation it did not declare, the
|
||||
* call is rejected with a 403 error.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
* @see host-client-factory.ts — SDK-side capability gating
|
||||
*/
|
||||
import type {
|
||||
PluginCapability,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginUiSlotType,
|
||||
PluginLauncherPlacementZone,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability requirement mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps high-level operations to the capabilities they require.
|
||||
*
|
||||
* When the bridge receives a call from a plugin worker, the host looks up
|
||||
* the operation in this map and checks the plugin's declared capabilities.
|
||||
* If any required capability is missing, the call is rejected.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
// Data read operations
|
||||
"companies.list": ["companies.read"],
|
||||
"companies.get": ["companies.read"],
|
||||
"projects.list": ["projects.read"],
|
||||
"projects.get": ["projects.read"],
|
||||
"project.workspaces.list": ["project.workspaces.read"],
|
||||
"project.workspaces.get": ["project.workspaces.read"],
|
||||
"issues.list": ["issues.read"],
|
||||
"issues.get": ["issues.read"],
|
||||
"issue.comments.list": ["issue.comments.read"],
|
||||
"issue.comments.get": ["issue.comments.read"],
|
||||
"agents.list": ["agents.read"],
|
||||
"agents.get": ["agents.read"],
|
||||
"goals.list": ["goals.read"],
|
||||
"goals.get": ["goals.read"],
|
||||
"activity.list": ["activity.read"],
|
||||
"activity.get": ["activity.read"],
|
||||
"costs.list": ["costs.read"],
|
||||
"costs.get": ["costs.read"],
|
||||
|
||||
// Data write operations
|
||||
"issues.create": ["issues.create"],
|
||||
"issues.update": ["issues.update"],
|
||||
"issue.comments.create": ["issue.comments.create"],
|
||||
"activity.log": ["activity.log.write"],
|
||||
"metrics.write": ["metrics.write"],
|
||||
|
||||
// Plugin state operations
|
||||
"plugin.state.get": ["plugin.state.read"],
|
||||
"plugin.state.list": ["plugin.state.read"],
|
||||
"plugin.state.set": ["plugin.state.write"],
|
||||
"plugin.state.delete": ["plugin.state.write"],
|
||||
|
||||
// Runtime / Integration operations
|
||||
"events.subscribe": ["events.subscribe"],
|
||||
"events.emit": ["events.emit"],
|
||||
"jobs.schedule": ["jobs.schedule"],
|
||||
"jobs.cancel": ["jobs.schedule"],
|
||||
"webhooks.receive": ["webhooks.receive"],
|
||||
"http.request": ["http.outbound"],
|
||||
"secrets.resolve": ["secrets.read-ref"],
|
||||
|
||||
// Agent tools
|
||||
"agent.tools.register": ["agent.tools.register"],
|
||||
"agent.tools.execute": ["agent.tools.register"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps UI slot types to the capability required to register them.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
sidebar: "ui.sidebar.register",
|
||||
sidebarPanel: "ui.sidebar.register",
|
||||
projectSidebarItem: "ui.sidebar.register",
|
||||
page: "ui.page.register",
|
||||
detailTab: "ui.detailTab.register",
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
};
|
||||
|
||||
/**
|
||||
* Launcher placement zones align with host UI surfaces and therefore inherit
|
||||
* the same capability requirements as the equivalent slot type.
|
||||
*/
|
||||
const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||||
PluginLauncherPlacementZone,
|
||||
PluginCapability
|
||||
> = {
|
||||
page: "ui.page.register",
|
||||
detailTab: "ui.detailTab.register",
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
sidebar: "ui.sidebar.register",
|
||||
sidebarPanel: "ui.sidebar.register",
|
||||
projectSidebarItem: "ui.sidebar.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps feature declarations in the manifest to their required capabilities.
|
||||
*/
|
||||
const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||
tools: "agent.tools.register",
|
||||
jobs: "jobs.schedule",
|
||||
webhooks: "webhooks.receive",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of a capability check. When `allowed` is false, `missing` contains
|
||||
* the capabilities that the plugin does not declare but the operation requires.
|
||||
*/
|
||||
export interface CapabilityCheckResult {
|
||||
allowed: boolean;
|
||||
missing: PluginCapability[];
|
||||
operation?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginCapabilityValidator interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginCapabilityValidator {
|
||||
/**
|
||||
* Check whether a plugin has a specific capability.
|
||||
*/
|
||||
hasCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capability: PluginCapability,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Check whether a plugin has all of the specified capabilities.
|
||||
*/
|
||||
hasAllCapabilities(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capabilities: PluginCapability[],
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Check whether a plugin has at least one of the specified capabilities.
|
||||
*/
|
||||
hasAnyCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capabilities: PluginCapability[],
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Check whether a plugin is allowed to perform the named operation.
|
||||
*
|
||||
* Operations are mapped to required capabilities via OPERATION_CAPABILITIES.
|
||||
* Unknown operations are rejected by default.
|
||||
*/
|
||||
checkOperation(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Assert that a plugin is allowed to perform an operation.
|
||||
* Throws a 403 HttpError if the capability check fails.
|
||||
*/
|
||||
assertOperation(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Assert that a plugin has a specific capability.
|
||||
* Throws a 403 HttpError if the capability is missing.
|
||||
*/
|
||||
assertCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capability: PluginCapability,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Check whether a plugin can register the given UI slot type.
|
||||
*/
|
||||
checkUiSlot(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
slotType: PluginUiSlotType,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Validate that a manifest's declared capabilities are consistent with its
|
||||
* declared features (tools, jobs, webhooks, UI slots).
|
||||
*
|
||||
* Returns all missing capabilities rather than failing on the first one.
|
||||
* This is useful for install-time validation to give comprehensive feedback.
|
||||
*/
|
||||
validateManifestCapabilities(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Get the capabilities required for a named operation.
|
||||
* Returns an empty array if the operation is unknown.
|
||||
*/
|
||||
getRequiredCapabilities(operation: string): readonly PluginCapability[];
|
||||
|
||||
/**
|
||||
* Get the capability required for a UI slot type.
|
||||
*/
|
||||
getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginCapabilityValidator.
|
||||
*
|
||||
* This service enforces capability gates for plugin operations. The host
|
||||
* uses it to verify that a plugin's declared capabilities permit the
|
||||
* operation it is attempting, both at install time (manifest validation)
|
||||
* and at runtime (bridge call gating).
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const validator = pluginCapabilityValidator();
|
||||
*
|
||||
* // Runtime: gate a bridge call
|
||||
* validator.assertOperation(plugin.manifestJson, "issues.create");
|
||||
*
|
||||
* // Install time: validate manifest consistency
|
||||
* const result = validator.validateManifestCapabilities(manifest);
|
||||
* if (!result.allowed) {
|
||||
* throw badRequest("Missing capabilities", result.missing);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
||||
const log = logger.child({ service: "plugin-capability-validator" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function capabilitySet(manifest: PaperclipPluginManifestV1): Set<PluginCapability> {
|
||||
return new Set(manifest.capabilities);
|
||||
}
|
||||
|
||||
function buildForbiddenMessage(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
missing: PluginCapability[],
|
||||
): string {
|
||||
return (
|
||||
`Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` +
|
||||
`Missing required capabilities: ${missing.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
hasCapability(manifest, capability) {
|
||||
return manifest.capabilities.includes(capability);
|
||||
},
|
||||
|
||||
hasAllCapabilities(manifest, capabilities) {
|
||||
const declared = capabilitySet(manifest);
|
||||
const missing = capabilities.filter((cap) => !declared.has(cap));
|
||||
return {
|
||||
allowed: missing.length === 0,
|
||||
missing,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
hasAnyCapability(manifest, capabilities) {
|
||||
const declared = capabilitySet(manifest);
|
||||
return capabilities.some((cap) => declared.has(cap));
|
||||
},
|
||||
|
||||
checkOperation(manifest, operation) {
|
||||
const required = OPERATION_CAPABILITIES[operation];
|
||||
|
||||
if (!required) {
|
||||
log.warn(
|
||||
{ pluginId: manifest.id, operation },
|
||||
"capability check for unknown operation – rejecting by default",
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
missing: [],
|
||||
operation,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
}
|
||||
|
||||
const declared = capabilitySet(manifest);
|
||||
const missing = required.filter((cap) => !declared.has(cap));
|
||||
|
||||
if (missing.length > 0) {
|
||||
log.debug(
|
||||
{ pluginId: manifest.id, operation, missing },
|
||||
"capability check failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: missing.length === 0,
|
||||
missing,
|
||||
operation,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
assertOperation(manifest, operation) {
|
||||
const result = this.checkOperation(manifest, operation);
|
||||
if (!result.allowed) {
|
||||
const msg = result.missing.length > 0
|
||||
? buildForbiddenMessage(manifest, operation, result.missing)
|
||||
: `Plugin '${manifest.id}' attempted unknown operation '${operation}'`;
|
||||
throw forbidden(msg);
|
||||
}
|
||||
},
|
||||
|
||||
assertCapability(manifest, capability) {
|
||||
if (!this.hasCapability(manifest, capability)) {
|
||||
throw forbidden(
|
||||
`Plugin '${manifest.id}' lacks required capability '${capability}'`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
checkUiSlot(manifest, slotType) {
|
||||
const required = UI_SLOT_CAPABILITIES[slotType];
|
||||
if (!required) {
|
||||
return {
|
||||
allowed: false,
|
||||
missing: [],
|
||||
operation: `ui.${slotType}.register`,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
}
|
||||
|
||||
const has = manifest.capabilities.includes(required);
|
||||
return {
|
||||
allowed: has,
|
||||
missing: has ? [] : [required],
|
||||
operation: `ui.${slotType}.register`,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
validateManifestCapabilities(manifest) {
|
||||
const declared = capabilitySet(manifest);
|
||||
const allMissing: PluginCapability[] = [];
|
||||
|
||||
// Check feature declarations → required capabilities
|
||||
for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) {
|
||||
const featureValue = manifest[feature as keyof PaperclipPluginManifestV1];
|
||||
if (Array.isArray(featureValue) && featureValue.length > 0) {
|
||||
if (!declared.has(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check UI slots → required capabilities
|
||||
const uiSlots = manifest.ui?.slots ?? [];
|
||||
if (uiSlots.length > 0) {
|
||||
for (const slot of uiSlots) {
|
||||
const requiredCap = UI_SLOT_CAPABILITIES[slot.type];
|
||||
if (requiredCap && !declared.has(requiredCap)) {
|
||||
if (!allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check launcher declarations → required capabilities
|
||||
const launchers = [
|
||||
...(manifest.launchers ?? []),
|
||||
...(manifest.ui?.launchers ?? []),
|
||||
];
|
||||
if (launchers.length > 0) {
|
||||
for (const launcher of launchers) {
|
||||
const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone];
|
||||
if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: allMissing.length === 0,
|
||||
missing: allMissing,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
getRequiredCapabilities(operation) {
|
||||
return OPERATION_CAPABILITIES[operation] ?? [];
|
||||
},
|
||||
|
||||
getUiSlotCapability(slotType) {
|
||||
return UI_SLOT_CAPABILITIES[slotType];
|
||||
},
|
||||
};
|
||||
}
|
||||
50
server/src/services/plugin-config-validator.ts
Normal file
50
server/src/services/plugin-config-validator.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @fileoverview Validates plugin instance configuration against its JSON Schema.
|
||||
*
|
||||
* Uses Ajv to validate `configJson` values against the `instanceConfigSchema`
|
||||
* declared in a plugin's manifest. This ensures that invalid configuration is
|
||||
* rejected at the API boundary, not discovered later at worker startup.
|
||||
*
|
||||
* @module server/services/plugin-config-validator
|
||||
*/
|
||||
|
||||
import Ajv, { type ErrorObject } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import type { JsonSchema } from "@paperclipai/shared";
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
valid: boolean;
|
||||
errors?: { field: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a config object against a JSON Schema.
|
||||
*
|
||||
* @param configJson - The configuration values to validate.
|
||||
* @param schema - The JSON Schema from the plugin manifest's `instanceConfigSchema`.
|
||||
* @returns Validation result with structured field errors on failure.
|
||||
*/
|
||||
export function validateInstanceConfig(
|
||||
configJson: Record<string, unknown>,
|
||||
schema: JsonSchema,
|
||||
): ConfigValidationResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ allErrors: true });
|
||||
// ajv-formats v3 default export is a FormatsPlugin object; call it as a plugin.
|
||||
const applyFormats = (addFormats as any).default ?? addFormats;
|
||||
applyFormats(ajv);
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(configJson);
|
||||
|
||||
if (valid) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const errors = (validate.errors ?? []).map((err: ErrorObject) => ({
|
||||
field: err.instancePath || "/",
|
||||
message: err.message ?? "validation failed",
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
339
server/src/services/plugin-dev-watcher.ts
Normal file
339
server/src/services/plugin-dev-watcher.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* PluginDevWatcher — watches local-path plugin directories for file changes
|
||||
* and triggers worker restarts so plugin authors get a fast rebuild-and-reload
|
||||
* cycle without manually restarting the server.
|
||||
*
|
||||
* Only plugins installed from a local path (i.e. those with a non-null
|
||||
* `packagePath` in the DB) are watched. File changes in the plugin's package
|
||||
* directory trigger a debounced worker restart via the lifecycle manager.
|
||||
*
|
||||
* Uses chokidar rather than raw fs.watch so we get a production-grade watcher
|
||||
* backend across platforms and avoid exhausting file descriptors as quickly in
|
||||
* large dev workspaces.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
*/
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
|
||||
const log = logger.child({ service: "plugin-dev-watcher" });
|
||||
|
||||
/** Debounce interval for file changes (ms). */
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
export interface PluginDevWatcher {
|
||||
/** Start watching a local-path plugin directory. */
|
||||
watch(pluginId: string, packagePath: string): void;
|
||||
/** Stop watching a specific plugin. */
|
||||
unwatch(pluginId: string): void;
|
||||
/** Stop all watchers and clean up. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export type ResolvePluginPackagePath = (
|
||||
pluginId: string,
|
||||
) => Promise<string | null | undefined>;
|
||||
|
||||
export interface PluginDevWatcherFsDeps {
|
||||
existsSync?: typeof existsSync;
|
||||
readFileSync?: typeof readFileSync;
|
||||
readdirSync?: typeof readdirSync;
|
||||
statSync?: typeof statSync;
|
||||
}
|
||||
|
||||
type PluginWatchTarget = {
|
||||
path: string;
|
||||
recursive: boolean;
|
||||
kind: "file" | "dir";
|
||||
};
|
||||
|
||||
type PluginPackageJson = {
|
||||
paperclipPlugin?: {
|
||||
manifest?: string;
|
||||
worker?: string;
|
||||
ui?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function shouldIgnorePath(filename: string | null | undefined): boolean {
|
||||
if (!filename) return false;
|
||||
const normalized = filename.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment === "node_modules" ||
|
||||
segment === ".git" ||
|
||||
segment === ".vite" ||
|
||||
segment === ".paperclip-sdk" ||
|
||||
segment.startsWith("."),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginWatchTargets(
|
||||
packagePath: string,
|
||||
fsDeps?: Pick<PluginDevWatcherFsDeps, "existsSync" | "readFileSync" | "readdirSync" | "statSync">,
|
||||
): PluginWatchTarget[] {
|
||||
const fileExists = fsDeps?.existsSync ?? existsSync;
|
||||
const readFile = fsDeps?.readFileSync ?? readFileSync;
|
||||
const readDir = fsDeps?.readdirSync ?? readdirSync;
|
||||
const statFile = fsDeps?.statSync ?? statSync;
|
||||
const absPath = path.resolve(packagePath);
|
||||
const targets = new Map<string, PluginWatchTarget>();
|
||||
|
||||
function addWatchTarget(targetPath: string, recursive: boolean, kind?: "file" | "dir"): void {
|
||||
const resolved = path.resolve(targetPath);
|
||||
if (!fileExists(resolved)) return;
|
||||
const inferredKind = kind ?? (statFile(resolved).isDirectory() ? "dir" : "file");
|
||||
|
||||
const existing = targets.get(resolved);
|
||||
if (existing) {
|
||||
existing.recursive = existing.recursive || recursive;
|
||||
return;
|
||||
}
|
||||
|
||||
targets.set(resolved, { path: resolved, recursive, kind: inferredKind });
|
||||
}
|
||||
|
||||
function addRuntimeFilesFromDir(dirPath: string): void {
|
||||
if (!fileExists(dirPath)) return;
|
||||
|
||||
for (const entry of readDir(dirPath, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
addRuntimeFilesFromDir(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".css")) continue;
|
||||
addWatchTarget(entryPath, false, "file");
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(absPath, "package.json");
|
||||
addWatchTarget(packageJsonPath, false, "file");
|
||||
if (!fileExists(packageJsonPath)) {
|
||||
return [...targets.values()];
|
||||
}
|
||||
|
||||
let packageJson: PluginPackageJson | null = null;
|
||||
try {
|
||||
packageJson = JSON.parse(readFile(packageJsonPath, "utf8")) as PluginPackageJson;
|
||||
} catch {
|
||||
packageJson = null;
|
||||
}
|
||||
|
||||
const entrypointPaths = [
|
||||
packageJson?.paperclipPlugin?.manifest,
|
||||
packageJson?.paperclipPlugin?.worker,
|
||||
packageJson?.paperclipPlugin?.ui,
|
||||
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
|
||||
if (entrypointPaths.length === 0) {
|
||||
addRuntimeFilesFromDir(path.join(absPath, "dist"));
|
||||
return [...targets.values()];
|
||||
}
|
||||
|
||||
for (const relativeEntrypoint of entrypointPaths) {
|
||||
const resolvedEntrypoint = path.resolve(absPath, relativeEntrypoint);
|
||||
if (!fileExists(resolvedEntrypoint)) continue;
|
||||
|
||||
const stat = statFile(resolvedEntrypoint);
|
||||
if (stat.isDirectory()) {
|
||||
addRuntimeFilesFromDir(resolvedEntrypoint);
|
||||
} else {
|
||||
addWatchTarget(resolvedEntrypoint, false, "file");
|
||||
}
|
||||
}
|
||||
|
||||
return [...targets.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginDevWatcher that monitors local plugin directories and
|
||||
* restarts workers on file changes.
|
||||
*/
|
||||
export function createPluginDevWatcher(
|
||||
lifecycle: PluginLifecycleManager,
|
||||
resolvePluginPackagePath?: ResolvePluginPackagePath,
|
||||
fsDeps?: PluginDevWatcherFsDeps,
|
||||
): PluginDevWatcher {
|
||||
const watchers = new Map<string, FSWatcher>();
|
||||
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const fileExists = fsDeps?.existsSync ?? existsSync;
|
||||
|
||||
function watchPlugin(pluginId: string, packagePath: string): void {
|
||||
// Don't double-watch
|
||||
if (watchers.has(pluginId)) return;
|
||||
|
||||
const absPath = path.resolve(packagePath);
|
||||
if (!fileExists(absPath)) {
|
||||
log.warn(
|
||||
{ pluginId, packagePath: absPath },
|
||||
"plugin-dev-watcher: package path does not exist, skipping watch",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const watcherTargets = resolvePluginWatchTargets(absPath, fsDeps);
|
||||
if (watcherTargets.length === 0) {
|
||||
log.warn(
|
||||
{ pluginId, packagePath: absPath },
|
||||
"plugin-dev-watcher: no valid watch targets found, skipping watch",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(
|
||||
watcherTargets.map((target) => target.path),
|
||||
{
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 200,
|
||||
pollInterval: 100,
|
||||
},
|
||||
ignored: (watchedPath) => {
|
||||
const relativePath = path.relative(absPath, watchedPath);
|
||||
return shouldIgnorePath(relativePath);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
watcher.on("all", (_eventName, changedPath) => {
|
||||
const relativePath = path.relative(absPath, changedPath);
|
||||
if (shouldIgnorePath(relativePath)) return;
|
||||
|
||||
const existing = debounceTimers.get(pluginId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
debounceTimers.set(
|
||||
pluginId,
|
||||
setTimeout(() => {
|
||||
debounceTimers.delete(pluginId);
|
||||
log.info(
|
||||
{ pluginId, changedFile: relativePath || path.basename(changedPath) },
|
||||
"plugin-dev-watcher: file change detected, restarting worker",
|
||||
);
|
||||
|
||||
lifecycle.restartWorker(pluginId).catch((err) => {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to restart worker after file change",
|
||||
);
|
||||
});
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: watcher error, stopping watch for this plugin",
|
||||
);
|
||||
unwatchPlugin(pluginId);
|
||||
});
|
||||
|
||||
watchers.set(pluginId, watcher);
|
||||
log.info(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
watchTargets: watcherTargets.map((target) => ({
|
||||
path: target.path,
|
||||
kind: target.kind,
|
||||
})),
|
||||
},
|
||||
"plugin-dev-watcher: watching local plugin for changes",
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to start file watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function unwatchPlugin(pluginId: string): void {
|
||||
const pluginWatcher = watchers.get(pluginId);
|
||||
if (pluginWatcher) {
|
||||
void pluginWatcher.close();
|
||||
watchers.delete(pluginId);
|
||||
}
|
||||
const timer = debounceTimers.get(pluginId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
lifecycle.off("plugin.loaded", handlePluginLoaded);
|
||||
lifecycle.off("plugin.enabled", handlePluginEnabled);
|
||||
lifecycle.off("plugin.disabled", handlePluginDisabled);
|
||||
lifecycle.off("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
for (const [pluginId] of watchers) {
|
||||
unwatchPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
async function watchLocalPluginById(pluginId: string): Promise<void> {
|
||||
if (!resolvePluginPackagePath) return;
|
||||
|
||||
try {
|
||||
const packagePath = await resolvePluginPackagePath(pluginId);
|
||||
if (!packagePath) return;
|
||||
watchPlugin(pluginId, packagePath);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to resolve plugin package path",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePluginLoaded(payload: { pluginId: string }): void {
|
||||
void watchLocalPluginById(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginEnabled(payload: { pluginId: string }): void {
|
||||
void watchLocalPluginById(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginDisabled(payload: { pluginId: string }): void {
|
||||
unwatchPlugin(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginUnloaded(payload: { pluginId: string }): void {
|
||||
unwatchPlugin(payload.pluginId);
|
||||
}
|
||||
|
||||
lifecycle.on("plugin.loaded", handlePluginLoaded);
|
||||
lifecycle.on("plugin.enabled", handlePluginEnabled);
|
||||
lifecycle.on("plugin.disabled", handlePluginDisabled);
|
||||
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
return {
|
||||
watch: watchPlugin,
|
||||
unwatch: unwatchPlugin,
|
||||
close,
|
||||
};
|
||||
}
|
||||
412
server/src/services/plugin-event-bus.ts
Normal file
412
server/src/services/plugin-event-bus.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* PluginEventBus — typed in-process event bus for the Paperclip plugin system.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Deliver core domain events to subscribing plugin workers (server-side).
|
||||
* - Apply `EventFilter` server-side so filtered-out events never reach the handler.
|
||||
* - Namespace plugin-emitted events as `plugin.<pluginId>.<eventName>`.
|
||||
* - Guard the core namespace: plugins may not emit events with the `plugin.` prefix.
|
||||
* - Isolate subscriptions per plugin — a plugin cannot enumerate or interfere with
|
||||
* another plugin's subscriptions.
|
||||
* - Support wildcard subscriptions via prefix matching (e.g. `plugin.acme.linear.*`).
|
||||
*
|
||||
* The bus operates in-process. In the full out-of-process architecture the host
|
||||
* calls `bus.emit()` after receiving events from the DB/queue layer, and the bus
|
||||
* forwards to handlers that proxy the call to the relevant worker process via IPC.
|
||||
* That IPC layer is separate; this module only handles routing and filtering.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §16 — Event System
|
||||
* @see PLUGIN_SPEC.md §16.1 — Event Filtering
|
||||
* @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events
|
||||
*/
|
||||
|
||||
import type { PluginEventType } from "@paperclipai/shared";
|
||||
import type { PluginEvent, EventFilter } from "@paperclipai/plugin-sdk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A registered subscription record stored per plugin.
|
||||
*/
|
||||
interface Subscription {
|
||||
/** The event name or prefix pattern this subscription matches. */
|
||||
eventPattern: string;
|
||||
/** Optional server-side filter applied before delivery. */
|
||||
filter: EventFilter | null;
|
||||
/** Async handler to invoke when a matching event passes the filter. */
|
||||
handler: (event: PluginEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matching helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the event type matches the subscription pattern.
|
||||
*
|
||||
* Matching rules:
|
||||
* - Exact match: `"issue.created"` matches `"issue.created"`.
|
||||
* - Wildcard suffix: `"plugin.acme.*"` matches any event type that starts with
|
||||
* `"plugin.acme."`. The wildcard `*` is only supported as a trailing token.
|
||||
*
|
||||
* No full glob syntax is supported — only trailing `*` after a `.` separator.
|
||||
*/
|
||||
function matchesPattern(eventType: string, pattern: string): boolean {
|
||||
if (pattern === eventType) return true;
|
||||
|
||||
// Trailing wildcard: "plugin.foo.*" → prefix is "plugin.foo."
|
||||
if (pattern.endsWith(".*")) {
|
||||
const prefix = pattern.slice(0, -1); // remove the trailing "*", keep the "."
|
||||
return eventType.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the event passes all fields of the filter.
|
||||
* A `null` or empty filter object passes all events.
|
||||
*
|
||||
* **Resolution strategy per field:**
|
||||
*
|
||||
* - `projectId` — checked against `event.entityId` when `entityType === "project"`,
|
||||
* otherwise against `payload.projectId`. This covers both direct project events
|
||||
* (e.g. `project.created`) and secondary events that embed a project reference in
|
||||
* their payload (e.g. `issue.created` with `payload.projectId`).
|
||||
*
|
||||
* - `companyId` — always resolved from `payload.companyId`. Core domain events that
|
||||
* belong to a company embed the company ID in their payload.
|
||||
*
|
||||
* - `agentId` — checked against `event.entityId` when `entityType === "agent"`,
|
||||
* otherwise against `payload.agentId`. Covers both direct agent lifecycle events
|
||||
* (e.g. `agent.created`) and run-level events with `payload.agentId` (e.g.
|
||||
* `agent.run.started`).
|
||||
*
|
||||
* Multiple filter fields are ANDed — all specified fields must match.
|
||||
*/
|
||||
function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
|
||||
if (!filter) return true;
|
||||
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
|
||||
if (filter.projectId !== undefined) {
|
||||
const projectId = event.entityType === "project"
|
||||
? event.entityId
|
||||
: (typeof payload?.projectId === "string" ? payload.projectId : undefined);
|
||||
if (projectId !== filter.projectId) return false;
|
||||
}
|
||||
|
||||
if (filter.companyId !== undefined) {
|
||||
if (event.companyId !== filter.companyId) return false;
|
||||
}
|
||||
|
||||
if (filter.agentId !== undefined) {
|
||||
const agentId = event.entityType === "agent"
|
||||
? event.entityId
|
||||
: (typeof payload?.agentId === "string" ? payload.agentId : undefined);
|
||||
if (agentId !== filter.agentId) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bus factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates and returns a new `PluginEventBus` instance.
|
||||
*
|
||||
* A single bus instance should be shared across the server process. Each
|
||||
* plugin interacts with the bus through a scoped handle obtained via
|
||||
* {@link PluginEventBus.forPlugin}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const bus = createPluginEventBus();
|
||||
*
|
||||
* // Give the Linear plugin a scoped handle
|
||||
* const linearBus = bus.forPlugin("acme.linear");
|
||||
*
|
||||
* // Subscribe from the plugin's perspective
|
||||
* linearBus.subscribe("issue.created", async (event) => {
|
||||
* // handle event
|
||||
* });
|
||||
*
|
||||
* // Emit a core domain event (called by the host, not the plugin)
|
||||
* await bus.emit({
|
||||
* eventId: "evt-1",
|
||||
* eventType: "issue.created",
|
||||
* occurredAt: new Date().toISOString(),
|
||||
* entityId: "iss-1",
|
||||
* entityType: "issue",
|
||||
* payload: { title: "Fix login bug", projectId: "proj-1" },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPluginEventBus(): PluginEventBus {
|
||||
// Subscription registry: pluginKey → list of subscriptions
|
||||
const registry = new Map<string, Subscription[]>();
|
||||
|
||||
/**
|
||||
* Retrieve or create the subscription list for a plugin.
|
||||
*/
|
||||
function subsFor(pluginId: string): Subscription[] {
|
||||
let subs = registry.get(pluginId);
|
||||
if (!subs) {
|
||||
subs = [];
|
||||
registry.set(pluginId, subs);
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event envelope to all matching subscribers across all plugins.
|
||||
*
|
||||
* Subscribers are called concurrently (Promise.all). Each handler's errors
|
||||
* are caught individually and collected in the returned `errors` array so a
|
||||
* single misbehaving plugin cannot interrupt delivery to other plugins.
|
||||
*/
|
||||
async function emit(event: PluginEvent): Promise<PluginEventBusEmitResult> {
|
||||
const errors: Array<{ pluginId: string; error: unknown }> = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [pluginId, subs] of registry) {
|
||||
for (const sub of subs) {
|
||||
if (!matchesPattern(event.eventType, sub.eventPattern)) continue;
|
||||
if (!passesFilter(event, sub.filter)) continue;
|
||||
|
||||
// Use Promise.resolve().then() so that synchronous throws from handlers
|
||||
// are also caught inside the promise chain. Calling
|
||||
// Promise.resolve(syncThrowingFn()) does NOT catch sync throws — the
|
||||
// throw escapes before Promise.resolve() can wrap it. Using .then()
|
||||
// ensures the call is deferred into the microtask queue where all
|
||||
// exceptions become rejections. Each .catch() swallows the rejection
|
||||
// and records it — the promise always resolves, so Promise.all never rejects.
|
||||
promises.push(
|
||||
Promise.resolve().then(() => sub.handler(event)).catch((error: unknown) => {
|
||||
errors.push({ pluginId, error });
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return { errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscriptions for a plugin (e.g. on worker shutdown or uninstall).
|
||||
*/
|
||||
function clearPlugin(pluginId: string): void {
|
||||
registry.delete(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a scoped handle for a specific plugin. The handle exposes only the
|
||||
* plugin's own subscription list and enforces the plugin namespace on `emit`.
|
||||
*/
|
||||
function forPlugin(pluginId: string): ScopedPluginEventBus {
|
||||
return {
|
||||
/**
|
||||
* Subscribe to a core domain event or a plugin-namespaced event.
|
||||
*
|
||||
* For wildcard subscriptions use a trailing `.*` pattern, e.g.
|
||||
* `"plugin.acme.linear.*"`.
|
||||
*
|
||||
* Requires the `events.subscribe` capability (capability enforcement is
|
||||
* done by the host layer before calling this method).
|
||||
*/
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
fnOrFilter: EventFilter | ((event: PluginEvent) => Promise<void>),
|
||||
maybeFn?: (event: PluginEvent) => Promise<void>,
|
||||
): void {
|
||||
let filter: EventFilter | null = null;
|
||||
let handler: (event: PluginEvent) => Promise<void>;
|
||||
|
||||
if (typeof fnOrFilter === "function") {
|
||||
handler = fnOrFilter;
|
||||
} else {
|
||||
filter = fnOrFilter;
|
||||
if (!maybeFn) throw new Error("Handler function is required when a filter is provided");
|
||||
handler = maybeFn;
|
||||
}
|
||||
|
||||
subsFor(pluginId).push({ eventPattern, filter, handler });
|
||||
},
|
||||
|
||||
/**
|
||||
* Emit a plugin-namespaced event. The event type is automatically
|
||||
* prefixed with `plugin.<pluginId>.` so:
|
||||
* - `emit("sync-done", payload)` becomes `"plugin.acme.linear.sync-done"`.
|
||||
*
|
||||
* Requires the `events.emit` capability (enforced by the host layer).
|
||||
*
|
||||
* @throws {Error} if `name` already contains the `plugin.` prefix
|
||||
* (prevents cross-namespace spoofing).
|
||||
*/
|
||||
async emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult> {
|
||||
if (!name || name.trim() === "") {
|
||||
throw new Error(`Plugin "${pluginId}" must provide a non-empty event name.`);
|
||||
}
|
||||
|
||||
if (!companyId || companyId.trim() === "") {
|
||||
throw new Error(`Plugin "${pluginId}" must provide a companyId when emitting events.`);
|
||||
}
|
||||
|
||||
if (name.startsWith("plugin.")) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" must not include the "plugin." prefix when emitting events. ` +
|
||||
`Emit the bare event name (e.g. "sync-done") and the bus will namespace it automatically.`,
|
||||
);
|
||||
}
|
||||
|
||||
const eventType = `plugin.${pluginId}.${name}` as const;
|
||||
const event: PluginEvent = {
|
||||
eventId: crypto.randomUUID(),
|
||||
eventType,
|
||||
companyId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
payload,
|
||||
};
|
||||
|
||||
return emit(event);
|
||||
},
|
||||
|
||||
/** Remove all subscriptions registered by this plugin. */
|
||||
clear(): void {
|
||||
clearPlugin(pluginId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
emit,
|
||||
forPlugin,
|
||||
clearPlugin,
|
||||
/** Expose subscription count for a plugin (useful for tests and diagnostics). */
|
||||
subscriptionCount(pluginId?: string): number {
|
||||
if (pluginId !== undefined) {
|
||||
return registry.get(pluginId)?.length ?? 0;
|
||||
}
|
||||
let total = 0;
|
||||
for (const subs of registry.values()) total += subs.length;
|
||||
return total;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result returned from `emit()`. Handler errors are collected and returned
|
||||
* rather than thrown so a single misbehaving plugin cannot block delivery to
|
||||
* other plugins.
|
||||
*/
|
||||
export interface PluginEventBusEmitResult {
|
||||
/** Errors thrown by individual handlers, keyed by the plugin that failed. */
|
||||
errors: Array<{ pluginId: string; error: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full event bus — held by the host process.
|
||||
*
|
||||
* Call `forPlugin(id)` to obtain a `ScopedPluginEventBus` for each plugin worker.
|
||||
*/
|
||||
export interface PluginEventBus {
|
||||
/**
|
||||
* Emit a typed domain event to all matching subscribers.
|
||||
*
|
||||
* Called by the host when a domain event occurs (e.g. from the DB layer or
|
||||
* message queue). All registered subscriptions across all plugins are checked.
|
||||
*/
|
||||
emit(event: PluginEvent): Promise<PluginEventBusEmitResult>;
|
||||
|
||||
/**
|
||||
* Get a scoped handle for a specific plugin worker.
|
||||
*
|
||||
* The scoped handle isolates the plugin's subscriptions and enforces the
|
||||
* plugin namespace on outbound events.
|
||||
*/
|
||||
forPlugin(pluginId: string): ScopedPluginEventBus;
|
||||
|
||||
/**
|
||||
* Remove all subscriptions for a plugin (called on worker shutdown/uninstall).
|
||||
*/
|
||||
clearPlugin(pluginId: string): void;
|
||||
|
||||
/**
|
||||
* Return the total number of active subscriptions, or the count for a
|
||||
* specific plugin if `pluginId` is provided.
|
||||
*/
|
||||
subscriptionCount(pluginId?: string): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin-scoped view of the event bus. Handed to the plugin worker (or its
|
||||
* host-side proxy) during initialisation.
|
||||
*
|
||||
* Plugins use this to:
|
||||
* 1. Subscribe to domain events (with optional server-side filter).
|
||||
* 2. Emit plugin-namespaced events for other plugins to consume.
|
||||
*
|
||||
* Note: `subscribe` overloads mirror the `PluginEventsClient.on()` interface
|
||||
* from the SDK. `emit` intentionally returns `PluginEventBusEmitResult` rather
|
||||
* than `void` so the host layer can inspect handler errors; the SDK-facing
|
||||
* `PluginEventsClient.emit()` wraps this and returns `void`.
|
||||
*/
|
||||
export interface ScopedPluginEventBus {
|
||||
/**
|
||||
* Subscribe to a core domain event or a plugin-namespaced event.
|
||||
*
|
||||
* **Pattern syntax:**
|
||||
* - Exact match: `"issue.created"` — receives only that event type.
|
||||
* - Wildcard suffix: `"plugin.acme.linear.*"` — receives all events emitted by
|
||||
* the `acme.linear` plugin. The `*` is supported only as a trailing token after
|
||||
* a `.` separator; no other glob syntax is supported.
|
||||
* - Top-level plugin wildcard: `"plugin.*"` — receives all plugin-emitted events
|
||||
* regardless of which plugin emitted them.
|
||||
*
|
||||
* Wildcards apply only to the `plugin.*` namespace. Core domain events must be
|
||||
* subscribed to by exact name (e.g. `"issue.created"`, not `"issue.*"`).
|
||||
*
|
||||
* An optional `EventFilter` can be passed as the second argument to perform
|
||||
* server-side pre-filtering; filtered-out events are never delivered to the handler.
|
||||
*/
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
fn: (event: PluginEvent) => Promise<void>,
|
||||
): void;
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
filter: EventFilter,
|
||||
fn: (event: PluginEvent) => Promise<void>,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Emit a plugin-namespaced event. The bus automatically prepends
|
||||
* `plugin.<pluginId>.` to the `name`, so passing `"sync-done"` from plugin
|
||||
* `"acme.linear"` produces the event type `"plugin.acme.linear.sync-done"`.
|
||||
*
|
||||
* @param name Bare event name (e.g. `"sync-done"`). Must be non-empty and
|
||||
* must not include the `plugin.` prefix — the bus adds that automatically.
|
||||
* @param companyId UUID of the company this event belongs to.
|
||||
* @param payload Arbitrary JSON-serializable data to attach to the event.
|
||||
*
|
||||
* @throws {Error} if `name` is empty or whitespace-only.
|
||||
* @throws {Error} if `name` starts with `"plugin."` (namespace spoofing guard).
|
||||
*/
|
||||
emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult>;
|
||||
|
||||
/**
|
||||
* Remove all subscriptions registered by this plugin.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
59
server/src/services/plugin-host-service-cleanup.ts
Normal file
59
server/src/services/plugin-host-service-cleanup.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
|
||||
type LifecycleLike = Pick<PluginLifecycleManager, "on" | "off">;
|
||||
|
||||
export interface PluginWorkerRuntimeEvent {
|
||||
type: "plugin.worker.crashed" | "plugin.worker.restarted";
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface PluginHostServiceCleanupController {
|
||||
handleWorkerEvent(event: PluginWorkerRuntimeEvent): void;
|
||||
disposeAll(): void;
|
||||
teardown(): void;
|
||||
}
|
||||
|
||||
export function createPluginHostServiceCleanup(
|
||||
lifecycle: LifecycleLike,
|
||||
disposers: Map<string, () => void>,
|
||||
): PluginHostServiceCleanupController {
|
||||
const runDispose = (pluginId: string, remove = false) => {
|
||||
const dispose = disposers.get(pluginId);
|
||||
if (!dispose) return;
|
||||
dispose();
|
||||
if (remove) {
|
||||
disposers.delete(pluginId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkerStopped = ({ pluginId }: { pluginId: string }) => {
|
||||
runDispose(pluginId);
|
||||
};
|
||||
|
||||
const handlePluginUnloaded = ({ pluginId }: { pluginId: string }) => {
|
||||
runDispose(pluginId, true);
|
||||
};
|
||||
|
||||
lifecycle.on("plugin.worker_stopped", handleWorkerStopped);
|
||||
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
return {
|
||||
handleWorkerEvent(event) {
|
||||
if (event.type === "plugin.worker.crashed") {
|
||||
runDispose(event.pluginId);
|
||||
}
|
||||
},
|
||||
|
||||
disposeAll() {
|
||||
for (const dispose of disposers.values()) {
|
||||
dispose();
|
||||
}
|
||||
disposers.clear();
|
||||
},
|
||||
|
||||
teardown() {
|
||||
lifecycle.off("plugin.worker_stopped", handleWorkerStopped);
|
||||
lifecycle.off("plugin.unloaded", handlePluginUnloaded);
|
||||
},
|
||||
};
|
||||
}
|
||||
1078
server/src/services/plugin-host-services.ts
Normal file
1078
server/src/services/plugin-host-services.ts
Normal file
File diff suppressed because it is too large
Load Diff
260
server/src/services/plugin-job-coordinator.ts
Normal file
260
server/src/services/plugin-job-coordinator.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* PluginJobCoordinator — bridges the plugin lifecycle manager with the
|
||||
* job scheduler and job store.
|
||||
*
|
||||
* This service listens to lifecycle events and performs the corresponding
|
||||
* scheduler and job store operations:
|
||||
*
|
||||
* - **plugin.loaded** → sync job declarations from manifest, then register
|
||||
* the plugin with the scheduler (computes `nextRunAt` for active jobs).
|
||||
*
|
||||
* - **plugin.disabled / plugin.unloaded** → unregister the plugin from the
|
||||
* scheduler (cancels in-flight runs, clears tracking state).
|
||||
*
|
||||
* ## Why a separate coordinator?
|
||||
*
|
||||
* The lifecycle manager, scheduler, and job store are independent services
|
||||
* with clean single-responsibility boundaries. The coordinator provides
|
||||
* the "glue" between them without adding coupling. This pattern is used
|
||||
* throughout Paperclip (e.g. heartbeat service coordinates timers + runs).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see ./plugin-job-scheduler.ts — Scheduler service
|
||||
* @see ./plugin-job-store.ts — Persistence layer
|
||||
* @see ./plugin-lifecycle.ts — Plugin state machine
|
||||
*/
|
||||
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
import type { PluginJobScheduler } from "./plugin-job-scheduler.js";
|
||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for creating a PluginJobCoordinator.
|
||||
*/
|
||||
export interface PluginJobCoordinatorOptions {
|
||||
/** Drizzle database instance. */
|
||||
db: Db;
|
||||
/** The plugin lifecycle manager to listen to. */
|
||||
lifecycle: PluginLifecycleManager;
|
||||
/** The job scheduler to register/unregister plugins with. */
|
||||
scheduler: PluginJobScheduler;
|
||||
/** The job store for syncing declarations. */
|
||||
jobStore: PluginJobStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public interface of the job coordinator.
|
||||
*/
|
||||
export interface PluginJobCoordinator {
|
||||
/**
|
||||
* Start listening to lifecycle events.
|
||||
*
|
||||
* This wires up the `plugin.loaded`, `plugin.disabled`, and
|
||||
* `plugin.unloaded` event handlers.
|
||||
*/
|
||||
start(): void;
|
||||
|
||||
/**
|
||||
* Stop listening to lifecycle events.
|
||||
*
|
||||
* Removes all event subscriptions added by `start()`.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginJobCoordinator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const coordinator = createPluginJobCoordinator({
|
||||
* db,
|
||||
* lifecycle,
|
||||
* scheduler,
|
||||
* jobStore,
|
||||
* });
|
||||
*
|
||||
* // Start listening to lifecycle events
|
||||
* coordinator.start();
|
||||
*
|
||||
* // On server shutdown
|
||||
* coordinator.stop();
|
||||
* ```
|
||||
*/
|
||||
export function createPluginJobCoordinator(
|
||||
options: PluginJobCoordinatorOptions,
|
||||
): PluginJobCoordinator {
|
||||
const { db, lifecycle, scheduler, jobStore } = options;
|
||||
const log = logger.child({ service: "plugin-job-coordinator" });
|
||||
const registry = pluginRegistryService(db);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When a plugin is loaded (transitions to `ready`):
|
||||
* 1. Look up the manifest from the registry
|
||||
* 2. Sync job declarations from the manifest into the DB
|
||||
* 3. Register the plugin with the scheduler (computes nextRunAt)
|
||||
*/
|
||||
async function onPluginLoaded(payload: { pluginId: string; pluginKey: string }): Promise<void> {
|
||||
const { pluginId, pluginKey } = payload;
|
||||
log.info({ pluginId, pluginKey }, "plugin loaded — syncing jobs and registering with scheduler");
|
||||
|
||||
try {
|
||||
// Get the manifest from the registry
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin?.manifestJson) {
|
||||
log.warn({ pluginId, pluginKey }, "plugin loaded but no manifest found — skipping job sync");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync job declarations from the manifest
|
||||
const manifest = plugin.manifestJson;
|
||||
const jobDeclarations = manifest.jobs ?? [];
|
||||
|
||||
if (jobDeclarations.length > 0) {
|
||||
log.info(
|
||||
{ pluginId, pluginKey, jobCount: jobDeclarations.length },
|
||||
"syncing job declarations from manifest",
|
||||
);
|
||||
await jobStore.syncJobDeclarations(pluginId, jobDeclarations);
|
||||
}
|
||||
|
||||
// Register with the scheduler (computes nextRunAt for active jobs)
|
||||
await scheduler.registerPlugin(pluginId);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to sync jobs or register plugin with scheduler",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a plugin is disabled (transitions to `error` with "disabled by
|
||||
* operator" or genuine error): unregister from the scheduler.
|
||||
*/
|
||||
async function onPluginDisabled(payload: {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
reason?: string;
|
||||
}): Promise<void> {
|
||||
const { pluginId, pluginKey, reason } = payload;
|
||||
log.info(
|
||||
{ pluginId, pluginKey, reason },
|
||||
"plugin disabled — unregistering from scheduler",
|
||||
);
|
||||
|
||||
try {
|
||||
await scheduler.unregisterPlugin(pluginId);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to unregister plugin from scheduler",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a plugin is unloaded (uninstalled): unregister from the scheduler.
|
||||
*/
|
||||
async function onPluginUnloaded(payload: {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
removeData: boolean;
|
||||
}): Promise<void> {
|
||||
const { pluginId, pluginKey, removeData } = payload;
|
||||
log.info(
|
||||
{ pluginId, pluginKey, removeData },
|
||||
"plugin unloaded — unregistering from scheduler",
|
||||
);
|
||||
|
||||
try {
|
||||
await scheduler.unregisterPlugin(pluginId);
|
||||
|
||||
// If data is being purged, also delete all job definitions and runs
|
||||
if (removeData) {
|
||||
log.info({ pluginId, pluginKey }, "purging job data for uninstalled plugin");
|
||||
await jobStore.deleteAllJobs(pluginId);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to unregister plugin from scheduler during unload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let attached = false;
|
||||
|
||||
// We need stable references for on/off since the lifecycle manager
|
||||
// uses them for matching. We wrap the async handlers in sync wrappers
|
||||
// that fire-and-forget (swallowing unhandled rejections via the try/catch
|
||||
// inside each handler).
|
||||
const boundOnLoaded = (payload: { pluginId: string; pluginKey: string }) => {
|
||||
void onPluginLoaded(payload);
|
||||
};
|
||||
const boundOnDisabled = (payload: { pluginId: string; pluginKey: string; reason?: string }) => {
|
||||
void onPluginDisabled(payload);
|
||||
};
|
||||
const boundOnUnloaded = (payload: { pluginId: string; pluginKey: string; removeData: boolean }) => {
|
||||
void onPluginUnloaded(payload);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
|
||||
lifecycle.on("plugin.loaded", boundOnLoaded);
|
||||
lifecycle.on("plugin.disabled", boundOnDisabled);
|
||||
lifecycle.on("plugin.unloaded", boundOnUnloaded);
|
||||
|
||||
log.info("plugin job coordinator started — listening to lifecycle events");
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (!attached) return;
|
||||
attached = false;
|
||||
|
||||
lifecycle.off("plugin.loaded", boundOnLoaded);
|
||||
lifecycle.off("plugin.disabled", boundOnDisabled);
|
||||
lifecycle.off("plugin.unloaded", boundOnUnloaded);
|
||||
|
||||
log.info("plugin job coordinator stopped");
|
||||
},
|
||||
};
|
||||
}
|
||||
752
server/src/services/plugin-job-scheduler.ts
Normal file
752
server/src/services/plugin-job-scheduler.ts
Normal file
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* PluginJobScheduler — tick-based scheduler for plugin scheduled jobs.
|
||||
*
|
||||
* The scheduler is the central coordinator for all plugin cron jobs. It
|
||||
* periodically ticks (default every 30 seconds), queries the `plugin_jobs`
|
||||
* table for jobs whose `nextRunAt` has passed, dispatches `runJob` RPC calls
|
||||
* to the appropriate worker processes, records each execution in the
|
||||
* `plugin_job_runs` table, and advances the scheduling pointer.
|
||||
*
|
||||
* ## Responsibilities
|
||||
*
|
||||
* 1. **Tick loop** — A `setInterval`-based loop fires every `tickIntervalMs`
|
||||
* (default 30s). Each tick scans for due jobs and dispatches them.
|
||||
*
|
||||
* 2. **Cron parsing & next-run calculation** — Uses the lightweight built-in
|
||||
* cron parser ({@link parseCron}, {@link nextCronTick}) to compute the
|
||||
* `nextRunAt` timestamp after each run or when a new job is registered.
|
||||
*
|
||||
* 3. **Overlap prevention** — Before dispatching a job, the scheduler checks
|
||||
* for an existing `running` run for the same job. If one exists, the job
|
||||
* is skipped for that tick.
|
||||
*
|
||||
* 4. **Job run recording** — Every execution creates a `plugin_job_runs` row:
|
||||
* `queued` → `running` → `succeeded` | `failed`. Duration and error are
|
||||
* captured.
|
||||
*
|
||||
* 5. **Lifecycle integration** — The scheduler exposes `registerPlugin()` and
|
||||
* `unregisterPlugin()` so the host lifecycle manager can wire up job
|
||||
* scheduling when plugins start/stop. On registration, the scheduler
|
||||
* computes `nextRunAt` for all active jobs that don't already have one.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see ./plugin-job-store.ts — Persistence layer
|
||||
* @see ./cron.ts — Cron parsing utilities
|
||||
*/
|
||||
|
||||
import { and, eq, lte, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { parseCron, nextCronTick, validateCron } from "./cron.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default interval between scheduler ticks (30 seconds). */
|
||||
const DEFAULT_TICK_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Default timeout for a runJob RPC call (5 minutes). */
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
|
||||
/** Maximum number of concurrent job executions across all plugins. */
|
||||
const DEFAULT_MAX_CONCURRENT_JOBS = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for creating a PluginJobScheduler.
|
||||
*/
|
||||
export interface PluginJobSchedulerOptions {
|
||||
/** Drizzle database instance. */
|
||||
db: Db;
|
||||
/** Persistence layer for jobs and runs. */
|
||||
jobStore: PluginJobStore;
|
||||
/** Worker process manager for RPC calls. */
|
||||
workerManager: PluginWorkerManager;
|
||||
/** Interval between scheduler ticks in ms (default: 30s). */
|
||||
tickIntervalMs?: number;
|
||||
/** Timeout for individual job RPC calls in ms (default: 5min). */
|
||||
jobTimeoutMs?: number;
|
||||
/** Maximum number of concurrent job executions (default: 10). */
|
||||
maxConcurrentJobs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a manual job trigger.
|
||||
*/
|
||||
export interface TriggerJobResult {
|
||||
/** The created run ID. */
|
||||
runId: string;
|
||||
/** The job ID that was triggered. */
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic information about the scheduler.
|
||||
*/
|
||||
export interface SchedulerDiagnostics {
|
||||
/** Whether the tick loop is running. */
|
||||
running: boolean;
|
||||
/** Number of jobs currently executing. */
|
||||
activeJobCount: number;
|
||||
/** Set of job IDs currently in-flight. */
|
||||
activeJobIds: string[];
|
||||
/** Total number of ticks executed since start. */
|
||||
tickCount: number;
|
||||
/** Timestamp of the last tick (ISO 8601). */
|
||||
lastTickAt: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The public interface of the job scheduler.
|
||||
*/
|
||||
export interface PluginJobScheduler {
|
||||
/**
|
||||
* Start the scheduler tick loop.
|
||||
*
|
||||
* Safe to call multiple times — subsequent calls are no-ops.
|
||||
*/
|
||||
start(): void;
|
||||
|
||||
/**
|
||||
* Stop the scheduler tick loop.
|
||||
*
|
||||
* In-flight job runs are NOT cancelled — they are allowed to finish
|
||||
* naturally. The tick loop simply stops firing.
|
||||
*/
|
||||
stop(): void;
|
||||
|
||||
/**
|
||||
* Register a plugin with the scheduler.
|
||||
*
|
||||
* Computes `nextRunAt` for all active jobs that are missing it. This is
|
||||
* typically called after a plugin's worker process starts and
|
||||
* `syncJobDeclarations()` has been called.
|
||||
*
|
||||
* @param pluginId - UUID of the plugin
|
||||
*/
|
||||
registerPlugin(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unregister a plugin from the scheduler.
|
||||
*
|
||||
* Cancels any in-flight runs for the plugin and removes tracking state.
|
||||
*
|
||||
* @param pluginId - UUID of the plugin
|
||||
*/
|
||||
unregisterPlugin(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Manually trigger a specific job (outside of the cron schedule).
|
||||
*
|
||||
* Creates a run with `trigger: "manual"` and dispatches immediately,
|
||||
* respecting the overlap prevention check.
|
||||
*
|
||||
* @param jobId - UUID of the job to trigger
|
||||
* @param trigger - What triggered this run (default: "manual")
|
||||
* @returns The created run info
|
||||
* @throws {Error} if the job is not found, not active, or already running
|
||||
*/
|
||||
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
|
||||
|
||||
/**
|
||||
* Run a single scheduler tick immediately (for testing).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
tick(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get diagnostic information about the scheduler state.
|
||||
*/
|
||||
diagnostics(): SchedulerDiagnostics;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new PluginJobScheduler.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const scheduler = createPluginJobScheduler({
|
||||
* db,
|
||||
* jobStore,
|
||||
* workerManager,
|
||||
* });
|
||||
*
|
||||
* // Start the tick loop
|
||||
* scheduler.start();
|
||||
*
|
||||
* // When a plugin comes online, register it
|
||||
* await scheduler.registerPlugin(pluginId);
|
||||
*
|
||||
* // Manually trigger a job
|
||||
* const { runId } = await scheduler.triggerJob(jobId);
|
||||
*
|
||||
* // On server shutdown
|
||||
* scheduler.stop();
|
||||
* ```
|
||||
*/
|
||||
export function createPluginJobScheduler(
|
||||
options: PluginJobSchedulerOptions,
|
||||
): PluginJobScheduler {
|
||||
const {
|
||||
db,
|
||||
jobStore,
|
||||
workerManager,
|
||||
tickIntervalMs = DEFAULT_TICK_INTERVAL_MS,
|
||||
jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS,
|
||||
maxConcurrentJobs = DEFAULT_MAX_CONCURRENT_JOBS,
|
||||
} = options;
|
||||
|
||||
const log = logger.child({ service: "plugin-job-scheduler" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Timer handle for the tick loop. */
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Whether the scheduler is running. */
|
||||
let running = false;
|
||||
|
||||
/** Set of job IDs currently being executed (for overlap prevention). */
|
||||
const activeJobs = new Set<string>();
|
||||
|
||||
/** Total number of ticks since start. */
|
||||
let tickCount = 0;
|
||||
|
||||
/** Timestamp of the last tick. */
|
||||
let lastTickAt: Date | null = null;
|
||||
|
||||
/** Guard against concurrent tick execution. */
|
||||
let tickInProgress = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: tick
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single scheduler tick. Queries for due jobs and dispatches them.
|
||||
*/
|
||||
async function tick(): Promise<void> {
|
||||
// Prevent overlapping ticks (in case a tick takes longer than the interval)
|
||||
if (tickInProgress) {
|
||||
log.debug("skipping tick — previous tick still in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
tickInProgress = true;
|
||||
tickCount++;
|
||||
lastTickAt = new Date();
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// Query for jobs whose nextRunAt has passed and are active.
|
||||
// We include jobs with null nextRunAt since they may have just been
|
||||
// registered and need their first run calculated.
|
||||
const dueJobs = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobs.status, "active"),
|
||||
lte(pluginJobs.nextRunAt, now),
|
||||
),
|
||||
);
|
||||
|
||||
if (dueJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug({ count: dueJobs.length }, "found due jobs");
|
||||
|
||||
// Dispatch each due job (respecting concurrency limits)
|
||||
const dispatches: Promise<void>[] = [];
|
||||
|
||||
for (const job of dueJobs) {
|
||||
// Concurrency limit
|
||||
if (activeJobs.size >= maxConcurrentJobs) {
|
||||
log.warn(
|
||||
{ maxConcurrentJobs, activeJobCount: activeJobs.size },
|
||||
"max concurrent jobs reached, deferring remaining jobs",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Overlap prevention: skip if this job is already running
|
||||
if (activeJobs.has(job.id)) {
|
||||
log.debug(
|
||||
{ jobId: job.id, jobKey: job.jobKey, pluginId: job.pluginId },
|
||||
"skipping job — already running (overlap prevention)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the worker is available
|
||||
if (!workerManager.isRunning(job.pluginId)) {
|
||||
log.debug(
|
||||
{ jobId: job.id, pluginId: job.pluginId },
|
||||
"skipping job — worker not running",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate cron expression before dispatching
|
||||
if (!job.schedule) {
|
||||
log.warn(
|
||||
{ jobId: job.id, jobKey: job.jobKey },
|
||||
"skipping job — no schedule defined",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
dispatches.push(dispatchJob(job));
|
||||
}
|
||||
|
||||
if (dispatches.length > 0) {
|
||||
await Promise.allSettled(dispatches);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"scheduler tick error",
|
||||
);
|
||||
} finally {
|
||||
tickInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: dispatch a single job
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dispatch a single job run — create the run record, call the worker,
|
||||
* record the result, and advance the schedule pointer.
|
||||
*/
|
||||
async function dispatchJob(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
): Promise<void> {
|
||||
const { id: jobId, pluginId, jobKey, schedule } = job;
|
||||
const jobLog = log.child({ jobId, pluginId, jobKey });
|
||||
|
||||
// Mark as active (overlap prevention)
|
||||
activeJobs.add(jobId);
|
||||
|
||||
let runId: string | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// 1. Create run record
|
||||
const run = await jobStore.createRun({
|
||||
jobId,
|
||||
pluginId,
|
||||
trigger: "schedule",
|
||||
});
|
||||
runId = run.id;
|
||||
|
||||
jobLog.info({ runId }, "dispatching scheduled job");
|
||||
|
||||
// 2. Mark run as running
|
||||
await jobStore.markRunning(runId);
|
||||
|
||||
// 3. Call worker via RPC
|
||||
await workerManager.call(
|
||||
pluginId,
|
||||
"runJob",
|
||||
{
|
||||
job: {
|
||||
jobKey,
|
||||
runId,
|
||||
trigger: "schedule" as const,
|
||||
scheduledAt: (job.nextRunAt ?? new Date()).toISOString(),
|
||||
},
|
||||
},
|
||||
jobTimeoutMs,
|
||||
);
|
||||
|
||||
// 4. Mark run as succeeded
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "succeeded",
|
||||
durationMs,
|
||||
});
|
||||
|
||||
jobLog.info({ runId, durationMs }, "job completed successfully");
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
jobLog.error(
|
||||
{ runId, durationMs, err: errorMessage },
|
||||
"job execution failed",
|
||||
);
|
||||
|
||||
// Record the failure
|
||||
if (runId) {
|
||||
try {
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
durationMs,
|
||||
});
|
||||
} catch (completeErr) {
|
||||
jobLog.error(
|
||||
{
|
||||
runId,
|
||||
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||
},
|
||||
"failed to record job failure",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Remove from active set
|
||||
activeJobs.delete(jobId);
|
||||
|
||||
// 5. Always advance the schedule pointer (even on failure)
|
||||
try {
|
||||
await advanceSchedulePointer(job);
|
||||
} catch (err) {
|
||||
jobLog.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"failed to advance schedule pointer",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: manual trigger
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function triggerJob(
|
||||
jobId: string,
|
||||
trigger: "manual" | "retry" = "manual",
|
||||
): Promise<TriggerJobResult> {
|
||||
const job = await jobStore.getJobById(jobId);
|
||||
if (!job) {
|
||||
throw new Error(`Job not found: ${jobId}`);
|
||||
}
|
||||
|
||||
if (job.status !== "active") {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" is not active (status: ${job.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Overlap prevention
|
||||
if (activeJobs.has(jobId)) {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" is already running — cannot trigger while in progress`,
|
||||
);
|
||||
}
|
||||
|
||||
// Also check DB for running runs (defensive — covers multi-instance)
|
||||
const existingRuns = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobRuns.jobId, jobId),
|
||||
eq(pluginJobRuns.status, "running"),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingRuns.length > 0) {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" already has a running execution — cannot trigger while in progress`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check worker availability
|
||||
if (!workerManager.isRunning(job.pluginId)) {
|
||||
throw new Error(
|
||||
`Worker for plugin "${job.pluginId}" is not running — cannot trigger job`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the run and dispatch (non-blocking)
|
||||
const run = await jobStore.createRun({
|
||||
jobId,
|
||||
pluginId: job.pluginId,
|
||||
trigger,
|
||||
});
|
||||
|
||||
// Dispatch in background — don't block the caller
|
||||
void dispatchManualRun(job, run.id, trigger);
|
||||
|
||||
return { runId: run.id, jobId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a manually triggered job run.
|
||||
*/
|
||||
async function dispatchManualRun(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
runId: string,
|
||||
trigger: "manual" | "retry",
|
||||
): Promise<void> {
|
||||
const { id: jobId, pluginId, jobKey } = job;
|
||||
const jobLog = log.child({ jobId, pluginId, jobKey, runId, trigger });
|
||||
|
||||
activeJobs.add(jobId);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await jobStore.markRunning(runId);
|
||||
|
||||
await workerManager.call(
|
||||
pluginId,
|
||||
"runJob",
|
||||
{
|
||||
job: {
|
||||
jobKey,
|
||||
runId,
|
||||
trigger,
|
||||
scheduledAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
jobTimeoutMs,
|
||||
);
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "succeeded",
|
||||
durationMs,
|
||||
});
|
||||
|
||||
jobLog.info({ durationMs }, "manual job completed successfully");
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
jobLog.error({ durationMs, err: errorMessage }, "manual job failed");
|
||||
|
||||
try {
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
durationMs,
|
||||
});
|
||||
} catch (completeErr) {
|
||||
jobLog.error(
|
||||
{
|
||||
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||
},
|
||||
"failed to record manual job failure",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Schedule pointer management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the `lastRunAt` and `nextRunAt` timestamps on a job after a run.
|
||||
*/
|
||||
async function advanceSchedulePointer(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
let nextRunAt: Date | null = null;
|
||||
|
||||
if (job.schedule) {
|
||||
const validationError = validateCron(job.schedule);
|
||||
if (validationError) {
|
||||
log.warn(
|
||||
{ jobId: job.id, schedule: job.schedule, error: validationError },
|
||||
"invalid cron schedule — cannot compute next run",
|
||||
);
|
||||
} else {
|
||||
const cron = parseCron(job.schedule);
|
||||
nextRunAt = nextCronTick(cron, now);
|
||||
}
|
||||
}
|
||||
|
||||
await jobStore.updateRunTimestamps(job.id, now, nextRunAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all active jobs for a plugin have a `nextRunAt` value.
|
||||
* Called when a plugin is registered with the scheduler.
|
||||
*/
|
||||
async function ensureNextRunTimestamps(pluginId: string): Promise<void> {
|
||||
const jobs = await jobStore.listJobs(pluginId, "active");
|
||||
|
||||
for (const job of jobs) {
|
||||
// Skip jobs that already have a valid nextRunAt in the future
|
||||
if (job.nextRunAt && job.nextRunAt.getTime() > Date.now()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip jobs without a schedule
|
||||
if (!job.schedule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationError = validateCron(job.schedule);
|
||||
if (validationError) {
|
||||
log.warn(
|
||||
{ jobId: job.id, jobKey: job.jobKey, schedule: job.schedule, error: validationError },
|
||||
"skipping job with invalid cron schedule",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cron = parseCron(job.schedule);
|
||||
const nextRunAt = nextCronTick(cron, new Date());
|
||||
|
||||
if (nextRunAt) {
|
||||
await jobStore.updateRunTimestamps(
|
||||
job.id,
|
||||
job.lastRunAt ?? new Date(0),
|
||||
nextRunAt,
|
||||
);
|
||||
log.debug(
|
||||
{ jobId: job.id, jobKey: job.jobKey, nextRunAt: nextRunAt.toISOString() },
|
||||
"computed nextRunAt for job",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Plugin registration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function registerPlugin(pluginId: string): Promise<void> {
|
||||
log.info({ pluginId }, "registering plugin with job scheduler");
|
||||
await ensureNextRunTimestamps(pluginId);
|
||||
}
|
||||
|
||||
async function unregisterPlugin(pluginId: string): Promise<void> {
|
||||
log.info({ pluginId }, "unregistering plugin from job scheduler");
|
||||
|
||||
// Cancel any in-flight run records for this plugin that are still
|
||||
// queued or running. Active jobs in-memory will finish naturally.
|
||||
try {
|
||||
const runningRuns = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobRuns.pluginId, pluginId),
|
||||
or(
|
||||
eq(pluginJobRuns.status, "running"),
|
||||
eq(pluginJobRuns.status, "queued"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const run of runningRuns) {
|
||||
await jobStore.completeRun(run.id, {
|
||||
status: "cancelled",
|
||||
error: "Plugin unregistered",
|
||||
durationMs: run.startedAt
|
||||
? Date.now() - run.startedAt.getTime()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"error cancelling in-flight runs during unregister",
|
||||
);
|
||||
}
|
||||
|
||||
// Remove any active tracking for jobs owned by this plugin
|
||||
const jobs = await jobStore.listJobs(pluginId);
|
||||
for (const job of jobs) {
|
||||
activeJobs.delete(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle: start / stop
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function start(): void {
|
||||
if (running) {
|
||||
log.debug("scheduler already running");
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
tickTimer = setInterval(() => {
|
||||
void tick();
|
||||
}, tickIntervalMs);
|
||||
|
||||
log.info(
|
||||
{ tickIntervalMs, maxConcurrentJobs },
|
||||
"plugin job scheduler started",
|
||||
);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
// Always clear the timer defensively, even if `running` is already false,
|
||||
// to prevent leaked interval timers.
|
||||
if (tickTimer !== null) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
|
||||
if (!running) return;
|
||||
running = false;
|
||||
|
||||
log.info(
|
||||
{ activeJobCount: activeJobs.size },
|
||||
"plugin job scheduler stopped",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Diagnostics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function diagnostics(): SchedulerDiagnostics {
|
||||
return {
|
||||
running,
|
||||
activeJobCount: activeJobs.size,
|
||||
activeJobIds: [...activeJobs],
|
||||
tickCount,
|
||||
lastTickAt: lastTickAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
registerPlugin,
|
||||
unregisterPlugin,
|
||||
triggerJob,
|
||||
tick,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
465
server/src/services/plugin-job-store.ts
Normal file
465
server/src/services/plugin-job-store.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Plugin Job Store — persistence layer for scheduled plugin jobs and their
|
||||
* execution history.
|
||||
*
|
||||
* This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is
|
||||
* the server-side backing store for the `ctx.jobs` SDK surface exposed to
|
||||
* plugin workers.
|
||||
*
|
||||
* ## Responsibilities
|
||||
*
|
||||
* 1. **Sync job declarations** — When a plugin is installed or started, the
|
||||
* host calls `syncJobDeclarations()` to upsert the manifest's declared jobs
|
||||
* into the `plugin_jobs` table. Jobs removed from the manifest are marked
|
||||
* `paused` (not deleted) to preserve history.
|
||||
*
|
||||
* 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin.
|
||||
*
|
||||
* 3. **Run lifecycle** — Create job run records, update their status, and
|
||||
* record results (duration, errors, logs).
|
||||
*
|
||||
* 4. **Next-run calculation** — After a run completes the host should call
|
||||
* `updateNextRunAt()` with the next cron tick so the scheduler knows when
|
||||
* to fire next.
|
||||
*
|
||||
* The capability check (`jobs.schedule`) is enforced upstream by the host
|
||||
* client factory and manifest validator — this store trusts that the caller
|
||||
* has already been authorised.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||
import type {
|
||||
PluginJobDeclaration,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginJobRecord,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
/**
|
||||
* The statuses used for job *definitions* in the `plugin_jobs` table.
|
||||
* Aliased from `PluginJobRecord` to keep the store API aligned with
|
||||
* the domain type (`"active" | "paused" | "failed"`).
|
||||
*/
|
||||
type JobDefinitionStatus = PluginJobRecord["status"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input for creating a job run record.
|
||||
*/
|
||||
export interface CreateJobRunInput {
|
||||
/** FK to the plugin_jobs row. */
|
||||
jobId: string;
|
||||
/** FK to the plugins row. */
|
||||
pluginId: string;
|
||||
/** What triggered this run. */
|
||||
trigger: PluginJobRunTrigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for completing (or failing) a job run.
|
||||
*/
|
||||
export interface CompleteJobRunInput {
|
||||
/** Final run status. */
|
||||
status: PluginJobRunStatus;
|
||||
/** Error message if the run failed. */
|
||||
error?: string | null;
|
||||
/** Run duration in milliseconds. */
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginJobStore backed by the given Drizzle database instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jobStore = pluginJobStore(db);
|
||||
*
|
||||
* // On plugin install/start — sync declared jobs into the DB
|
||||
* await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []);
|
||||
*
|
||||
* // Before dispatching a runJob RPC — create a run record
|
||||
* const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" });
|
||||
*
|
||||
* // After the RPC completes — record the result
|
||||
* await jobStore.completeRun(run.id, {
|
||||
* status: "succeeded",
|
||||
* durationMs: Date.now() - startedAt,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function pluginJobStore(db: Db) {
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function assertPluginExists(pluginId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ id: plugins.id })
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
if (rows.length === 0) {
|
||||
throw notFound(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// =====================================================================
|
||||
// Job declarations (plugin_jobs)
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Sync declared jobs from a plugin manifest into the `plugin_jobs` table.
|
||||
*
|
||||
* This is called at plugin install and on each worker startup so the DB
|
||||
* always reflects the manifest's declared jobs:
|
||||
*
|
||||
* - **New jobs** are inserted with status `active`.
|
||||
* - **Existing jobs** have their `schedule` updated if it changed.
|
||||
* - **Removed jobs** (present in DB but absent from the manifest) are
|
||||
* set to `paused` so their history is preserved.
|
||||
*
|
||||
* The unique constraint `(pluginId, jobKey)` is used for conflict
|
||||
* resolution.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param declarations - Job declarations from the plugin manifest
|
||||
*/
|
||||
async syncJobDeclarations(
|
||||
pluginId: string,
|
||||
declarations: PluginJobDeclaration[],
|
||||
): Promise<void> {
|
||||
await assertPluginExists(pluginId);
|
||||
|
||||
// Fetch existing jobs for this plugin
|
||||
const existingJobs = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId));
|
||||
|
||||
const existingByKey = new Map(
|
||||
existingJobs.map((j) => [j.jobKey, j]),
|
||||
);
|
||||
|
||||
const declaredKeys = new Set<string>();
|
||||
|
||||
// Upsert each declared job
|
||||
for (const decl of declarations) {
|
||||
declaredKeys.add(decl.jobKey);
|
||||
|
||||
const existing = existingByKey.get(decl.jobKey);
|
||||
const schedule = decl.schedule ?? "";
|
||||
|
||||
if (existing) {
|
||||
// Update schedule if it changed; re-activate if it was paused
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existing.schedule !== schedule) {
|
||||
updates.schedule = schedule;
|
||||
}
|
||||
if (existing.status === "paused") {
|
||||
updates.status = "active";
|
||||
}
|
||||
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set(updates)
|
||||
.where(eq(pluginJobs.id, existing.id));
|
||||
} else {
|
||||
// Insert new job
|
||||
await db.insert(pluginJobs).values({
|
||||
pluginId,
|
||||
jobKey: decl.jobKey,
|
||||
schedule,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pause jobs that are no longer declared in the manifest
|
||||
for (const existing of existingJobs) {
|
||||
if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(pluginJobs.id, existing.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List all jobs for a plugin, optionally filtered by status.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param status - Optional status filter
|
||||
*/
|
||||
async listJobs(
|
||||
pluginId: string,
|
||||
status?: JobDefinitionStatus,
|
||||
): Promise<(typeof pluginJobs.$inferSelect)[]> {
|
||||
const conditions = [eq(pluginJobs.pluginId, pluginId)];
|
||||
if (status) {
|
||||
conditions.push(eq(pluginJobs.status, status));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(...conditions));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single job by its composite key `(pluginId, jobKey)`.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param jobKey - Stable job identifier from the manifest
|
||||
* @returns The job row, or `null` if not found
|
||||
*/
|
||||
async getJobByKey(
|
||||
pluginId: string,
|
||||
jobKey: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobs.pluginId, pluginId),
|
||||
eq(pluginJobs.jobKey, jobKey),
|
||||
),
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single job by its primary key (UUID).
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @returns The job row, or `null` if not found
|
||||
*/
|
||||
async getJobById(
|
||||
jobId: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single job by ID, scoped to a specific plugin.
|
||||
*
|
||||
* Returns `null` if the job does not exist or does not belong to the
|
||||
* given plugin — callers should treat both cases as "not found".
|
||||
*/
|
||||
async getJobByIdForPlugin(
|
||||
pluginId: string,
|
||||
jobId: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId)));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a job's status.
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @param status - New status
|
||||
*/
|
||||
async updateJobStatus(
|
||||
jobId: string,
|
||||
status: JobDefinitionStatus,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the `lastRunAt` and `nextRunAt` timestamps on a job.
|
||||
*
|
||||
* Called by the scheduler after a run completes to advance the
|
||||
* scheduling pointer.
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @param lastRunAt - When the last run started
|
||||
* @param nextRunAt - When the next run should fire
|
||||
*/
|
||||
async updateRunTimestamps(
|
||||
jobId: string,
|
||||
lastRunAt: Date,
|
||||
nextRunAt: Date | null,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({
|
||||
lastRunAt,
|
||||
nextRunAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all jobs (and cascaded runs) owned by a plugin.
|
||||
*
|
||||
* Called during plugin uninstall when `removeData = true`.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
*/
|
||||
async deleteAllJobs(pluginId: string): Promise<void> {
|
||||
await db
|
||||
.delete(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId));
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Job runs (plugin_job_runs)
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Create a new job run record with status `queued`.
|
||||
*
|
||||
* The caller should create the run record *before* dispatching the
|
||||
* `runJob` RPC to the worker, then update it to `running` once the
|
||||
* worker begins execution.
|
||||
*
|
||||
* @param input - Job run input (jobId, pluginId, trigger)
|
||||
* @returns The newly created run row
|
||||
*/
|
||||
async createRun(
|
||||
input: CreateJobRunInput,
|
||||
): Promise<typeof pluginJobRuns.$inferSelect> {
|
||||
const rows = await db
|
||||
.insert(pluginJobRuns)
|
||||
.values({
|
||||
jobId: input.jobId,
|
||||
pluginId: input.pluginId,
|
||||
trigger: input.trigger,
|
||||
status: "queued",
|
||||
})
|
||||
.returning();
|
||||
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark a run as `running` and set its `startedAt` timestamp.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
*/
|
||||
async markRunning(runId: string): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobRuns)
|
||||
.set({
|
||||
status: "running" as PluginJobRunStatus,
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a run — set its final status, error, duration, and
|
||||
* `finishedAt` timestamp.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
* @param input - Completion details
|
||||
*/
|
||||
async completeRun(
|
||||
runId: string,
|
||||
input: CompleteJobRunInput,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobRuns)
|
||||
.set({
|
||||
status: input.status,
|
||||
error: input.error ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
finishedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a run by its primary key.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
* @returns The run row, or `null` if not found
|
||||
*/
|
||||
async getRunById(
|
||||
runId: string,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* List runs for a specific job, ordered by creation time descending.
|
||||
*
|
||||
* @param jobId - UUID of the job
|
||||
* @param limit - Maximum number of rows to return (default: 50)
|
||||
*/
|
||||
async listRunsByJob(
|
||||
jobId: string,
|
||||
limit = 50,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(eq(pluginJobRuns.jobId, jobId))
|
||||
.orderBy(desc(pluginJobRuns.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* List runs for a plugin, optionally filtered by status.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param status - Optional status filter
|
||||
* @param limit - Maximum number of rows to return (default: 50)
|
||||
*/
|
||||
async listRunsByPlugin(
|
||||
pluginId: string,
|
||||
status?: PluginJobRunStatus,
|
||||
limit = 50,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||
const conditions = [eq(pluginJobRuns.pluginId, pluginId)];
|
||||
if (status) {
|
||||
conditions.push(eq(pluginJobRuns.status, status));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pluginJobRuns.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Type alias for the return value of `pluginJobStore()`. */
|
||||
export type PluginJobStore = ReturnType<typeof pluginJobStore>;
|
||||
821
server/src/services/plugin-lifecycle.ts
Normal file
821
server/src/services/plugin-lifecycle.ts
Normal file
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* PluginLifecycleManager — state-machine controller for plugin status
|
||||
* transitions and worker process coordination.
|
||||
*
|
||||
* Each plugin moves through a well-defined state machine:
|
||||
*
|
||||
* ```
|
||||
* installed ──→ ready ──→ disabled
|
||||
* │ │ │
|
||||
* │ ├──→ error│
|
||||
* │ ↓ │
|
||||
* │ upgrade_pending │
|
||||
* │ │ │
|
||||
* ↓ ↓ ↓
|
||||
* uninstalled
|
||||
* ```
|
||||
*
|
||||
* The lifecycle manager:
|
||||
*
|
||||
* 1. **Validates transitions** — Only transitions defined in
|
||||
* `VALID_TRANSITIONS` are allowed; invalid transitions throw.
|
||||
*
|
||||
* 2. **Coordinates workers** — When a plugin moves to `ready`, its
|
||||
* worker process is started. When it moves out of `ready`, the
|
||||
* worker is stopped gracefully.
|
||||
*
|
||||
* 3. **Emits events** — `plugin.loaded`, `plugin.enabled`,
|
||||
* `plugin.disabled`, `plugin.unloaded`, `plugin.status_changed`
|
||||
* events are emitted so that other services (job coordinator,
|
||||
* tool dispatcher, event bus) can react accordingly.
|
||||
*
|
||||
* 4. **Persists state** — Status changes are written to the database
|
||||
* through the plugin registry service.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||
*/
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
PluginStatus,
|
||||
PluginRecord,
|
||||
PaperclipPluginManifestV1,
|
||||
} from "@paperclipai/shared";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { pluginLoader, type PluginLoader } from "./plugin-loader.js";
|
||||
import type { PluginWorkerManager, WorkerStartOptions } from "./plugin-worker-manager.js";
|
||||
import { badRequest, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Valid state transitions for the plugin lifecycle.
|
||||
*
|
||||
* installed → ready (initial load succeeds)
|
||||
* installed → error (initial load fails)
|
||||
* installed → uninstalled (abort installation)
|
||||
*
|
||||
* ready → disabled (operator disables plugin)
|
||||
* ready → error (runtime failure)
|
||||
* ready → upgrade_pending (upgrade with new capabilities)
|
||||
* ready → uninstalled (uninstall)
|
||||
*
|
||||
* disabled → ready (operator re-enables plugin)
|
||||
* disabled → uninstalled (uninstall while disabled)
|
||||
*
|
||||
* error → ready (retry / recovery)
|
||||
* error → uninstalled (give up and uninstall)
|
||||
*
|
||||
* upgrade_pending → ready (operator approves new capabilities)
|
||||
* upgrade_pending → error (upgrade worker fails)
|
||||
* upgrade_pending → uninstalled (reject upgrade and uninstall)
|
||||
*
|
||||
* uninstalled → installed (reinstall)
|
||||
*/
|
||||
const VALID_TRANSITIONS: Record<string, readonly PluginStatus[]> = {
|
||||
installed: ["ready", "error", "uninstalled"],
|
||||
ready: ["ready", "disabled", "error", "upgrade_pending", "uninstalled"],
|
||||
disabled: ["ready", "uninstalled"],
|
||||
error: ["ready", "uninstalled"],
|
||||
upgrade_pending: ["ready", "error", "uninstalled"],
|
||||
uninstalled: ["installed"], // reinstall
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a transition from `from` → `to` is valid.
|
||||
*/
|
||||
function isValidTransition(from: PluginStatus, to: PluginStatus): boolean {
|
||||
return VALID_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Events emitted by the PluginLifecycleManager.
|
||||
* Consumers can subscribe to these for routing-table updates, UI refresh
|
||||
* notifications, and observability.
|
||||
*/
|
||||
export interface PluginLifecycleEvents {
|
||||
/** Emitted after a plugin is loaded (installed → ready). */
|
||||
"plugin.loaded": { pluginId: string; pluginKey: string };
|
||||
/** Emitted after a plugin transitions to ready (enabled). */
|
||||
"plugin.enabled": { pluginId: string; pluginKey: string };
|
||||
/** Emitted after a plugin is disabled (ready → disabled). */
|
||||
"plugin.disabled": { pluginId: string; pluginKey: string; reason?: string };
|
||||
/** Emitted after a plugin is unloaded (any → uninstalled). */
|
||||
"plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean };
|
||||
/** Emitted on any status change. */
|
||||
"plugin.status_changed": {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
previousStatus: PluginStatus;
|
||||
newStatus: PluginStatus;
|
||||
};
|
||||
/** Emitted when a plugin enters an error state. */
|
||||
"plugin.error": { pluginId: string; pluginKey: string; error: string };
|
||||
/** Emitted when a plugin enters upgrade_pending. */
|
||||
"plugin.upgrade_pending": { pluginId: string; pluginKey: string };
|
||||
/** Emitted when a plugin worker process has been started. */
|
||||
"plugin.worker_started": { pluginId: string; pluginKey: string };
|
||||
/** Emitted when a plugin worker process has been stopped. */
|
||||
"plugin.worker_stopped": { pluginId: string; pluginKey: string };
|
||||
}
|
||||
|
||||
type LifecycleEventName = keyof PluginLifecycleEvents;
|
||||
type LifecycleEventPayload<K extends LifecycleEventName> = PluginLifecycleEvents[K];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginLifecycleManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginLifecycleManager {
|
||||
/**
|
||||
* Load a newly installed plugin – transitions `installed` → `ready`.
|
||||
*
|
||||
* This is called after the registry has persisted the initial install record.
|
||||
* The caller should have already spawned the worker and performed health
|
||||
* checks before calling this. If the worker fails, call `markError` instead.
|
||||
*/
|
||||
load(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state.
|
||||
* Transitions → `ready`.
|
||||
*/
|
||||
enable(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Disable a running plugin.
|
||||
* Transitions `ready` → `disabled`.
|
||||
*/
|
||||
disable(pluginId: string, reason?: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Unload (uninstall) a plugin from any active state.
|
||||
* Transitions → `uninstalled`.
|
||||
*
|
||||
* When `removeData` is true, the plugin row and cascaded config are
|
||||
* hard-deleted. Otherwise a soft-delete sets status to `uninstalled`.
|
||||
*/
|
||||
unload(pluginId: string, removeData?: boolean): Promise<PluginRecord | null>;
|
||||
|
||||
/**
|
||||
* Mark a plugin as errored (e.g. worker crash, health-check failure).
|
||||
* Transitions → `error`.
|
||||
*/
|
||||
markError(pluginId: string, error: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Mark a plugin as requiring upgrade approval.
|
||||
* Transitions `ready` → `upgrade_pending`.
|
||||
*/
|
||||
markUpgradePending(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Upgrade a plugin to a newer version.
|
||||
* This is a placeholder that handles the lifecycle state transition.
|
||||
* The actual package installation is handled by plugin-loader.
|
||||
*
|
||||
* If the upgrade adds new capabilities, transitions to `upgrade_pending`.
|
||||
* Otherwise, transitions to `ready` directly.
|
||||
*/
|
||||
upgrade(pluginId: string, version?: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Start the worker process for a plugin that is already in `ready` state.
|
||||
*
|
||||
* This is used by the server startup orchestration to start workers for
|
||||
* plugins that were persisted as `ready`. It requires a `PluginWorkerManager`
|
||||
* to have been provided at construction time.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to start
|
||||
* @param options - Worker start options (entrypoint path, config, etc.)
|
||||
* @throws if no worker manager is configured or the plugin is not ready
|
||||
*/
|
||||
startWorker(pluginId: string, options: WorkerStartOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker process for a plugin without changing lifecycle state.
|
||||
*
|
||||
* This is used during server shutdown to gracefully stop all workers.
|
||||
* It does not transition the plugin state — plugins remain in their
|
||||
* current status so they can be restarted on next server boot.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to stop
|
||||
*/
|
||||
stopWorker(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restart the worker process for a running plugin.
|
||||
*
|
||||
* Stops and re-starts the worker process. The plugin remains in `ready`
|
||||
* state throughout. This is typically called after a config change.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to restart
|
||||
* @throws if no worker manager is configured or the plugin is not ready
|
||||
*/
|
||||
restartWorker(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current lifecycle state for a plugin.
|
||||
*/
|
||||
getStatus(pluginId: string): Promise<PluginStatus | null>;
|
||||
|
||||
/**
|
||||
* Check whether a transition is allowed from the plugin's current state.
|
||||
*/
|
||||
canTransition(pluginId: string, to: PluginStatus): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Subscribe to lifecycle events.
|
||||
*/
|
||||
on<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribe from lifecycle events.
|
||||
*/
|
||||
off<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Subscribe to a lifecycle event once.
|
||||
*/
|
||||
once<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for constructing a PluginLifecycleManager.
|
||||
*/
|
||||
export interface PluginLifecycleManagerOptions {
|
||||
/** Plugin loader instance. Falls back to the default if omitted. */
|
||||
loader?: PluginLoader;
|
||||
|
||||
/**
|
||||
* Worker process manager. When provided, lifecycle transitions that bring
|
||||
* a plugin online (load, enable, upgrade-to-ready) will start the worker
|
||||
* process, and transitions that take a plugin offline (disable, unload,
|
||||
* markError) will stop it.
|
||||
*
|
||||
* When omitted the lifecycle manager operates in state-only mode — the
|
||||
* caller is responsible for managing worker processes externally.
|
||||
*/
|
||||
workerManager?: PluginWorkerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginLifecycleManager.
|
||||
*
|
||||
* This service orchestrates plugin state transitions on top of the
|
||||
* `pluginRegistryService` (which handles raw DB persistence). It enforces
|
||||
* the lifecycle state machine, emits events for downstream consumers
|
||||
* (routing tables, UI, observability), and manages worker processes via
|
||||
* the `PluginWorkerManager` when one is provided.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const lifecycle = pluginLifecycleManager(db, {
|
||||
* workerManager: createPluginWorkerManager(),
|
||||
* });
|
||||
* lifecycle.on("plugin.enabled", ({ pluginId }) => { ... });
|
||||
* await lifecycle.load(pluginId);
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugins.status` column
|
||||
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||
*/
|
||||
export function pluginLifecycleManager(
|
||||
db: Db,
|
||||
options?: PluginLoader | PluginLifecycleManagerOptions,
|
||||
): PluginLifecycleManager {
|
||||
// Support the legacy signature: pluginLifecycleManager(db, loader)
|
||||
// as well as the new options object form.
|
||||
let loaderArg: PluginLoader | undefined;
|
||||
let workerManager: PluginWorkerManager | undefined;
|
||||
|
||||
if (options && typeof options === "object" && "discoverAll" in options) {
|
||||
// Legacy: second arg is a PluginLoader directly
|
||||
loaderArg = options as PluginLoader;
|
||||
} else if (options && typeof options === "object") {
|
||||
const opts = options as PluginLifecycleManagerOptions;
|
||||
loaderArg = opts.loader;
|
||||
workerManager = opts.workerManager;
|
||||
}
|
||||
|
||||
const registry = pluginRegistryService(db);
|
||||
const pluginLoaderInstance = loaderArg ?? pluginLoader(db);
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
|
||||
|
||||
const log = logger.child({ service: "plugin-lifecycle" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function requirePlugin(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin) throw notFound(`Plugin not found: ${pluginId}`);
|
||||
return plugin as PluginRecord;
|
||||
}
|
||||
|
||||
function assertTransition(plugin: PluginRecord, to: PluginStatus): void {
|
||||
if (!isValidTransition(plugin.status, to)) {
|
||||
throw badRequest(
|
||||
`Invalid lifecycle transition: ${plugin.status} → ${to} for plugin ${plugin.pluginKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(
|
||||
pluginId: string,
|
||||
to: PluginStatus,
|
||||
lastError: string | null = null,
|
||||
existingPlugin?: PluginRecord,
|
||||
): Promise<PluginRecord> {
|
||||
const plugin = existingPlugin ?? await requirePlugin(pluginId);
|
||||
assertTransition(plugin, to);
|
||||
|
||||
const previousStatus = plugin.status;
|
||||
|
||||
const updated = await registry.updateStatus(pluginId, {
|
||||
status: to,
|
||||
lastError,
|
||||
});
|
||||
|
||||
if (!updated) throw notFound(`Plugin not found after status update: ${pluginId}`);
|
||||
const result = updated as PluginRecord;
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: result.pluginKey, from: previousStatus, to },
|
||||
`plugin lifecycle: ${previousStatus} → ${to}`,
|
||||
);
|
||||
|
||||
// Emit the generic status_changed event
|
||||
emitter.emit("plugin.status_changed", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
previousStatus,
|
||||
newStatus: to,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function emitDomain(
|
||||
event: LifecycleEventName,
|
||||
payload: PluginLifecycleEvents[LifecycleEventName],
|
||||
): void {
|
||||
emitter.emit(event, payload);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Worker management helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stop the worker for a plugin if one is running.
|
||||
* This is a best-effort operation — if no worker manager is configured
|
||||
* or no worker is running, it silently succeeds.
|
||||
*/
|
||||
async function stopWorkerIfRunning(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
): Promise<void> {
|
||||
if (!workerManager) return;
|
||||
if (!workerManager.isRunning(pluginId) && !workerManager.getWorker(pluginId)) return;
|
||||
|
||||
try {
|
||||
await workerManager.stopWorker(pluginId);
|
||||
log.info({ pluginId, pluginKey }, "plugin lifecycle: worker stopped");
|
||||
emitDomain("plugin.worker_stopped", { pluginId, pluginKey });
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ pluginId, pluginKey, err: err instanceof Error ? err.message : String(err) },
|
||||
"plugin lifecycle: failed to stop worker (best-effort)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateReadyPlugin(pluginId: string): Promise<void> {
|
||||
const supportsRuntimeActivation =
|
||||
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||
&& typeof pluginLoaderInstance.loadSingle === "function";
|
||||
if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadResult = await pluginLoaderInstance.loadSingle(pluginId);
|
||||
if (!loadResult.success) {
|
||||
throw new Error(
|
||||
loadResult.error
|
||||
?? `Failed to activate plugin ${loadResult.plugin.pluginKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivatePluginRuntime(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
): Promise<void> {
|
||||
const supportsRuntimeDeactivation =
|
||||
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||
&& typeof pluginLoaderInstance.unloadSingle === "function";
|
||||
|
||||
if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) {
|
||||
await pluginLoaderInstance.unloadSingle(pluginId, pluginKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopWorkerIfRunning(pluginId, pluginKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// -- load -------------------------------------------------------------
|
||||
/**
|
||||
* load — Transitions a plugin to 'ready' status and starts its worker.
|
||||
*
|
||||
* This method is called after a plugin has been successfully installed and
|
||||
* validated. It marks the plugin as ready in the database and immediately
|
||||
* triggers the plugin loader to start the worker process.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to load.
|
||||
* @returns The updated plugin record.
|
||||
*/
|
||||
async load(pluginId: string): Promise<PluginRecord> {
|
||||
const result = await transition(pluginId, "ready");
|
||||
await activateReadyPlugin(pluginId);
|
||||
|
||||
emitDomain("plugin.loaded", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- enable -----------------------------------------------------------
|
||||
/**
|
||||
* enable — Re-enables a plugin that was previously in an error or upgrade state.
|
||||
*
|
||||
* Similar to load(), this method transitions the plugin to 'ready' and starts
|
||||
* its worker, but it specifically targets plugins that are currently disabled.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to enable.
|
||||
* @returns The updated plugin record.
|
||||
*/
|
||||
async enable(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Only allow enabling from disabled, error, or upgrade_pending states
|
||||
if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") {
|
||||
throw badRequest(
|
||||
`Cannot enable plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await transition(pluginId, "ready", null, plugin);
|
||||
await activateReadyPlugin(pluginId);
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- disable ----------------------------------------------------------
|
||||
async disable(pluginId: string, reason?: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Only allow disabling from ready state
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot disable plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status to be disabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "disabled", reason ?? null, plugin);
|
||||
emitDomain("plugin.disabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
reason,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- unload -----------------------------------------------------------
|
||||
async unload(
|
||||
pluginId: string,
|
||||
removeData = false,
|
||||
): Promise<PluginRecord | null> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// If already uninstalled and removeData, hard-delete
|
||||
if (plugin.status === "uninstalled") {
|
||||
if (removeData) {
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
const deleted = await registry.uninstall(pluginId, true);
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: hard-deleted already-uninstalled plugin",
|
||||
);
|
||||
emitDomain("plugin.unloaded", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
removeData: true,
|
||||
});
|
||||
return deleted as PluginRecord | null;
|
||||
}
|
||||
throw badRequest(
|
||||
`Plugin ${plugin.pluginKey} is already uninstalled. ` +
|
||||
`Use removeData=true to permanently delete it.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
|
||||
// Perform the uninstall via registry (handles soft/hard delete)
|
||||
const result = await registry.uninstall(pluginId, removeData);
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, removeData },
|
||||
`plugin lifecycle: ${plugin.status} → uninstalled${removeData ? " (hard delete)" : ""}`,
|
||||
);
|
||||
|
||||
emitter.emit("plugin.status_changed", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
previousStatus: plugin.status,
|
||||
newStatus: "uninstalled" as PluginStatus,
|
||||
});
|
||||
|
||||
emitDomain("plugin.unloaded", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
removeData,
|
||||
});
|
||||
|
||||
return result as PluginRecord | null;
|
||||
},
|
||||
|
||||
// -- markError --------------------------------------------------------
|
||||
async markError(pluginId: string, error: string): Promise<PluginRecord> {
|
||||
// Stop the worker — the plugin is in an error state and should not
|
||||
// continue running. The worker manager's auto-restart is disabled
|
||||
// because we are intentionally taking the plugin offline.
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "error", error, plugin);
|
||||
emitDomain("plugin.error", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
error,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- markUpgradePending -----------------------------------------------
|
||||
async markUpgradePending(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "upgrade_pending", null, plugin);
|
||||
emitDomain("plugin.upgrade_pending", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- upgrade ----------------------------------------------------------
|
||||
/**
|
||||
* Upgrade a plugin to a newer version by performing a package update and
|
||||
* managing the lifecycle state transition.
|
||||
*
|
||||
* Following PLUGIN_SPEC.md §25.3, the upgrade process:
|
||||
* 1. Stops the current worker process (if running).
|
||||
* 2. Fetches and validates the new plugin package via the `PluginLoader`.
|
||||
* 3. Compares the capabilities declared in the new manifest against the old one.
|
||||
* 4. If new capabilities are added, transitions the plugin to `upgrade_pending`
|
||||
* to await operator approval (worker stays stopped).
|
||||
* 5. If no new capabilities are added, transitions the plugin back to `ready`
|
||||
* with the updated version and manifest metadata.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to upgrade.
|
||||
* @param version - Optional target version specifier.
|
||||
* @returns The updated `PluginRecord`.
|
||||
* @throws {BadRequest} If the plugin is not in a ready or upgrade_pending state.
|
||||
*/
|
||||
async upgrade(pluginId: string, version?: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Can only upgrade plugins that are ready or already in upgrade_pending
|
||||
if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") {
|
||||
throw badRequest(
|
||||
`Cannot upgrade plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, targetVersion: version },
|
||||
"plugin lifecycle: upgrade requested",
|
||||
);
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
// 1. Download and validate new package via loader
|
||||
const { oldManifest, newManifest, discovered } =
|
||||
await pluginLoaderInstance.upgradePlugin(pluginId, { version });
|
||||
|
||||
log.info(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
oldVersion: oldManifest.version,
|
||||
newVersion: newManifest.version,
|
||||
},
|
||||
"plugin lifecycle: package upgraded on disk",
|
||||
);
|
||||
|
||||
// 2. Compare capabilities
|
||||
const addedCaps = newManifest.capabilities.filter(
|
||||
(cap) => !oldManifest.capabilities.includes(cap),
|
||||
);
|
||||
|
||||
// 3. Transition state
|
||||
if (addedCaps.length > 0) {
|
||||
// New capabilities require operator approval — worker stays stopped
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, addedCaps },
|
||||
"plugin lifecycle: new capabilities detected, transitioning to upgrade_pending",
|
||||
);
|
||||
// Skip the inner stopWorkerIfRunning since we already stopped above
|
||||
const result = await transition(pluginId, "upgrade_pending", null, plugin);
|
||||
emitDomain("plugin.upgrade_pending", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
const result = await transition(pluginId, "ready", null, {
|
||||
...plugin,
|
||||
version: discovered.version,
|
||||
manifestJson: newManifest,
|
||||
} as PluginRecord);
|
||||
await activateReadyPlugin(pluginId);
|
||||
|
||||
emitDomain("plugin.loaded", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
// -- startWorker ------------------------------------------------------
|
||||
async startWorker(
|
||||
pluginId: string,
|
||||
options: WorkerStartOptions,
|
||||
): Promise<void> {
|
||||
if (!workerManager) {
|
||||
throw badRequest(
|
||||
"Cannot start worker: no PluginWorkerManager is configured. " +
|
||||
"Provide a workerManager option when constructing the lifecycle manager.",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot start worker for plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: starting worker",
|
||||
);
|
||||
|
||||
await workerManager.startWorker(pluginId, options);
|
||||
emitDomain("plugin.worker_started", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: worker started",
|
||||
);
|
||||
},
|
||||
|
||||
// -- stopWorker -------------------------------------------------------
|
||||
async stopWorker(pluginId: string): Promise<void> {
|
||||
if (!workerManager) return; // No worker manager — nothing to stop
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
},
|
||||
|
||||
// -- restartWorker ----------------------------------------------------
|
||||
async restartWorker(pluginId: string): Promise<void> {
|
||||
if (!workerManager) {
|
||||
throw badRequest(
|
||||
"Cannot restart worker: no PluginWorkerManager is configured.",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot restart worker for plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status.`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle = workerManager.getWorker(pluginId);
|
||||
if (!handle) {
|
||||
throw badRequest(
|
||||
`Cannot restart worker for plugin "${plugin.pluginKey}": no worker is running.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: restarting worker",
|
||||
);
|
||||
|
||||
await handle.restart();
|
||||
|
||||
emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey });
|
||||
emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey });
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: worker restarted",
|
||||
);
|
||||
},
|
||||
|
||||
// -- getStatus --------------------------------------------------------
|
||||
async getStatus(pluginId: string): Promise<PluginStatus | null> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
return plugin?.status ?? null;
|
||||
},
|
||||
|
||||
// -- canTransition ----------------------------------------------------
|
||||
async canTransition(pluginId: string, to: PluginStatus): Promise<boolean> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin) return false;
|
||||
return isValidTransition(plugin.status, to);
|
||||
},
|
||||
|
||||
// -- Event subscriptions ----------------------------------------------
|
||||
on(event, listener) {
|
||||
emitter.on(event, listener);
|
||||
},
|
||||
|
||||
off(event, listener) {
|
||||
emitter.off(event, listener);
|
||||
},
|
||||
|
||||
once(event, listener) {
|
||||
emitter.once(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
1950
server/src/services/plugin-loader.ts
Normal file
1950
server/src/services/plugin-loader.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user