From 80cdbdbd47ceb6fcb7f2ea1d784063cc9e51698d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 16:22:34 -0500 Subject: [PATCH 1/9] Add plugin framework and settings UI --- doc/plugins/PLUGIN_SPEC.md | 24 + .../db/src/migrations/0028_plugin_tables.sql | 177 ++ packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/index.ts | 8 + .../db/src/schema/plugin_company_settings.ts | 41 + packages/db/src/schema/plugin_config.ts | 30 + packages/db/src/schema/plugin_entities.ts | 54 + packages/db/src/schema/plugin_jobs.ts | 102 + packages/db/src/schema/plugin_logs.ts | 43 + packages/db/src/schema/plugin_state.ts | 90 + packages/db/src/schema/plugin_webhooks.ts | 65 + packages/db/src/schema/plugins.ts | 45 + .../plugins/create-paperclip-plugin/README.md | 38 + .../create-paperclip-plugin/package.json | 40 + .../create-paperclip-plugin/src/index.ts | 398 +++ .../create-paperclip-plugin/tsconfig.json | 9 + .../plugin-file-browser-example/README.md | 62 + .../plugin-file-browser-example/package.json | 42 + .../scripts/build-ui.mjs | 24 + .../plugin-file-browser-example/src/index.ts | 2 + .../src/manifest.ts | 85 + .../src/ui/index.tsx | 815 ++++++ .../plugin-file-browser-example/src/worker.ts | 226 ++ .../plugin-file-browser-example/tsconfig.json | 10 + .../plugin-hello-world-example/README.md | 38 + .../plugin-hello-world-example/package.json | 35 + .../plugin-hello-world-example/src/index.ts | 2 + .../src/manifest.ts | 39 + .../src/ui/index.tsx | 17 + .../plugin-hello-world-example/src/worker.ts | 27 + .../plugin-hello-world-example/tsconfig.json | 10 + packages/plugins/sdk/README.md | 959 +++++++ packages/plugins/sdk/package.json | 124 + packages/plugins/sdk/src/bundlers.ts | 161 ++ packages/plugins/sdk/src/define-plugin.ts | 255 ++ packages/plugins/sdk/src/dev-cli.ts | 54 + packages/plugins/sdk/src/dev-server.ts | 228 ++ .../plugins/sdk/src/host-client-factory.ts | 563 ++++ packages/plugins/sdk/src/index.ts | 287 ++ packages/plugins/sdk/src/protocol.ts | 1038 +++++++ packages/plugins/sdk/src/testing.ts | 720 +++++ packages/plugins/sdk/src/types.ts | 1116 ++++++++ packages/plugins/sdk/src/ui/components.ts | 310 +++ packages/plugins/sdk/src/ui/hooks.ts | 153 ++ packages/plugins/sdk/src/ui/index.ts | 125 + packages/plugins/sdk/src/ui/runtime.ts | 51 + packages/plugins/sdk/src/ui/types.ts | 358 +++ packages/plugins/sdk/src/worker-rpc-host.ts | 1221 +++++++++ packages/plugins/sdk/tsconfig.json | 9 + packages/shared/src/constants.ts | 312 ++- packages/shared/src/index.ts | 94 + packages/shared/src/types/index.ts | 24 + packages/shared/src/types/plugin.ts | 545 ++++ packages/shared/src/validators/index.ts | 42 + packages/shared/src/validators/plugin.ts | 694 +++++ pnpm-workspace.yaml | 2 + scripts/ensure-plugin-build-deps.mjs | 46 + server/package.json | 5 +- .../__tests__/plugin-worker-manager.test.ts | 43 + server/src/app.ts | 125 +- server/src/routes/plugin-ui-static.ts | 496 ++++ server/src/routes/plugins.ts | 2417 +++++++++++++++++ server/src/services/cron.ts | 373 +++ server/src/services/live-events.ts | 14 + .../services/plugin-capability-validator.ts | 451 +++ .../src/services/plugin-config-validator.ts | 50 + server/src/services/plugin-dev-watcher.ts | 189 ++ server/src/services/plugin-event-bus.ts | 515 ++++ .../services/plugin-host-service-cleanup.ts | 59 + server/src/services/plugin-host-services.ts | 1077 ++++++++ server/src/services/plugin-job-coordinator.ts | 260 ++ server/src/services/plugin-job-scheduler.ts | 752 +++++ server/src/services/plugin-job-store.ts | 465 ++++ server/src/services/plugin-lifecycle.ts | 807 ++++++ server/src/services/plugin-loader.ts | 1852 +++++++++++++ server/src/services/plugin-log-retention.ts | 86 + .../src/services/plugin-manifest-validator.ts | 163 ++ server/src/services/plugin-registry.ts | 963 +++++++ server/src/services/plugin-runtime-sandbox.ts | 221 ++ server/src/services/plugin-secrets-handler.ts | 367 +++ server/src/services/plugin-state-store.ts | 237 ++ server/src/services/plugin-stream-bus.ts | 81 + server/src/services/plugin-tool-dispatcher.ts | 448 +++ server/src/services/plugin-tool-registry.ts | 449 +++ server/src/services/plugin-worker-manager.ts | 1342 +++++++++ ui/src/App.tsx | 13 +- ui/src/api/client.ts | 2 + ui/src/api/plugins.ts | 469 ++++ ui/src/components/InstanceSidebar.tsx | 5 +- ui/src/components/JsonSchemaForm.tsx | 1048 +++++++ ui/src/components/Layout.tsx | 54 +- ui/src/components/SidebarProjects.tsx | 75 +- ui/src/lib/queryKeys.ts | 16 + ui/src/main.tsx | 5 + ui/src/pages/Dashboard.tsx | 8 + ui/src/pages/PluginManager.tsx | 512 ++++ ui/src/pages/PluginPage.tsx | 113 + ui/src/pages/PluginSettings.tsx | 836 ++++++ ui/src/pages/ProjectDetail.tsx | 66 +- ui/src/plugins/bridge-init.ts | 116 + ui/src/plugins/bridge.ts | 361 +++ ui/src/plugins/launchers.tsx | 829 ++++++ ui/src/plugins/slots.tsx | 862 ++++++ 103 files changed, 31760 insertions(+), 35 deletions(-) create mode 100644 packages/db/src/migrations/0028_plugin_tables.sql create mode 100644 packages/db/src/schema/plugin_company_settings.ts create mode 100644 packages/db/src/schema/plugin_config.ts create mode 100644 packages/db/src/schema/plugin_entities.ts create mode 100644 packages/db/src/schema/plugin_jobs.ts create mode 100644 packages/db/src/schema/plugin_logs.ts create mode 100644 packages/db/src/schema/plugin_state.ts create mode 100644 packages/db/src/schema/plugin_webhooks.ts create mode 100644 packages/db/src/schema/plugins.ts create mode 100644 packages/plugins/create-paperclip-plugin/README.md create mode 100644 packages/plugins/create-paperclip-plugin/package.json create mode 100644 packages/plugins/create-paperclip-plugin/src/index.ts create mode 100644 packages/plugins/create-paperclip-plugin/tsconfig.json create mode 100644 packages/plugins/examples/plugin-file-browser-example/README.md create mode 100644 packages/plugins/examples/plugin-file-browser-example/package.json create mode 100644 packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/index.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/tsconfig.json create mode 100644 packages/plugins/examples/plugin-hello-world-example/README.md create mode 100644 packages/plugins/examples/plugin-hello-world-example/package.json create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/index.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/tsconfig.json create mode 100644 packages/plugins/sdk/README.md create mode 100644 packages/plugins/sdk/package.json create mode 100644 packages/plugins/sdk/src/bundlers.ts create mode 100644 packages/plugins/sdk/src/define-plugin.ts create mode 100644 packages/plugins/sdk/src/dev-cli.ts create mode 100644 packages/plugins/sdk/src/dev-server.ts create mode 100644 packages/plugins/sdk/src/host-client-factory.ts create mode 100644 packages/plugins/sdk/src/index.ts create mode 100644 packages/plugins/sdk/src/protocol.ts create mode 100644 packages/plugins/sdk/src/testing.ts create mode 100644 packages/plugins/sdk/src/types.ts create mode 100644 packages/plugins/sdk/src/ui/components.ts create mode 100644 packages/plugins/sdk/src/ui/hooks.ts create mode 100644 packages/plugins/sdk/src/ui/index.ts create mode 100644 packages/plugins/sdk/src/ui/runtime.ts create mode 100644 packages/plugins/sdk/src/ui/types.ts create mode 100644 packages/plugins/sdk/src/worker-rpc-host.ts create mode 100644 packages/plugins/sdk/tsconfig.json create mode 100644 packages/shared/src/types/plugin.ts create mode 100644 packages/shared/src/validators/plugin.ts create mode 100644 scripts/ensure-plugin-build-deps.mjs create mode 100644 server/src/__tests__/plugin-worker-manager.test.ts create mode 100644 server/src/routes/plugin-ui-static.ts create mode 100644 server/src/routes/plugins.ts create mode 100644 server/src/services/cron.ts create mode 100644 server/src/services/plugin-capability-validator.ts create mode 100644 server/src/services/plugin-config-validator.ts create mode 100644 server/src/services/plugin-dev-watcher.ts create mode 100644 server/src/services/plugin-event-bus.ts create mode 100644 server/src/services/plugin-host-service-cleanup.ts create mode 100644 server/src/services/plugin-host-services.ts create mode 100644 server/src/services/plugin-job-coordinator.ts create mode 100644 server/src/services/plugin-job-scheduler.ts create mode 100644 server/src/services/plugin-job-store.ts create mode 100644 server/src/services/plugin-lifecycle.ts create mode 100644 server/src/services/plugin-loader.ts create mode 100644 server/src/services/plugin-log-retention.ts create mode 100644 server/src/services/plugin-manifest-validator.ts create mode 100644 server/src/services/plugin-registry.ts create mode 100644 server/src/services/plugin-runtime-sandbox.ts create mode 100644 server/src/services/plugin-secrets-handler.ts create mode 100644 server/src/services/plugin-state-store.ts create mode 100644 server/src/services/plugin-stream-bus.ts create mode 100644 server/src/services/plugin-tool-dispatcher.ts create mode 100644 server/src/services/plugin-tool-registry.ts create mode 100644 server/src/services/plugin-worker-manager.ts create mode 100644 ui/src/api/plugins.ts create mode 100644 ui/src/components/JsonSchemaForm.tsx create mode 100644 ui/src/pages/PluginManager.tsx create mode 100644 ui/src/pages/PluginPage.tsx create mode 100644 ui/src/pages/PluginSettings.tsx create mode 100644 ui/src/plugins/bridge-init.ts create mode 100644 ui/src/plugins/bridge.ts create mode 100644 ui/src/plugins/launchers.tsx create mode 100644 ui/src/plugins/slots.tsx diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 896f5115..65fabac0 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -8,6 +8,26 @@ 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: + +- 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. + +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 +232,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 +259,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. diff --git a/packages/db/src/migrations/0028_plugin_tables.sql b/packages/db/src/migrations/0028_plugin_tables.sql new file mode 100644 index 00000000..8ee0d937 --- /dev/null +++ b/packages/db/src/migrations/0028_plugin_tables.sql @@ -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"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 80a1dfbd..63c18087 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1773150731736, "tag": "0027_tranquil_tenebrous", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773417600000, + "tag": "0028_plugin_tables", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3416ea9a..25904f70 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -32,3 +32,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"; diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts new file mode 100644 index 00000000..87d4b4af --- /dev/null +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -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>().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, + ), + }), +); diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts new file mode 100644 index 00000000..24407b97 --- /dev/null +++ b/packages/db/src/schema/plugin_config.ts @@ -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>().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), + }), +); diff --git a/packages/db/src/schema/plugin_entities.ts b/packages/db/src/schema/plugin_entities.ts new file mode 100644 index 00000000..5f732304 --- /dev/null +++ b/packages/db/src/schema/plugin_entities.ts @@ -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().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>().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, + ), + }), +); diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts new file mode 100644 index 00000000..fec0d0c4 --- /dev/null +++ b/packages/db/src/schema/plugin_jobs.ts @@ -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().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().notNull(), + /** Current lifecycle state of this run. */ + status: text("status").$type().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().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), + }), +); diff --git a/packages/db/src/schema/plugin_logs.ts b/packages/db/src/schema/plugin_logs.ts new file mode 100644 index 00000000..d32908f1 --- /dev/null +++ b/packages/db/src/schema/plugin_logs.ts @@ -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>(), + 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), + }), +); diff --git a/packages/db/src/schema/plugin_state.ts b/packages/db/src/schema/plugin_state.ts new file mode 100644 index 00000000..600797fa --- /dev/null +++ b/packages/db/src/schema/plugin_state.ts @@ -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().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, + ), + }), +); diff --git a/packages/db/src/schema/plugin_webhooks.ts b/packages/db/src/schema/plugin_webhooks.ts new file mode 100644 index 00000000..0580e970 --- /dev/null +++ b/packages/db/src/schema/plugin_webhooks.ts @@ -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().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>().notNull(), + /** Relevant HTTP headers from the inbound request (e.g. signature headers). */ + headers: jsonb("headers").$type>().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), + }), +); diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts new file mode 100644 index 00000000..948e5d60 --- /dev/null +++ b/packages/db/src/schema/plugins.ts @@ -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().notNull().default([]), + manifestJson: jsonb("manifest_json").$type().notNull(), + status: text("status").$type().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), + }), +); diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md new file mode 100644 index 00000000..46519da1 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -0,0 +1,38 @@ +# @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 `@paperclipai/plugin-sdk/ui` +- 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`) + +## 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 +``` diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json new file mode 100644 index 00000000..e863cd6c --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/package.json @@ -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" + } +} diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts new file mode 100644 index 00000000..6d0e6c2d --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -0,0 +1,398 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +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"; +} + +/** 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); +} + +/** + * 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); + + fs.mkdirSync(outputDir, { recursive: true }); + + 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", + typecheck: "tsc --noEmit" + }, + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui/" + }, + keywords: ["paperclip", "plugin", category], + author, + license: "MIT", + dependencies: { + "@paperclipai/plugin-sdk": "^1.0.0" + }, + 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" + } + }; + + 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: "src" + }, + 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, "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 { MetricCard, StatusBadge, usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return ; + + return ( +
+ + +
+ ); +} +`, + ); + + 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 +\`\`\` + +## 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. +`, + ); + + writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\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 [--template default|connector|workspace] [--output ]"); + 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, + }); + + // eslint-disable-next-line no-console + console.log(`Created plugin scaffold at ${out}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli(); +} diff --git a/packages/plugins/create-paperclip-plugin/tsconfig.json b/packages/plugins/create-paperclip-plugin/tsconfig.json new file mode 100644 index 00000000..90314411 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-file-browser-example/README.md b/packages/plugins/examples/plugin-file-browser-example/README.md new file mode 100644 index 00000000..ca02fcf7 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/README.md @@ -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). diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json new file mode 100644 index 00000000..86c720d4 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -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" + } +} diff --git a/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs @@ -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", +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/src/index.ts b/packages/plugins/examples/plugin-file-browser-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts new file mode 100644 index 00000000..027c134b --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts @@ -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; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx new file mode 100644 index 00000000..0e12d903 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -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, + options?: { bottomPadding?: number; minHeight?: number }, +): number | null { + const bottomPadding = options?.bottomPadding ?? 24; + const minHeight = options?.minHeight ?? 384; + const [height, setHeight] = useState(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 ( +
  • + + {isExpanded ? ( + + ) : null} +
  • + ); + } + + return ( +
  • + +
  • + ); +} + +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 ( +
      + {children.map((child) => ( + + ))} +
    + ); +} + +/** + * 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("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) => { + 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 ( + + Files + + ); +} + +/** + * 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(null); + const availableHeight = useAvailableHeight(panesRef, { + bottomPadding: isMobile ? 16 : 24, + minHeight: isMobile ? 320 : 420, + }); + const { data: workspacesData } = usePluginData("workspaces", { + projectId, + companyId, + }); + const workspaces = workspacesData ?? []; + const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); + const [workspaceId, setWorkspaceId] = useState(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(() => { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("file") || null; + }); + const lastConsumedFileRef = useRef(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(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(null); + const viewRef = useRef(null); + const loadedContentRef = useRef(""); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + const [saveError, setSaveError] = useState(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 ( +
    +
    + + +
    + +
    +
    +
    + File Tree +
    +
    + {selectedWorkspace ? ( + fileListLoading ? ( +

    Loading files...

    + ) : entries.length > 0 ? ( +
      + {entries.map((entry) => ( + { + setSelectedPath(path); + setMobileView("editor"); + }} + /> + ))} +
    + ) : ( +

    No files found in this workspace.

    + ) + ) : ( +

    Select a workspace to browse files.

    + )} +
    +
    +
    +
    +
    + +
    Editor
    +
    {selectedPath ?? "No file selected"}
    +
    +
    + +
    +
    + {isDirty || saveMessage || saveError ? ( +
    + {saveError ? ( + {saveError} + ) : saveMessage ? ( + {saveMessage} + ) : ( + Unsaved changes + )} +
    + ) : null} + {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? ( +
    {fileContentData.error}
    + ) : null} +
    +
    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// 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) { + 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("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 ( + + ); +} + +// --------------------------------------------------------------------------- +// 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("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 ( +
    +
    + Files +
    + {data.links.map((link) => { + const href = buildFileBrowserHref(prefix, projectId, link); + const fileName = link.split("/").pop() ?? link; + return ( + 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`} + > + {fileName} + + ); + })} +
    + ); +} diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts new file mode 100644 index 00000000..1c39af75 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -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(); + 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 the + // commentAnnotationMode setting and hide themselves when disabled. + ctx.data.register("plugin-config", async () => { + const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record | null; + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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); diff --git a/packages/plugins/examples/plugin-file-browser-example/tsconfig.json b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-hello-world-example/README.md b/packages/plugins/examples/plugin-hello-world-example/README.md new file mode 100644 index 00000000..889c9d25 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/README.md @@ -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`. diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json new file mode 100644 index 00000000..5d055caa --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/package.json @@ -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" + } +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/index.ts b/packages/plugins/examples/plugin-hello-world-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts new file mode 100644 index 00000000..2fcd8077 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts @@ -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; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx new file mode 100644 index 00000000..10e12fb0 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx @@ -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 ( +
    + Hello world +
    This widget was added by @paperclipai/plugin-hello-world-example.
    + {/* Include host context so authors can see where scoped IDs come from. */} +
    Company context: {context.companyId}
    +
    + ); +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/worker.ts b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts new file mode 100644 index 00000000..07c7fbea --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts @@ -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); diff --git a/packages/plugins/examples/plugin-hello-world-example/tsconfig.json b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md new file mode 100644 index 00000000..66cdebcb --- /dev/null +++ b/packages/plugins/sdk/README.md @@ -0,0 +1,959 @@ +# `@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, components, 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`, shared components | +| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only | +| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces | +| `@paperclipai/plugin-sdk/ui/components` | `MetricCard`, `StatusBadge`, `Spinner`, `ErrorBoundary`, etc. | +| `@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. + +- 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. + +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`, `assets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. All 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..` (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-scoped delivery:** Events with a `companyId` are only delivered to plugins that are enabled for that company. If a company has disabled a plugin via settings, that plugin's handlers will not receive events belonging to that company. Events without a `companyId` are delivered to all subscribers. + +## 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-scoped). 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::`. 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` | +| | `assets.write` | +| | `assets.read` | +| | `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, MetricCard } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget() { + const { data } = usePluginData<{ status: string }>("health"); + const ping = usePluginAction("ping"); + return ( +
    + + +
    + ); +} +``` + +### Hooks reference + +#### `usePluginData(key, params?)` + +Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`. + +```tsx +import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui"; + +interface SyncStatus { + lastSyncAt: string; + syncedCount: number; + healthy: boolean; +} + +export function SyncStatusWidget({ context }: PluginWidgetProps) { + const { data, loading, error, refresh } = usePluginData("sync-status", { + companyId: context.companyId, + }); + + if (loading) return ; + if (error) return ; + + return ( +
    + +

    Synced {data!.syncedCount} items

    +

    Last sync: {data!.lastSyncAt}

    + +
    + ); +} +``` + +#### `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(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 ( +
    + + {error &&

    {error}

    } +
    + ); +} +``` + +#### `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

    No linked Linear issue.

    ; + return View in Linear; +} +``` + +#### `usePluginStream(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("chat-stream", { + companyId: context.companyId ?? undefined, + }); + + return ( +
    + {events.map((e, i) => {e.text})} + {connected && } + +
    + ); +} +``` + +The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection. + +### Shared components reference + +All components are provided by the host at runtime and match the host design tokens. Import from `@paperclipai/plugin-sdk/ui` or `@paperclipai/plugin-sdk/ui/components`. + +#### `MetricCard` + +Displays a single metric value with optional trend and sparkline. + +```tsx + + +``` + +#### `StatusBadge` + +Inline status indicator with semantic color. + +```tsx + + + +``` + +#### `DataTable` + +Sortable, paginated table. + +```tsx + new Date(v as string).toLocaleDateString() }, + ]} + rows={issues} + totalCount={totalCount} + page={page} + pageSize={25} + onPageChange={setPage} + onSort={(key, dir) => setSortBy({ key, dir })} +/> +``` + +#### `TimeseriesChart` + +Line or bar chart for time-series data. + +```tsx + +``` + +#### `ActionBar` + +Row of action buttons wired to the plugin bridge. + +```tsx + data.refresh()} + onError={(key, err) => console.error(key, err)} +/> +``` + +#### `LogView`, `JsonTree`, `KeyValueList`, `MarkdownBlock` + +```tsx + + + + +``` + +#### `Spinner`, `ErrorBoundary` + +```tsx + + +Something went wrong.

    } onError={(err) => console.error(err)}> + +
    +``` + +### 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, KeyValueList, Spinner } from "@paperclipai/plugin-sdk/ui"; + +export function AgentMetricsTab({ context }: PluginDetailTabProps) { + const { data, loading } = usePluginData>("agent-metrics", { + agentId: context.entityId, + companyId: context.companyId, + }); + + if (loading) return ; + if (!data) return

    No metrics available.

    ; + + return ( + ({ label, value }))} + /> + ); +} +``` + +## 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 ( + + Files + + ); +} +``` + +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 { + ErrorBoundary, + Spinner, + 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(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 ( + + + {open ? ( +
    !submitting && setOpen(false)} + > +
    event.stopPropagation()} + > +

    Sync this project?

    +

    + Queue a sync for {context.projectId}. +

    + {errorMessage ? ( +

    {errorMessage}

    + ) : null} +
    + + +
    +
    +
    + ) : 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(`agent:${agentId}`, { companyId }); + const [prompt, setPrompt] = useState(""); + + async function send() { + setPrompt(""); + await askAgent({ agentId, companyId, prompt }); + } + + return ( +
    +
    {events.filter(e => e.type === "chunk").map((e, i) => {e.text})}
    + setPrompt(e.target.value)} /> + + {connected && } +
    + ); +} +``` + +## 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 diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json new file mode 100644 index 00000000..d5e5c19c --- /dev/null +++ b/packages/plugins/sdk/package.json @@ -0,0 +1,124 @@ +{ + "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" + }, + "./ui/components": { + "types": "./dist/ui/components.d.ts", + "import": "./dist/ui/components.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" + }, + "./ui/components": { + "types": "./dist/ui/components.d.ts", + "import": "./dist/ui/components.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 + } + } +} diff --git a/packages/plugins/sdk/src/bundlers.ts b/packages/plugins/sdk/src/bundlers.ts new file mode 100644 index 00000000..a8ec6850 --- /dev/null +++ b/packages/plugins/sdk/src/bundlers.ts @@ -0,0 +1,161 @@ +/** + * 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", + "@paperclipai/plugin-sdk/ui/components", + "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: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "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: ["@paperclipai/plugin-sdk", "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 } : {}), + }, + }; +} diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts new file mode 100644 index 00000000..43fefdd2 --- /dev/null +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -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; +} + +// --------------------------------------------------------------------------- +// 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; + /** 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; + + /** + * 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; + + /** + * 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): Promise; + + /** + * 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; + + /** + * 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): Promise; + + /** + * 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; +} + +// --------------------------------------------------------------------------- +// 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 }); +} diff --git a/packages/plugins/sdk/src/dev-cli.ts b/packages/plugins/sdk/src/dev-cli.ts new file mode 100644 index 00000000..7c1b0e2b --- /dev/null +++ b/packages/plugins/sdk/src/dev-cli.ts @@ -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); +}); diff --git a/packages/plugins/sdk/src/dev-server.ts b/packages/plugins/sdk/src/dev-server.ts new file mode 100644 index 00000000..2eadff81 --- /dev/null +++ b/packages/plugins/sdk/src/dev-server.ts @@ -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; +} + +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 { + if (existsSync(uiDir)) return; + await mkdir(uiDir, { recursive: true }); +} + +async function listFilesRecursive(dir: string): Promise { + 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 { + 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 { + 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(); + + 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((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((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> { + 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)); +} diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts new file mode 100644 index 00000000..82d2b42f --- /dev/null +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -0,0 +1,563 @@ +/** + * 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>; + }; + + /** Provides `state.get`, `state.set`, `state.delete`. */ + state: { + get(params: WorkerToHostMethods["state.get"][0]): Promise; + set(params: WorkerToHostMethods["state.set"][0]): Promise; + delete(params: WorkerToHostMethods["state.delete"][0]): Promise; + }; + + /** Provides `entities.upsert`, `entities.list`. */ + entities: { + upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise; + list(params: WorkerToHostMethods["entities.list"][0]): Promise; + }; + + /** Provides `events.emit`. */ + events: { + emit(params: WorkerToHostMethods["events.emit"][0]): Promise; + }; + + /** Provides `http.fetch`. */ + http: { + fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise; + }; + + /** Provides `secrets.resolve`. */ + secrets: { + resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise; + }; + + /** Provides `assets.upload`, `assets.getUrl`. */ + assets: { + upload(params: WorkerToHostMethods["assets.upload"][0]): Promise; + getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise; + }; + + /** Provides `activity.log`. */ + activity: { + log(params: { + companyId: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; + }): Promise; + }; + + /** Provides `metrics.write`. */ + metrics: { + write(params: WorkerToHostMethods["metrics.write"][0]): Promise; + }; + + /** Provides `log`. */ + logger: { + log(params: WorkerToHostMethods["log"][0]): Promise; + }; + + /** Provides `companies.list`, `companies.get`. */ + companies: { + list(params: WorkerToHostMethods["companies.list"][0]): Promise; + get(params: WorkerToHostMethods["companies.get"][0]): Promise; + }; + + /** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */ + projects: { + list(params: WorkerToHostMethods["projects.list"][0]): Promise; + get(params: WorkerToHostMethods["projects.get"][0]): Promise; + listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise; + getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise; + getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise; + }; + + /** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */ + issues: { + list(params: WorkerToHostMethods["issues.list"][0]): Promise; + get(params: WorkerToHostMethods["issues.get"][0]): Promise; + create(params: WorkerToHostMethods["issues.create"][0]): Promise; + update(params: WorkerToHostMethods["issues.update"][0]): Promise; + listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise; + createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; + }; + + /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ + agents: { + list(params: WorkerToHostMethods["agents.list"][0]): Promise; + get(params: WorkerToHostMethods["agents.get"][0]): Promise; + pause(params: WorkerToHostMethods["agents.pause"][0]): Promise; + resume(params: WorkerToHostMethods["agents.resume"][0]): Promise; + invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise; + }; + + /** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */ + agentSessions: { + create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise; + list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise; + sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise; + close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise; + }; + + /** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */ + goals: { + list(params: WorkerToHostMethods["goals.list"][0]): Promise; + get(params: WorkerToHostMethods["goals.get"][0]): Promise; + create(params: WorkerToHostMethods["goals.create"][0]): Promise; + update(params: WorkerToHostMethods["goals.update"][0]): Promise; + }; +} + +// --------------------------------------------------------------------------- +// 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 = ( + params: WorkerToHostMethods[M][0], +) => Promise; + +/** + * 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; +}; + +// --------------------------------------------------------------------------- +// 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 = { + // 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", + + // Assets + "assets.upload": "assets.write", + "assets.getUrl": "assets.read", + + // 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(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( + method: M, + handler: HostHandler, + ): HostHandler { + 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); + }), + + // Assets + "assets.upload": gated("assets.upload", async (params) => { + return services.assets.upload(params); + }), + "assets.getUrl": gated("assets.getUrl", async (params) => { + return services.assets.getUrl(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]; +} diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts new file mode 100644 index 00000000..60c59f6a --- /dev/null +++ b/packages/plugins/sdk/src/index.ts @@ -0,0 +1,287 @@ +/** + * `@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, + PluginAssetsClient, + 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"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts new file mode 100644 index 00000000..211f2573 --- /dev/null +++ b/packages/plugins/sdk/src/protocol.ts @@ -0,0 +1,1038 @@ +/** + * JSON-RPC 2.0 message types and protocol helpers for the host ↔ worker IPC + * channel. + * + * The Paperclip plugin runtime uses JSON-RPC 2.0 over stdio to communicate + * between the host process and each plugin worker process. This module defines: + * + * - Core JSON-RPC 2.0 envelope types (request, response, notification, error) + * - Standard and plugin-specific error codes + * - Typed method maps for host→worker and worker→host calls + * - Helper functions for creating well-formed messages + * + * @see PLUGIN_SPEC.md §12.1 — Process Model + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + * @see https://www.jsonrpc.org/specification + */ + +import type { + PaperclipPluginManifestV1, + PluginLauncherBounds, + PluginLauncherRenderContextSnapshot, + PluginLauncherRenderEnvironment, + PluginStateScopeKind, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; +export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; + +import type { + PluginEvent, + PluginJobContext, + PluginWorkspace, + ToolRunContext, + ToolResult, +} from "./types.js"; +import type { + PluginHealthDiagnostics, + PluginConfigValidationResult, + PluginWebhookInput, +} from "./define-plugin.js"; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 — Core Protocol Types +// --------------------------------------------------------------------------- + +/** The JSON-RPC protocol version. Always `"2.0"`. */ +export const JSONRPC_VERSION = "2.0" as const; + +/** + * A unique request identifier. JSON-RPC 2.0 allows strings or numbers; + * we use strings (UUIDs or monotonic counters) for all Paperclip messages. + */ +export type JsonRpcId = string | number; + +/** + * A JSON-RPC 2.0 request message. + * + * The host sends requests to the worker (or vice versa) and expects a + * matching response with the same `id`. + */ +export interface JsonRpcRequest< + TMethod extends string = string, + TParams = unknown, +> { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Unique request identifier. Must be echoed in the response. */ + readonly id: JsonRpcId; + /** The RPC method name to invoke. */ + readonly method: TMethod; + /** Structured parameters for the method call. */ + readonly params: TParams; +} + +/** + * A JSON-RPC 2.0 success response. + */ +export interface JsonRpcSuccessResponse { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Echoed request identifier. */ + readonly id: JsonRpcId; + /** The method return value. */ + readonly result: TResult; + readonly error?: never; +} + +/** + * A JSON-RPC 2.0 error object embedded in an error response. + */ +export interface JsonRpcError { + /** Machine-readable error code. */ + readonly code: number; + /** Human-readable error message. */ + readonly message: string; + /** Optional structured error data. */ + readonly data?: TData; +} + +/** + * A JSON-RPC 2.0 error response. + */ +export interface JsonRpcErrorResponse { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Echoed request identifier. */ + readonly id: JsonRpcId | null; + readonly result?: never; + /** The error object. */ + readonly error: JsonRpcError; +} + +/** + * A JSON-RPC 2.0 response — either success or error. + */ +export type JsonRpcResponse = + | JsonRpcSuccessResponse + | JsonRpcErrorResponse; + +/** + * A JSON-RPC 2.0 notification (a request with no `id`). + * + * Notifications are fire-and-forget — no response is expected. + */ +export interface JsonRpcNotification< + TMethod extends string = string, + TParams = unknown, +> { + readonly jsonrpc: typeof JSONRPC_VERSION; + readonly id?: never; + /** The notification method name. */ + readonly method: TMethod; + /** Structured parameters for the notification. */ + readonly params: TParams; +} + +/** + * Any well-formed JSON-RPC 2.0 message (request, response, or notification). + */ +export type JsonRpcMessage = + | JsonRpcRequest + | JsonRpcResponse + | JsonRpcNotification; + +// --------------------------------------------------------------------------- +// Error Codes +// --------------------------------------------------------------------------- + +/** + * Standard JSON-RPC 2.0 error codes. + * + * @see https://www.jsonrpc.org/specification#error_object + */ +export const JSONRPC_ERROR_CODES = { + /** Invalid JSON was received by the server. */ + PARSE_ERROR: -32700, + /** The JSON sent is not a valid Request object. */ + INVALID_REQUEST: -32600, + /** The method does not exist or is not available. */ + METHOD_NOT_FOUND: -32601, + /** Invalid method parameter(s). */ + INVALID_PARAMS: -32602, + /** Internal JSON-RPC error. */ + INTERNAL_ERROR: -32603, +} as const; + +export type JsonRpcErrorCode = + (typeof JSONRPC_ERROR_CODES)[keyof typeof JSONRPC_ERROR_CODES]; + +/** + * Paperclip plugin-specific error codes. + * + * These live in the JSON-RPC "server error" reserved range (-32000 to -32099) + * as specified by JSON-RPC 2.0 for implementation-defined server errors. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export const PLUGIN_RPC_ERROR_CODES = { + /** The worker process is not running or not reachable. */ + WORKER_UNAVAILABLE: -32000, + /** The plugin does not have the required capability for this operation. */ + CAPABILITY_DENIED: -32001, + /** The worker reported an unhandled error during method execution. */ + WORKER_ERROR: -32002, + /** The method call timed out waiting for the worker response. */ + TIMEOUT: -32003, + /** The worker does not implement the requested optional method. */ + METHOD_NOT_IMPLEMENTED: -32004, + /** A catch-all for errors that do not fit other categories. */ + UNKNOWN: -32099, +} as const; + +export type PluginRpcErrorCode = + (typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES]; + +// --------------------------------------------------------------------------- +// Host → Worker Method Signatures (§13 Host-Worker Protocol) +// --------------------------------------------------------------------------- + +/** + * Input for the `initialize` RPC method. + * + * @see PLUGIN_SPEC.md §13.1 — `initialize` + */ +export interface InitializeParams { + /** Full plugin manifest snapshot. */ + manifest: PaperclipPluginManifestV1; + /** Resolved operator configuration (validated against `instanceConfigSchema`). */ + config: Record; + /** Instance-level metadata. */ + instanceInfo: { + /** UUID of this Paperclip instance. */ + instanceId: string; + /** Semver version of the running Paperclip host. */ + hostVersion: string; + }; + /** Host API version. */ + apiVersion: number; +} + +/** + * Result returned by the `initialize` RPC method. + */ +export interface InitializeResult { + /** Whether initialization succeeded. */ + ok: boolean; + /** Optional methods the worker has implemented (e.g. "validateConfig", "onEvent"). */ + supportedMethods?: string[]; +} + +/** + * Input for the `configChanged` RPC method. + * + * @see PLUGIN_SPEC.md §13.4 — `configChanged` + */ +export interface ConfigChangedParams { + /** The newly resolved configuration. */ + config: Record; +} + +/** + * Input for the `validateConfig` RPC method. + * + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + */ +export interface ValidateConfigParams { + /** The configuration to validate. */ + config: Record; +} + +/** + * Input for the `onEvent` RPC method. + * + * @see PLUGIN_SPEC.md §13.5 — `onEvent` + */ +export interface OnEventParams { + /** The domain event to deliver. */ + event: PluginEvent; +} + +/** + * Input for the `runJob` RPC method. + * + * @see PLUGIN_SPEC.md §13.6 — `runJob` + */ +export interface RunJobParams { + /** Job execution context. */ + job: PluginJobContext; +} + +/** + * Input for the `getData` RPC method. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + */ +export interface GetDataParams { + /** Plugin-defined data key (e.g. `"sync-health"`). */ + key: string; + /** Context and query parameters from the UI. */ + params: Record; + /** Optional launcher/container metadata from the host render environment. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; +} + +/** + * Input for the `performAction` RPC method. + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + */ +export interface PerformActionParams { + /** Plugin-defined action key (e.g. `"resync"`). */ + key: string; + /** Action parameters from the UI. */ + params: Record; + /** Optional launcher/container metadata from the host render environment. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; +} + +/** + * Input for the `executeTool` RPC method. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ExecuteToolParams { + /** Tool name (without plugin namespace prefix). */ + toolName: string; + /** Parsed parameters matching the tool's declared schema. */ + parameters: unknown; + /** Agent run context. */ + runContext: ToolRunContext; +} + +// --------------------------------------------------------------------------- +// UI launcher / modal host interaction payloads +// --------------------------------------------------------------------------- + +/** + * Bounds request issued by a plugin UI running inside a host-managed launcher + * container such as a modal, drawer, or popover. + */ +export interface PluginModalBoundsRequest { + /** High-level size preset requested from the host. */ + bounds: PluginLauncherBounds; + /** Optional explicit width override in CSS pixels. */ + width?: number; + /** Optional explicit height override in CSS pixels. */ + height?: number; + /** Optional lower bounds for host resizing decisions. */ + minWidth?: number; + minHeight?: number; + /** Optional upper bounds for host resizing decisions. */ + maxWidth?: number; + maxHeight?: number; +} + +/** + * Reason metadata supplied by host-managed close lifecycle callbacks. + */ +export interface PluginRenderCloseEvent { + reason: + | "escapeKey" + | "backdrop" + | "hostNavigation" + | "programmatic" + | "submit" + | "unknown"; + nativeEvent?: unknown; +} + +/** + * Map of host→worker RPC method names to their `[params, result]` types. + * + * This type is the single source of truth for all methods the host can call + * on a worker. Used by both the host dispatcher and the worker handler to + * ensure type safety across the IPC boundary. + */ +export interface HostToWorkerMethods { + /** @see PLUGIN_SPEC.md §13.1 */ + initialize: [params: InitializeParams, result: InitializeResult]; + /** @see PLUGIN_SPEC.md §13.2 */ + health: [params: Record, result: PluginHealthDiagnostics]; + /** @see PLUGIN_SPEC.md §12.5 */ + shutdown: [params: Record, result: void]; + /** @see PLUGIN_SPEC.md §13.3 */ + validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult]; + /** @see PLUGIN_SPEC.md §13.4 */ + configChanged: [params: ConfigChangedParams, result: void]; + /** @see PLUGIN_SPEC.md §13.5 */ + onEvent: [params: OnEventParams, result: void]; + /** @see PLUGIN_SPEC.md §13.6 */ + runJob: [params: RunJobParams, result: void]; + /** @see PLUGIN_SPEC.md §13.7 */ + handleWebhook: [params: PluginWebhookInput, result: void]; + /** @see PLUGIN_SPEC.md §13.8 */ + getData: [params: GetDataParams, result: unknown]; + /** @see PLUGIN_SPEC.md §13.9 */ + performAction: [params: PerformActionParams, result: unknown]; + /** @see PLUGIN_SPEC.md §13.10 */ + executeTool: [params: ExecuteToolParams, result: ToolResult]; +} + +/** Union of all host→worker method names. */ +export type HostToWorkerMethodName = keyof HostToWorkerMethods; + +/** Required methods the worker MUST implement. */ +export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[] = [ + "initialize", + "health", + "shutdown", +] as const; + +/** Optional methods the worker MAY implement. */ +export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [ + "validateConfig", + "configChanged", + "onEvent", + "runJob", + "handleWebhook", + "getData", + "performAction", + "executeTool", +] as const; + +// --------------------------------------------------------------------------- +// Worker → Host Method Signatures (SDK client calls) +// --------------------------------------------------------------------------- + +/** + * Map of worker→host RPC method names to their `[params, result]` types. + * + * These represent the SDK client calls that the worker makes back to the + * host to access platform services (state, entities, config, etc.). + */ +export interface WorkerToHostMethods { + // Config + "config.get": [params: Record, result: Record]; + + // State + "state.get": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string }, + result: unknown, + ]; + "state.set": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string; value: unknown }, + result: void, + ]; + "state.delete": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string }, + result: void, + ]; + + // Entities + "entities.upsert": [ + params: { + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId?: string; + externalId?: string; + title?: string; + status?: string; + data: Record; + }, + result: { + id: string; + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId: string | null; + externalId: string | null; + title: string | null; + status: string | null; + data: Record; + createdAt: string; + updatedAt: string; + }, + ]; + "entities.list": [ + params: { + entityType?: string; + scopeKind?: PluginStateScopeKind; + scopeId?: string; + externalId?: string; + limit?: number; + offset?: number; + }, + result: Array<{ + id: string; + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId: string | null; + externalId: string | null; + title: string | null; + status: string | null; + data: Record; + createdAt: string; + updatedAt: string; + }>, + ]; + + // Events + "events.emit": [ + params: { name: string; companyId: string; payload: unknown }, + result: void, + ]; + + // HTTP + "http.fetch": [ + params: { url: string; init?: Record }, + result: { status: number; statusText: string; headers: Record; body: string }, + ]; + + // Secrets + "secrets.resolve": [ + params: { secretRef: string }, + result: string, + ]; + + // Assets + "assets.upload": [ + params: { filename: string; contentType: string; data: string }, + result: { assetId: string; url: string }, + ]; + "assets.getUrl": [ + params: { assetId: string }, + result: string, + ]; + + // Activity + "activity.log": [ + params: { + companyId: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; + }, + result: void, + ]; + + // Metrics + "metrics.write": [ + params: { name: string; value: number; tags?: Record }, + result: void, + ]; + + // Logger + "log": [ + params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record }, + result: void, + ]; + + // Companies (read) + "companies.list": [ + params: { limit?: number; offset?: number }, + result: Company[], + ]; + "companies.get": [ + params: { companyId: string }, + result: Company | null, + ]; + + // Projects (read) + "projects.list": [ + params: { companyId: string; limit?: number; offset?: number }, + result: Project[], + ]; + "projects.get": [ + params: { projectId: string; companyId: string }, + result: Project | null, + ]; + "projects.listWorkspaces": [ + params: { projectId: string; companyId: string }, + result: PluginWorkspace[], + ]; + "projects.getPrimaryWorkspace": [ + params: { projectId: string; companyId: string }, + result: PluginWorkspace | null, + ]; + "projects.getWorkspaceForIssue": [ + params: { issueId: string; companyId: string }, + result: PluginWorkspace | null, + ]; + + // Issues + "issues.list": [ + params: { + companyId: string; + projectId?: string; + assigneeAgentId?: string; + status?: string; + limit?: number; + offset?: number; + }, + result: Issue[], + ]; + "issues.get": [ + params: { issueId: string; companyId: string }, + result: Issue | null, + ]; + "issues.create": [ + params: { + companyId: string; + projectId?: string; + goalId?: string; + parentId?: string; + title: string; + description?: string; + priority?: string; + assigneeAgentId?: string; + }, + result: Issue, + ]; + "issues.update": [ + params: { + issueId: string; + patch: Record; + companyId: string; + }, + result: Issue, + ]; + "issues.listComments": [ + params: { issueId: string; companyId: string }, + result: IssueComment[], + ]; + "issues.createComment": [ + params: { issueId: string; body: string; companyId: string }, + result: IssueComment, + ]; + + // Agents (read) + "agents.list": [ + params: { companyId: string; status?: string; limit?: number; offset?: number }, + result: Agent[], + ]; + "agents.get": [ + params: { agentId: string; companyId: string }, + result: Agent | null, + ]; + + // Agents (write) + "agents.pause": [ + params: { agentId: string; companyId: string }, + result: Agent, + ]; + "agents.resume": [ + params: { agentId: string; companyId: string }, + result: Agent, + ]; + "agents.invoke": [ + params: { agentId: string; companyId: string; prompt: string; reason?: string }, + result: { runId: string }, + ]; + + // Agent Sessions + "agents.sessions.create": [ + params: { agentId: string; companyId: string; taskKey?: string; reason?: string }, + result: { sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }, + ]; + "agents.sessions.list": [ + params: { agentId: string; companyId: string }, + result: Array<{ sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }>, + ]; + "agents.sessions.sendMessage": [ + params: { sessionId: string; companyId: string; prompt: string; reason?: string }, + result: { runId: string }, + ]; + "agents.sessions.close": [ + params: { sessionId: string; companyId: string }, + result: void, + ]; + + // Goals + "goals.list": [ + params: { companyId: string; level?: string; status?: string; limit?: number; offset?: number }, + result: Goal[], + ]; + "goals.get": [ + params: { goalId: string; companyId: string }, + result: Goal | null, + ]; + "goals.create": [ + params: { + companyId: string; + title: string; + description?: string; + level?: string; + status?: string; + parentId?: string; + ownerAgentId?: string; + }, + result: Goal, + ]; + "goals.update": [ + params: { + goalId: string; + patch: Record; + companyId: string; + }, + result: Goal, + ]; +} + +/** Union of all worker→host method names. */ +export type WorkerToHostMethodName = keyof WorkerToHostMethods; + +// --------------------------------------------------------------------------- +// Worker→Host Notification Types (fire-and-forget, no response) +// --------------------------------------------------------------------------- + +/** + * Typed parameter shapes for worker→host JSON-RPC notifications. + * + * Notifications are fire-and-forget — the worker does not wait for a response. + * These are used for streaming events and logging, not for request-response RPCs. + */ +export interface WorkerToHostNotifications { + /** + * Forward a stream event to connected SSE clients. + * + * Emitted by the worker for each event on a stream channel. The host + * publishes to the PluginStreamBus, which fans out to all SSE clients + * subscribed to the (pluginId, channel, companyId) tuple. + * + * The `event` payload is JSON-serializable and sent as SSE `data:`. + * The default SSE event type is `"message"`. + */ + "streams.emit": { + channel: string; + companyId: string; + event: unknown; + }; + + /** + * Signal that a stream channel has been opened. + * + * Emitted when the worker calls `ctx.streams.open(channel, companyId)`. + * UI clients may use this to display a "connected" indicator or begin + * buffering input. The host tracks open channels so it can emit synthetic + * close events if the worker crashes. + */ + "streams.open": { + channel: string; + companyId: string; + }; + + /** + * Signal that a stream channel has been closed. + * + * Emitted when the worker calls `ctx.streams.close(channel)`, or + * synthetically by the host when a worker process exits with channels + * still open. UI clients should treat this as terminal and disconnect + * the SSE connection. + */ + "streams.close": { + channel: string; + companyId: string; + }; +} + +/** Union of all worker→host notification method names. */ +export type WorkerToHostNotificationName = keyof WorkerToHostNotifications; + +// --------------------------------------------------------------------------- +// Typed Request / Response Helpers +// --------------------------------------------------------------------------- + +/** + * A typed JSON-RPC request for a specific host→worker method. + */ +export type HostToWorkerRequest = + JsonRpcRequest; + +/** + * A typed JSON-RPC success response for a specific host→worker method. + */ +export type HostToWorkerResponse = + JsonRpcSuccessResponse; + +/** + * A typed JSON-RPC request for a specific worker→host method. + */ +export type WorkerToHostRequest = + JsonRpcRequest; + +/** + * A typed JSON-RPC success response for a specific worker→host method. + */ +export type WorkerToHostResponse = + JsonRpcSuccessResponse; + +// --------------------------------------------------------------------------- +// Message Factory Functions +// --------------------------------------------------------------------------- + +/** Counter for generating unique request IDs when no explicit ID is provided. */ +let _nextId = 1; + +/** Wrap around before reaching Number.MAX_SAFE_INTEGER to prevent precision loss. */ +const MAX_SAFE_RPC_ID = Number.MAX_SAFE_INTEGER - 1; + +/** + * Create a JSON-RPC 2.0 request message. + * + * @param method - The RPC method name + * @param params - Structured parameters + * @param id - Optional explicit request ID (auto-generated if omitted) + */ +export function createRequest( + method: TMethod, + params: unknown, + id?: JsonRpcId, +): JsonRpcRequest { + if (_nextId >= MAX_SAFE_RPC_ID) { + _nextId = 1; + } + return { + jsonrpc: JSONRPC_VERSION, + id: id ?? _nextId++, + method, + params, + }; +} + +/** + * Create a JSON-RPC 2.0 success response. + * + * @param id - The request ID being responded to + * @param result - The result value + */ +export function createSuccessResponse( + id: JsonRpcId, + result: TResult, +): JsonRpcSuccessResponse { + return { + jsonrpc: JSONRPC_VERSION, + id, + result, + }; +} + +/** + * Create a JSON-RPC 2.0 error response. + * + * @param id - The request ID being responded to (null if the request ID could not be determined) + * @param code - Machine-readable error code + * @param message - Human-readable error message + * @param data - Optional structured error data + */ +export function createErrorResponse( + id: JsonRpcId | null, + code: number, + message: string, + data?: TData, +): JsonRpcErrorResponse { + const response: JsonRpcErrorResponse = { + jsonrpc: JSONRPC_VERSION, + id, + error: data !== undefined + ? { code, message, data } + : { code, message } as JsonRpcError, + }; + return response; +} + +/** + * Create a JSON-RPC 2.0 notification (fire-and-forget, no response expected). + * + * @param method - The notification method name + * @param params - Structured parameters + */ +export function createNotification( + method: TMethod, + params: unknown, +): JsonRpcNotification { + return { + jsonrpc: JSONRPC_VERSION, + method, + params, + }; +} + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 request. + * + * A request has `jsonrpc: "2.0"`, a string `method`, and an `id`. + */ +export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + typeof obj.method === "string" && + "id" in obj && + obj.id !== undefined && + obj.id !== null + ); +} + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 notification. + * + * A notification has `jsonrpc: "2.0"`, a string `method`, but no `id`. + */ +export function isJsonRpcNotification( + value: unknown, +): value is JsonRpcNotification { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + typeof obj.method === "string" && + !("id" in obj) + ); +} + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 response (success or error). + */ +export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + "id" in obj && + ("result" in obj || "error" in obj) + ); +} + +/** + * Check whether a JSON-RPC response is a success response. + */ +export function isJsonRpcSuccessResponse( + response: JsonRpcResponse, +): response is JsonRpcSuccessResponse { + return "result" in response && !("error" in response && response.error !== undefined); +} + +/** + * Check whether a JSON-RPC response is an error response. + */ +export function isJsonRpcErrorResponse( + response: JsonRpcResponse, +): response is JsonRpcErrorResponse { + return "error" in response && response.error !== undefined; +} + +// --------------------------------------------------------------------------- +// Serialization Helpers +// --------------------------------------------------------------------------- + +/** + * Line delimiter for JSON-RPC messages over stdio. + * + * Each message is a single line of JSON terminated by a newline character. + * This follows the newline-delimited JSON (NDJSON) convention. + */ +export const MESSAGE_DELIMITER = "\n" as const; + +/** + * Serialize a JSON-RPC message to a newline-delimited string for transmission + * over stdio. + * + * @param message - Any JSON-RPC message (request, response, or notification) + * @returns The JSON string terminated with a newline + */ +export function serializeMessage(message: JsonRpcMessage): string { + return JSON.stringify(message) + MESSAGE_DELIMITER; +} + +/** + * Parse a JSON string into a JSON-RPC message. + * + * Returns the parsed message or throws a `JsonRpcParseError` if the input + * is not valid JSON or does not conform to the JSON-RPC 2.0 structure. + * + * @param line - A single line of JSON text (with or without trailing newline) + * @returns The parsed JSON-RPC message + * @throws {JsonRpcParseError} If parsing fails + */ +export function parseMessage(line: string): JsonRpcMessage { + const trimmed = line.trim(); + if (trimmed.length === 0) { + throw new JsonRpcParseError("Empty message"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new JsonRpcParseError(`Invalid JSON: ${trimmed.slice(0, 200)}`); + } + + if (typeof parsed !== "object" || parsed === null) { + throw new JsonRpcParseError("Message must be a JSON object"); + } + + const obj = parsed as Record; + + if (obj.jsonrpc !== JSONRPC_VERSION) { + throw new JsonRpcParseError( + `Invalid or missing jsonrpc version (expected "${JSONRPC_VERSION}", got ${JSON.stringify(obj.jsonrpc)})`, + ); + } + + // It's a valid JSON-RPC 2.0 envelope — return as-is and let the caller + // use the type guards for more specific classification. + return parsed as JsonRpcMessage; +} + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +/** + * Error thrown when a JSON-RPC message cannot be parsed. + */ +export class JsonRpcParseError extends Error { + override readonly name = "JsonRpcParseError"; + constructor(message: string) { + super(message); + } +} + +/** + * Error thrown when a JSON-RPC call fails with a structured error response. + * + * Captures the full `JsonRpcError` so callers can inspect the code and data. + */ +export class JsonRpcCallError extends Error { + override readonly name = "JsonRpcCallError"; + /** The JSON-RPC error code. */ + readonly code: number; + /** Optional structured error data from the response. */ + readonly data: unknown; + + constructor(error: JsonRpcError) { + super(error.message); + this.code = error.code; + this.data = error.data; + } +} + +// --------------------------------------------------------------------------- +// Reset helper (testing only) +// --------------------------------------------------------------------------- + +/** + * Reset the internal request ID counter. **For testing only.** + * + * @internal + */ +export function _resetIdCounter(): void { + _nextId = 1; +} diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts new file mode 100644 index 00000000..ee8a38ce --- /dev/null +++ b/packages/plugins/sdk/src/testing.ts @@ -0,0 +1,720 @@ +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; +} + +export interface TestHarnessLogEntry { + level: "info" | "warn" | "error" | "debug"; + message: string; + meta?: Record; +} + +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): void; + /** Dispatch a host or plugin event to registered handlers. */ + emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial): Promise; + /** Execute a previously-registered scheduled job handler. */ + runJob(jobKey: string, partial?: Partial): Promise; + /** Invoke a `ctx.data.register(...)` handler by key. */ + getData(key: string, params?: Record): Promise; + /** Invoke a `ctx.actions.register(...)` handler by key. */ + performAction(key: string, params?: Record): Promise; + /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ + executeTool(name: string, params: unknown, runCtx?: Partial): Promise; + /** 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): void; + logs: TestHarnessLogEntry[]; + activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; + metrics: Array<{ name: string; value: number; tags?: Record }>; +} + +type EventRegistration = { + name: PluginEventType | `plugin.${string}`; + filter?: EventFilter; + fn: (event: PluginEvent) => Promise; +}; + +function normalizeScope(input: ScopeKey): Required> & Pick { + 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 | undefined)?.companyId ?? "")) return false; + if (filter.projectId && filter.projectId !== String((event.payload as Record | undefined)?.projectId ?? "")) return false; + if (filter.agentId && filter.agentId !== String((event.payload as Record | undefined)?.agentId ?? "")) return false; + return true; +} + +function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set, 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( + 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(); + const entities = new Map(); + const entityExternalIndex = new Map(); + const assets = new Map(); + + const companies = new Map(); + const projects = new Map(); + const issues = new Map(); + const issueComments = new Map(); + const agents = new Map(); + const goals = new Map(); + const projectWorkspaces = new Map(); + + const sessions = new Map(); + const sessionEventCallbacks = new Map void>(); + + const events: EventRegistration[] = []; + const jobs = new Map Promise>(); + const launchers = new Map(); + const dataHandlers = new Map) => Promise>(); + const actionHandlers = new Map) => Promise>(); + const toolHandlers = new Map Promise>(); + + const ctx: PluginContext = { + manifest, + config: { + async get() { + return { ...currentConfig }; + }, + }, + events: { + on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => 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}`; + }, + }, + assets: { + async upload(filename, contentType, data) { + requireCapability(manifest, capabilitySet, "assets.write"); + const assetId = `asset_${randomUUID()}`; + assets.set(assetId, { contentType, data: data instanceof Uint8Array ? data : new Uint8Array(data) }); + return { assetId, url: `memory://assets/${filename}` }; + }, + async getUrl(assetId) { + requireCapability(manifest, capabilitySet, "assets.read"); + if (!assets.has(assetId)) throw new Error(`Asset not found: ${assetId}`); + return `memory://assets/${assetId}`; + }, + }, + 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)?.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(); + 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(key: string, params: Record = {}) { + const handler = dataHandlers.get(key); + if (!handler) throw new Error(`No data handler registered for '${key}'`); + return await handler(params) as T; + }, + async performAction(key: string, params: Record = {}) { + const handler = actionHandlers.get(key); + if (!handler) throw new Error(`No action handler registered for '${key}'`); + return await handler(params) as T; + }, + async executeTool(name: string, params: unknown, runCtx: Partial = {}) { + 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; +} diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts new file mode 100644 index 00000000..82940c7f --- /dev/null +++ b/packages/plugins/sdk/src/types.ts @@ -0,0 +1,1116 @@ +/** + * Core types for the Paperclip plugin worker-side SDK. + * + * These types define the stable public API surface that plugin workers import + * from `@paperclipai/plugin-sdk`. The host provides a concrete implementation + * of `PluginContext` to the plugin at initialisation time. + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + * @see PLUGIN_SPEC.md §29.2 — SDK Versioning + */ + +import type { + PaperclipPluginManifestV1, + PluginStateScopeKind, + PluginEventType, + PluginToolDeclaration, + PluginLauncherDeclaration, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; + +// --------------------------------------------------------------------------- +// Re-exports from @paperclipai/shared (plugin authors import from one place) +// --------------------------------------------------------------------------- + +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, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; + +// --------------------------------------------------------------------------- +// Scope key — identifies where plugin state is stored +// --------------------------------------------------------------------------- + +/** + * A scope key identifies the exact location where plugin state is stored. + * Scope is partitioned by `scopeKind` and optional `scopeId`. + * + * Examples: + * - `{ scopeKind: "instance" }` — single global value for the whole instance + * - `{ scopeKind: "project", scopeId: "proj-uuid" }` — per-project state + * - `{ scopeKind: "issue", scopeId: "iss-uuid" }` — per-issue state + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state` + */ +export interface ScopeKey { + /** What kind of Paperclip object this state is scoped to. */ + scopeKind: PluginStateScopeKind; + /** UUID or text identifier for the scoped object. Omit for `instance` scope. */ + scopeId?: string; + /** Optional sub-namespace within the scope to avoid key collisions. Defaults to `"default"`. */ + namespace?: string; + /** The state key within the namespace. */ + stateKey: string; +} + +// --------------------------------------------------------------------------- +// Event types +// --------------------------------------------------------------------------- + +/** + * Optional filter applied when subscribing to an event. The host evaluates + * the filter server-side so filtered-out events never cross the process boundary. + * + * All filter fields are optional. If omitted the plugin receives every event + * of the subscribed type. + * + * @see PLUGIN_SPEC.md §16.1 — Event Filtering + */ +export interface EventFilter { + /** Only receive events for this project. */ + projectId?: string; + /** Only receive events for this company. */ + companyId?: string; + /** Only receive events for this agent. */ + agentId?: string; + /** Additional arbitrary filter fields. */ + [key: string]: unknown; +} + +/** + * Envelope wrapping every domain event delivered to a plugin worker. + * + * @see PLUGIN_SPEC.md §16 — Event System + */ +export interface PluginEvent { + /** Unique event identifier (UUID). */ + eventId: string; + /** The event type (e.g. `"issue.created"`). */ + eventType: PluginEventType | `plugin.${string}`; + /** ISO 8601 timestamp when the event occurred. */ + occurredAt: string; + /** ID of the actor that caused the event, if applicable. */ + actorId?: string; + /** Type of actor: `"user"`, `"agent"`, `"system"`, or `"plugin"`. */ + actorType?: "user" | "agent" | "system" | "plugin"; + /** Primary entity involved in the event. */ + entityId?: string; + /** Type of the primary entity. */ + entityType?: string; + /** UUID of the company this event belongs to. */ + companyId: string; + /** Typed event payload. */ + payload: TPayload; +} + +// --------------------------------------------------------------------------- +// Job context +// --------------------------------------------------------------------------- + +/** + * Context passed to a plugin job handler when the host triggers a scheduled run. + * + * @see PLUGIN_SPEC.md §13.6 — `runJob` + */ +export interface PluginJobContext { + /** Stable job key matching the declaration in the manifest. */ + jobKey: string; + /** UUID for this specific job run instance. */ + runId: string; + /** What triggered this run. */ + trigger: "schedule" | "manual" | "retry"; + /** ISO 8601 timestamp when the run was scheduled to start. */ + scheduledAt: string; +} + +// --------------------------------------------------------------------------- +// Tool run context +// --------------------------------------------------------------------------- + +/** + * Run context passed to a plugin tool handler when an agent invokes the tool. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ToolRunContext { + /** UUID of the agent invoking the tool. */ + agentId: string; + /** UUID of the current agent run. */ + runId: string; + /** UUID of the company the run belongs to. */ + companyId: string; + /** UUID of the project the run belongs to. */ + projectId: string; +} + +/** + * Result returned from a plugin tool handler. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ToolResult { + /** String content returned to the agent. Required for success responses. */ + content?: string; + /** Structured data returned alongside or instead of string content. */ + data?: unknown; + /** If present, indicates the tool call failed. */ + error?: string; +} + +// --------------------------------------------------------------------------- +// Plugin entity store +// --------------------------------------------------------------------------- + +/** + * Input for creating or updating a plugin-owned entity. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntityUpsert { + /** Plugin-defined entity type (e.g. `"linear-issue"`, `"github-pr"`). */ + entityType: string; + /** Scope where this entity lives. */ + scopeKind: PluginStateScopeKind; + /** Optional scope ID. */ + scopeId?: string; + /** External identifier in the remote system (e.g. Linear issue ID). */ + externalId?: string; + /** Human-readable title for display in the Paperclip UI. */ + title?: string; + /** Optional status string. */ + status?: string; + /** Full entity data blob. Must be JSON-serializable. */ + data: Record; +} + +/** + * A plugin-owned entity record as returned by `ctx.entities.list()`. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntityRecord { + /** UUID primary key. */ + id: string; + /** Plugin-defined entity type. */ + entityType: string; + /** Scope kind. */ + scopeKind: PluginStateScopeKind; + /** Scope ID, if any. */ + scopeId: string | null; + /** External identifier, if any. */ + externalId: string | null; + /** Human-readable title. */ + title: string | null; + /** Status string. */ + status: string | null; + /** Full entity data. */ + data: Record; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; +} + +/** + * Query parameters for `ctx.entities.list()`. + */ +export interface PluginEntityQuery { + /** Filter by entity type. */ + entityType?: string; + /** Filter by scope kind. */ + scopeKind?: PluginStateScopeKind; + /** Filter by scope ID. */ + scopeId?: string; + /** Filter by external ID. */ + externalId?: string; + /** Maximum number of results to return. */ + limit?: number; + /** Number of results to skip (for pagination). */ + offset?: number; +} + +// --------------------------------------------------------------------------- +// Project workspace metadata (read-only via ctx.projects) +// --------------------------------------------------------------------------- + +/** + * Workspace metadata provided by the host. Plugins use this to resolve local + * filesystem paths for file browsing, git, terminal, and process operations. + * + * @see PLUGIN_SPEC.md §7 — Project Workspaces + * @see PLUGIN_SPEC.md §20 — Local Tooling + */ +export interface PluginWorkspace { + /** UUID primary key. */ + id: string; + /** UUID of the parent project. */ + projectId: string; + /** Display name for this workspace. */ + name: string; + /** Absolute filesystem path to the workspace directory. */ + path: string; + /** Whether this is the project's primary workspace. */ + isPrimary: boolean; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Host API surfaces exposed via PluginContext +// --------------------------------------------------------------------------- + +/** + * `ctx.config` — read resolved operator configuration for this plugin. + * + * Plugin workers receive the resolved config at initialisation. Use `get()` + * to access the current configuration at any time. The host calls + * `configChanged` on the worker when the operator updates config at runtime. + * + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + * @see PLUGIN_SPEC.md §13.4 — `configChanged` + */ +export interface PluginConfigClient { + /** + * Returns the resolved operator configuration for this plugin instance. + * Values are validated against the plugin's `instanceConfigSchema` by the + * host before being passed to the worker. + */ + get(): Promise>; +} + +/** + * `ctx.events` — subscribe to and emit Paperclip domain events. + * + * Requires `events.subscribe` capability for `on()`. + * Requires `events.emit` capability for `emit()`. + * + * @see PLUGIN_SPEC.md §16 — Event System + */ +export interface PluginEventsClient { + /** + * Subscribe to a core Paperclip domain event or a plugin-namespaced event. + * + * @param name - Event type, e.g. `"issue.created"` or `"plugin.@acme/linear.sync-done"` + * @param fn - Async event handler + */ + on(name: PluginEventType | `plugin.${string}`, fn: (event: PluginEvent) => Promise): () => void; + + /** + * Subscribe to an event with an optional server-side filter. + * + * @param name - Event type + * @param filter - Server-side filter evaluated before dispatching to the worker + * @param fn - Async event handler + * @returns An unsubscribe function that removes the handler + */ + on(name: PluginEventType | `plugin.${string}`, filter: EventFilter, fn: (event: PluginEvent) => Promise): () => void; + + /** + * Emit a plugin-namespaced event. Other plugins with `events.subscribe` can + * subscribe to it using `"plugin.."`. + * + * Requires the `events.emit` capability. + * + * Plugin-emitted events are automatically namespaced: if the plugin ID is + * `"acme.linear"` and the event name is `"sync-done"`, the full event type + * becomes `"plugin.acme.linear.sync-done"`. + * + * @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events + * + * @param name - Bare event name (e.g. `"sync-done"`) + * @param companyId - UUID of the company this event belongs to + * @param payload - JSON-serializable event payload + */ + emit(name: string, companyId: string, payload: unknown): Promise; +} + +/** + * `ctx.jobs` — register handlers for scheduled jobs declared in the manifest. + * + * Requires `jobs.schedule` capability. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + */ +export interface PluginJobsClient { + /** + * Register a handler for a scheduled job. + * + * The `key` must match a `jobKey` declared in the plugin manifest. + * The host calls this handler according to the job's declared `schedule`. + * + * @param key - Job key matching the manifest declaration + * @param fn - Async job handler + */ + register(key: string, fn: (job: PluginJobContext) => Promise): void; +} + +/** + * A runtime launcher registration uses the same declaration shape as a + * manifest launcher entry. + */ +export type PluginLauncherRegistration = PluginLauncherDeclaration; + +/** + * `ctx.launchers` — register launcher declarations at runtime. + */ +export interface PluginLaunchersClient { + /** + * Register launcher metadata for host discovery. + * + * If a launcher with the same id is registered more than once, the latest + * declaration replaces the previous one. + */ + register(launcher: PluginLauncherRegistration): void; +} + +/** + * `ctx.http` — make outbound HTTP requests. + * + * Requires `http.outbound` capability. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Runtime/Integration + */ +export interface PluginHttpClient { + /** + * Perform an outbound HTTP request. + * + * The host enforces `http.outbound` capability before allowing the call. + * Plugins may also use standard Node `fetch` or other libraries directly — + * this client exists for host-managed tracing and audit logging. + * + * @param url - Target URL + * @param init - Standard `RequestInit` options + * @returns The response + */ + fetch(url: string, init?: RequestInit): Promise; +} + +/** + * `ctx.secrets` — resolve secret references. + * + * Requires `secrets.read-ref` capability. + * + * Plugins store secret *references* in their config (e.g. a secret name). + * This client resolves the reference through the Paperclip secret provider + * system and returns the resolved value at execution time. + * + * @see PLUGIN_SPEC.md §22 — Secrets + */ +export interface PluginSecretsClient { + /** + * Resolve a secret reference to its current value. + * + * The reference is a string identifier pointing to a secret configured + * in the Paperclip secret provider (e.g. `"MY_API_KEY"`). + * + * Secret values are resolved at call time and must never be cached or + * written to logs, config, or other persistent storage. + * + * @param secretRef - The secret reference string from plugin config + * @returns The resolved secret value + */ + resolve(secretRef: string): Promise; +} + +/** + * `ctx.assets` — read and write assets (files, images, etc.). + * + * `assets.read` capability required for `getUrl()`. + * `assets.write` capability required for `upload()`. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write + */ +export interface PluginAssetsClient { + /** + * Upload an asset (e.g. a screenshot or generated file). + * + * @param filename - Name for the asset file + * @param contentType - MIME type + * @param data - Raw asset data as a Buffer or Uint8Array + * @returns The asset ID and public URL + */ + upload(filename: string, contentType: string, data: Buffer | Uint8Array): Promise<{ assetId: string; url: string }>; + + /** + * Get the public URL for an existing asset by ID. + * + * @param assetId - Asset identifier + * @returns The public URL + */ + getUrl(assetId: string): Promise; +} + +/** + * Input for writing a plugin activity log entry. + * + * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes + */ +export interface PluginActivityLogEntry { + /** UUID of the company this activity belongs to. Required for auditing. */ + companyId: string; + /** Human-readable description of the activity. */ + message: string; + /** Optional entity type this activity relates to. */ + entityType?: string; + /** Optional entity ID this activity relates to. */ + entityId?: string; + /** Optional additional metadata. */ + metadata?: Record; +} + +/** + * `ctx.activity` — write plugin-originated activity log entries. + * + * Requires `activity.log.write` capability. + * + * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes + */ +export interface PluginActivityClient { + /** + * Write an activity log entry attributed to this plugin. + * + * The host writes the entry with `actor_type = plugin` and + * `actor_id = `. + * + * @param entry - The activity log entry to write + */ + log(entry: PluginActivityLogEntry): Promise; +} + +/** + * `ctx.state` — read and write plugin-scoped key-value state. + * + * Each plugin gets an isolated namespace: state written by plugin A can never + * be read or overwritten by plugin B. Within a plugin, state is partitioned by + * a five-part composite key: `(pluginId, scopeKind, scopeId, namespace, stateKey)`. + * + * **Scope kinds** + * + * | `scopeKind` | `scopeId` | Typical use | + * |-------------|-----------|-------------| + * | `"instance"` | omit | Global flags, last full-sync timestamps | + * | `"company"` | company UUID | Per-company sync cursors | + * | `"project"` | project UUID | Per-project settings, branch tracking | + * | `"project_workspace"` | workspace UUID | Per-workspace state | + * | `"agent"` | agent UUID | Per-agent memory | + * | `"issue"` | issue UUID | Idempotency keys, linked external IDs | + * | `"goal"` | goal UUID | Per-goal progress | + * | `"run"` | run UUID | Per-run checkpoints | + * + * **Namespaces** + * + * The optional `namespace` field (default: `"default"`) lets you group related + * keys within a scope without risking collisions between different logical + * subsystems inside the same plugin. + * + * **Security** + * + * Never store resolved secret values. Store only secret references and resolve + * them at call time via `ctx.secrets.resolve()`. + * + * @example + * ```ts + * // Instance-global flag + * await ctx.state.set({ scopeKind: "instance", stateKey: "schema-version" }, 2); + * + * // Idempotency key per issue + * const synced = await ctx.state.get({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" }); + * if (!synced) { + * await syncToLinear(issueId); + * await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" }, true); + * } + * + * // Per-project, namespaced for two integrations + * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "linear", stateKey: "cursor" }, cursor); + * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "github", stateKey: "last-event" }, eventId); + * ``` + * + * `plugin.state.read` capability required for `get()`. + * `plugin.state.write` capability required for `set()` and `delete()`. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state` + */ +export interface PluginStateClient { + /** + * Read a state value. + * + * Returns the stored JSON value as-is, or `null` if no entry has been set + * for this scope+key combination. Falsy values (`false`, `0`, `""`) are + * returned correctly and are not confused with "not set". + * + * @param input - Scope key identifying the entry to read + * @returns The stored JSON value, or `null` if no value has been set + */ + get(input: ScopeKey): Promise; + + /** + * Write a state value. Creates the row if it does not exist; replaces it + * atomically (upsert) if it does. Safe to call concurrently. + * + * Any JSON-serializable value is accepted: objects, arrays, strings, + * numbers, booleans, and `null`. + * + * @param input - Scope key identifying the entry to write + * @param value - JSON-serializable value to store + */ + set(input: ScopeKey, value: unknown): Promise; + + /** + * Delete a state value. No-ops silently if the entry does not exist + * (idempotent by design — safe to call without prior `get()`). + * + * @param input - Scope key identifying the entry to delete + */ + delete(input: ScopeKey): Promise; +} + +/** + * `ctx.entities` — create and query plugin-owned entity records. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntitiesClient { + /** + * Create or update a plugin entity record (upsert by `externalId` within + * the given scope, or by `id` if provided). + * + * @param input - Entity data to upsert + */ + upsert(input: PluginEntityUpsert): Promise; + + /** + * Query plugin entity records. + * + * @param query - Filter criteria + * @returns Matching entity records + */ + list(query: PluginEntityQuery): Promise; +} + +/** + * `ctx.projects` — read project and workspace metadata. + * + * Requires `projects.read` capability. + * Requires `project.workspaces.read` capability for workspace operations. + * + * @see PLUGIN_SPEC.md §7 — Project Workspaces + */ +export interface PluginProjectsClient { + /** + * List projects visible to the plugin. + * + * Requires the `projects.read` capability. + */ + list(input: { companyId: string; limit?: number; offset?: number }): Promise; + + /** + * Get a single project by ID. + * + * Requires the `projects.read` capability. + */ + get(projectId: string, companyId: string): Promise; + + /** + * List all workspaces attached to a project. + * + * @param projectId - UUID of the project + * @param companyId - UUID of the company that owns the project + * @returns All workspaces for the project, ordered with primary first + */ + listWorkspaces(projectId: string, companyId: string): Promise; + + /** + * Get the primary workspace for a project. + * + * @param projectId - UUID of the project + * @param companyId - UUID of the company that owns the project + * @returns The primary workspace, or `null` if no workspace is configured + */ + getPrimaryWorkspace(projectId: string, companyId: string): Promise; + + /** + * Resolve the primary workspace for an issue by looking up the issue's + * project and returning its primary workspace. + * + * This is a convenience method that combines `issues.get()` and + * `getPrimaryWorkspace()` in a single RPC call. + * + * @param issueId - UUID of the issue + * @param companyId - UUID of the company that owns the issue + * @returns The primary workspace for the issue's project, or `null` if + * the issue has no project or the project has no workspace + * + * @see PLUGIN_SPEC.md §20 — Local Tooling + */ + getWorkspaceForIssue(issueId: string, companyId: string): Promise; +} + +/** + * `ctx.data` — register `getData` handlers that back `usePluginData()` in the + * plugin's frontend components. + * + * The plugin's UI calls `usePluginData(key, params)` which routes through the + * host bridge to the worker's registered handler. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + */ +export interface PluginDataClient { + /** + * Register a handler for a plugin-defined data key. + * + * @param key - Stable string identifier for this data type (e.g. `"sync-health"`) + * @param handler - Async function that receives request params and returns JSON-serializable data + */ + register(key: string, handler: (params: Record) => Promise): void; +} + +/** + * `ctx.actions` — register `performAction` handlers that back + * `usePluginAction()` in the plugin's frontend components. + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + */ +export interface PluginActionsClient { + /** + * Register a handler for a plugin-defined action key. + * + * @param key - Stable string identifier for this action (e.g. `"resync"`) + * @param handler - Async function that receives action params and returns a result + */ + register(key: string, handler: (params: Record) => Promise): void; +} + +/** + * `ctx.tools` — register handlers for agent tools declared in the manifest. + * + * Requires `agent.tools.register` capability. + * + * Tool names are automatically namespaced by plugin ID at runtime. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + */ +export interface PluginToolsClient { + /** + * Register a handler for a plugin-contributed agent tool. + * + * @param name - Tool name matching the manifest declaration (without namespace prefix) + * @param declaration - Tool metadata (displayName, description, parametersSchema) + * @param fn - Async handler that executes the tool + */ + register( + name: string, + declaration: Pick, + fn: (params: unknown, runCtx: ToolRunContext) => Promise, + ): void; +} + +/** + * `ctx.logger` — structured logging from the plugin worker. + * + * Log output is captured by the host, stored, and surfaced in the plugin + * health dashboard. + * + * @see PLUGIN_SPEC.md §26.1 — Logging + */ +export interface PluginLogger { + /** Log an informational message. */ + info(message: string, meta?: Record): void; + /** Log a warning. */ + warn(message: string, meta?: Record): void; + /** Log an error. */ + error(message: string, meta?: Record): void; + /** Log a debug message (may be suppressed in production). */ + debug(message: string, meta?: Record): void; +} + +// --------------------------------------------------------------------------- +// Plugin metrics +// --------------------------------------------------------------------------- + +/** + * `ctx.metrics` — write plugin-contributed metrics. + * + * Requires `metrics.write` capability. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write + */ +export interface PluginMetricsClient { + /** + * Write a numeric metric data point. + * + * @param name - Metric name (plugin-namespaced by the host) + * @param value - Numeric value + * @param tags - Optional key-value tags for filtering + */ + write(name: string, value: number, tags?: Record): Promise; +} + +/** + * `ctx.companies` — read company metadata. + * + * Requires `companies.read` capability. + */ +export interface PluginCompaniesClient { + /** + * List companies visible to this plugin. + */ + list(input?: { limit?: number; offset?: number }): Promise; + + /** + * Get one company by ID. + */ + get(companyId: string): Promise; +} + +/** + * `ctx.issues` — read and mutate issues plus comments. + * + * Requires: + * - `issues.read` for read operations + * - `issues.create` for create + * - `issues.update` for update + * - `issue.comments.read` for `listComments` + * - `issue.comments.create` for `createComment` + */ +export interface PluginIssuesClient { + list(input: { + companyId: string; + projectId?: string; + assigneeAgentId?: string; + status?: Issue["status"]; + limit?: number; + offset?: number; + }): Promise; + get(issueId: string, companyId: string): Promise; + create(input: { + companyId: string; + projectId?: string; + goalId?: string; + parentId?: string; + title: string; + description?: string; + priority?: Issue["priority"]; + assigneeAgentId?: string; + }): Promise; + update( + issueId: string, + patch: Partial>, + companyId: string, + ): Promise; + listComments(issueId: string, companyId: string): Promise; + createComment(issueId: string, body: string, companyId: string): Promise; +} + +/** + * `ctx.agents` — read and manage agents. + * + * Requires `agents.read` for reads; `agents.pause` / `agents.resume` / + * `agents.invoke` for write operations. + */ +export interface PluginAgentsClient { + list(input: { companyId: string; status?: Agent["status"]; limit?: number; offset?: number }): Promise; + get(agentId: string, companyId: string): Promise; + /** Pause an agent. Throws if agent is terminated or not found. Requires `agents.pause`. */ + pause(agentId: string, companyId: string): Promise; + /** Resume a paused agent (sets status to idle). Throws if terminated, pending_approval, or not found. Requires `agents.resume`. */ + resume(agentId: string, companyId: string): Promise; + /** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */ + invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>; + /** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */ + sessions: PluginAgentSessionsClient; +} + +// --------------------------------------------------------------------------- +// Agent Sessions — two-way chat with agents +// --------------------------------------------------------------------------- + +/** + * Represents an active conversational session with an agent. + * Maps to an `AgentTaskSession` row on the host. + */ +export interface AgentSession { + sessionId: string; + agentId: string; + companyId: string; + status: "active" | "closed"; + createdAt: string; +} + +/** + * A streaming event received during a session's `sendMessage` call. + * Delivered via JSON-RPC notifications from host to worker. + */ +export interface AgentSessionEvent { + sessionId: string; + runId: string; + seq: number; + /** The kind of event: "chunk" for output data, "status" for run state changes, "done" for end-of-stream, "error" for failures. */ + eventType: "chunk" | "status" | "done" | "error"; + stream: "stdout" | "stderr" | "system" | null; + message: string | null; + payload: Record | null; +} + +/** + * Result of sending a message to a session. + */ +export interface AgentSessionSendResult { + runId: string; +} + +/** + * `ctx.agents.sessions` — create, message, and close agent chat sessions. + * + * Requires `agent.sessions.create` for create, `agent.sessions.list` for list, + * `agent.sessions.send` for sendMessage, `agent.sessions.close` for close. + */ +export interface PluginAgentSessionsClient { + /** Create a new conversational session with an agent. Requires `agent.sessions.create`. */ + create(agentId: string, companyId: string, opts?: { + taskKey?: string; + reason?: string; + }): Promise; + + /** List active sessions for an agent owned by this plugin. Requires `agent.sessions.list`. */ + list(agentId: string, companyId: string): Promise; + + /** + * Send a message to a session and receive streaming events via the `onEvent` callback. + * Returns immediately with `{ runId }`. Events are delivered asynchronously. + * Requires `agent.sessions.send`. + */ + sendMessage(sessionId: string, companyId: string, opts: { + prompt: string; + reason?: string; + onEvent?: (event: AgentSessionEvent) => void; + }): Promise; + + /** Close a session, releasing resources. Requires `agent.sessions.close`. */ + close(sessionId: string, companyId: string): Promise; +} + +/** + * `ctx.goals` — read and mutate goals. + * + * Requires: + * - `goals.read` for read operations + * - `goals.create` for create + * - `goals.update` for update + */ +export interface PluginGoalsClient { + list(input: { + companyId: string; + level?: Goal["level"]; + status?: Goal["status"]; + limit?: number; + offset?: number; + }): Promise; + get(goalId: string, companyId: string): Promise; + create(input: { + companyId: string; + title: string; + description?: string; + level?: Goal["level"]; + status?: Goal["status"]; + parentId?: string; + ownerAgentId?: string; + }): Promise; + update( + goalId: string, + patch: Partial>, + companyId: string, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Streaming (worker → UI push channel) +// --------------------------------------------------------------------------- + +/** + * `ctx.streams` — push real-time events from the worker to the plugin UI. + * + * The worker opens a named channel, emits events on it, and closes it when + * done. On the UI side, `usePluginStream(channel)` receives these events in + * real time via SSE. + * + * Streams are scoped to `(pluginId, channel, companyId)`. Multiple UI clients + * can subscribe to the same channel concurrently. + * + * @example + * ```ts + * // Worker: stream chat tokens to the UI + * ctx.streams.open("chat", companyId); + * for await (const token of tokenStream) { + * ctx.streams.emit("chat", { type: "token", text: token }); + * } + * ctx.streams.close("chat"); + * ``` + * + * @see usePluginStream in `@paperclipai/plugin-sdk/ui` + */ +export interface PluginStreamsClient { + /** + * Open a named stream channel. Optional — `emit()` implicitly opens if needed. + * Sends a `stream:open` event to connected UI clients. + */ + open(channel: string, companyId: string): void; + + /** + * Push an event to all UI clients subscribed to this channel. + * + * @param channel - Stream channel name (e.g. `"chat"`, `"logs"`) + * @param event - JSON-serializable event payload + */ + emit(channel: string, event: unknown): void; + + /** + * Close a stream channel. Sends a `stream:close` event to connected UI + * clients so they know no more events will arrive. + */ + close(channel: string): void; +} + +// --------------------------------------------------------------------------- +// Full plugin context +// --------------------------------------------------------------------------- + +/** + * The full plugin context object passed to the plugin worker at initialisation. + * + * This is the central interface plugin authors use to interact with the host. + * Every client is capability-gated: calling a client method without the + * required capability declared in the manifest results in a runtime error. + * + * @example + * ```ts + * import { definePlugin } from "@paperclipai/plugin-sdk"; + * + * export default definePlugin({ + * async setup(ctx) { + * ctx.events.on("issue.created", async (event) => { + * ctx.logger.info("Issue created", { issueId: event.entityId }); + * }); + * + * ctx.data.register("sync-health", async ({ companyId }) => { + * const state = await ctx.state.get({ scopeKind: "company", scopeId: String(companyId), stateKey: "last-sync" }); + * return { lastSync: state }; + * }); + * }, + * }); + * ``` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ +export interface PluginContext { + /** The plugin's manifest as validated at install time. */ + manifest: PaperclipPluginManifestV1; + + /** Read resolved operator configuration. */ + config: PluginConfigClient; + + /** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */ + events: PluginEventsClient; + + /** Register handlers for scheduled jobs. Requires `jobs.schedule`. */ + jobs: PluginJobsClient; + + /** Register launcher metadata that the host can surface in plugin UI entry points. */ + launchers: PluginLaunchersClient; + + /** Make outbound HTTP requests. Requires `http.outbound`. */ + http: PluginHttpClient; + + /** Resolve secret references. Requires `secrets.read-ref`. */ + secrets: PluginSecretsClient; + + /** Read and write assets. Requires `assets.read` / `assets.write`. */ + assets: PluginAssetsClient; + + /** Write activity log entries. Requires `activity.log.write`. */ + activity: PluginActivityClient; + + /** Read and write scoped plugin state. Requires `plugin.state.read` / `plugin.state.write`. */ + state: PluginStateClient; + + /** Create and query plugin-owned entity records. */ + entities: PluginEntitiesClient; + + /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ + projects: PluginProjectsClient; + + /** Read company metadata. Requires `companies.read`. */ + companies: PluginCompaniesClient; + + /** Read and write issues/comments. Requires issue capabilities. */ + issues: PluginIssuesClient; + + /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ + agents: PluginAgentsClient; + + /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */ + goals: PluginGoalsClient; + + /** Register getData handlers for the plugin's UI components. */ + data: PluginDataClient; + + /** Register performAction handlers for the plugin's UI components. */ + actions: PluginActionsClient; + + /** Push real-time events from the worker to the plugin UI via SSE. */ + streams: PluginStreamsClient; + + /** Register agent tool handlers. Requires `agent.tools.register`. */ + tools: PluginToolsClient; + + /** Write plugin metrics. Requires `metrics.write`. */ + metrics: PluginMetricsClient; + + /** Structured logger. Output is captured and surfaced in the plugin health dashboard. */ + logger: PluginLogger; +} diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts new file mode 100644 index 00000000..b93c1db4 --- /dev/null +++ b/packages/plugins/sdk/src/ui/components.ts @@ -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> { + /** 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> { + /** Column definitions. */ + columns: DataTableColumn[]; + /** 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; + /** 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; +} + +/** 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(name: string): React.ComponentType { + return function PaperclipSdkUiComponent(props: TProps) { + return renderSdkUiComponent(name, props) as React.ReactNode; + }; +} + +export const MetricCard = createSdkUiComponent("MetricCard"); + +/** + * Displays an inline status badge (ok / warning / error / info / pending). + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const StatusBadge = createSdkUiComponent("StatusBadge"); + +/** + * Sortable, paginated data table. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const DataTable = createSdkUiComponent("DataTable"); + +/** + * Line or bar chart for time-series data. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const TimeseriesChart = createSdkUiComponent("TimeseriesChart"); + +/** + * Renders Markdown text as HTML. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const MarkdownBlock = createSdkUiComponent("MarkdownBlock"); + +/** + * Renders a definition-list of label/value pairs. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const KeyValueList = createSdkUiComponent("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("ActionBar"); + +/** + * Scrollable, timestamped log output viewer. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const LogView = createSdkUiComponent("LogView"); + +/** + * Collapsible JSON tree for debugging or raw data inspection. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const JsonTree = createSdkUiComponent("JsonTree"); + +/** + * Loading indicator. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const Spinner = createSdkUiComponent("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("ErrorBoundary"); diff --git a/packages/plugins/sdk/src/ui/hooks.ts b/packages/plugins/sdk/src/ui/hooks.ts new file mode 100644 index 00000000..fdba2fe3 --- /dev/null +++ b/packages/plugins/sdk/src/ui/hooks.ts @@ -0,0 +1,153 @@ +import type { PluginDataResult, PluginActionFn, PluginHostContext, PluginStreamResult } 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` with `data`, `loading`, `error`, and `refresh` + * + * @example + * ```tsx + * function SyncWidget({ context }: PluginWidgetProps) { + * const { data, loading, error } = usePluginData("sync-health", { + * companyId: context.companyId, + * }); + * + * if (loading) return ; + * if (error) return
    Error: {error.message}
    ; + * return ; + * } + * ``` + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export function usePluginData( + key: string, + params?: Record, +): PluginDataResult { + const impl = getSdkUiRuntimeValue< + (nextKey: string, nextParams?: Record) => PluginDataResult + >("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(null); + * + * async function handleClick() { + * try { + * await resync({ companyId: context.companyId }); + * } catch (err) { + * setError((err as PluginBridgeError).message); + * } + * } + * + * return ; + * } + * ``` + * + * @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
    {data?.linearIssueUrl}
    ; + * } + * ``` + * + * @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` with `events`, `lastEvent`, connection status, and `close()` + * + * @example + * ```tsx + * function ChatMessages() { + * const { events, connected, close } = usePluginStream("chat-stream"); + * + * return ( + *
    + * {events.map((e, i) => {e.text})} + * {connected && } + * + *
    + * ); + * } + * ``` + * + * @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming + */ +export function usePluginStream( + channel: string, + options?: { companyId?: string }, +): PluginStreamResult { + const impl = getSdkUiRuntimeValue< + (nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult + >("usePluginStream"); + return impl(channel, options); +} diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts new file mode 100644 index 00000000..05fdad63 --- /dev/null +++ b/packages/plugins/sdk/src/ui/index.ts @@ -0,0 +1,125 @@ +/** + * `@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, + * useHostContext, + * MetricCard, + * StatusBadge, + * Spinner, + * } 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 ; + * if (error) return
    Error: {error.message}
    ; + * + * return ( + *
    + * + * + *
    + * ); + * } + * ``` + */ + +/** + * 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, +} from "./hooks.js"; + +// Bridge error and host context types +export type { + PluginBridgeError, + PluginBridgeErrorCode, + PluginHostContext, + PluginModalBoundsRequest, + PluginRenderCloseEvent, + PluginRenderCloseHandler, + PluginRenderCloseLifecycle, + PluginRenderEnvironmentContext, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, + PluginDataResult, + PluginActionFn, + PluginStreamResult, +} from "./types.js"; + +// Slot component prop interfaces +export type { + PluginPageProps, + PluginWidgetProps, + PluginDetailTabProps, + PluginSidebarProps, + PluginProjectSidebarItemProps, + PluginCommentAnnotationProps, + PluginCommentContextMenuItemProps, + PluginSettingsPageProps, +} from "./types.js"; + +// Shared UI components +export { + MetricCard, + StatusBadge, + DataTable, + TimeseriesChart, + MarkdownBlock, + KeyValueList, + ActionBar, + LogView, + JsonTree, + Spinner, + ErrorBoundary, +} from "./components.js"; + +// Shared component prop types (for plugin authors who need to extend them) +export type { + MetricCardProps, + MetricTrend, + StatusBadgeProps, + StatusBadgeVariant, + DataTableProps, + DataTableColumn, + TimeseriesChartProps, + TimeseriesDataPoint, + MarkdownBlockProps, + KeyValueListProps, + KeyValuePair, + ActionBarProps, + ActionBarItem, + LogViewProps, + LogViewEntry, + JsonTreeProps, + SpinnerProps, + ErrorBoundaryProps, +} from "./components.js"; diff --git a/packages/plugins/sdk/src/ui/runtime.ts b/packages/plugins/sdk/src/ui/runtime.ts new file mode 100644 index 00000000..998428e9 --- /dev/null +++ b/packages/plugins/sdk/src/ui/runtime.ts @@ -0,0 +1,51 @@ +type PluginBridgeRegistry = { + react?: { + createElement?: (type: unknown, props?: Record | null) => unknown; + } | null; + sdkUi?: Record | 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(name: string): T { + const value = getBridgeRegistry()?.sdkUi?.[name]; + if (value === undefined) { + throw missingBridgeValueError(name); + } + return value as T; +} + +export function renderSdkUiComponent( + 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); + } + + if (typeof component === "function") { + return component(props); + } + + throw new Error(`Paperclip plugin UI component "${name}" is not callable`); +} diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts new file mode 100644 index 00000000..267b10b5 --- /dev/null +++ b/packages/plugins/sdk/src/ui/types.ts @@ -0,0 +1,358 @@ +/** + * 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; + +/** + * 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; + /** 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 { + /** 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; +} + +// --------------------------------------------------------------------------- +// usePluginAction hook return type +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// usePluginStream hook return type +// --------------------------------------------------------------------------- + +/** + * Return value of `usePluginStream(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 { + /** 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"); + * + * ``` + */ +export type PluginActionFn = (params?: Record) => Promise; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts new file mode 100644 index 00000000..05e5f3b5 --- /dev/null +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -0,0 +1,1221 @@ +/** + * Worker-side RPC host — runs inside the child process spawned by the host. + * + * This module is the worker-side counterpart to the server's + * `PluginWorkerManager`. It: + * + * 1. Reads newline-delimited JSON-RPC 2.0 requests from **stdin** + * 2. Dispatches them to the appropriate plugin handler (events, jobs, tools, …) + * 3. Writes JSON-RPC 2.0 responses back on **stdout** + * 4. Provides a concrete `PluginContext` whose SDK client methods (e.g. + * `ctx.state.get()`, `ctx.events.emit()`) send JSON-RPC requests to the + * host on stdout and await responses on stdin. + * + * ## Message flow + * + * ``` + * Host (parent) Worker (this module) + * | | + * |--- request(initialize) -------------> | → calls plugin.setup(ctx) + * |<-- response(ok:true) ---------------- | + * | | + * |--- request(onEvent) ----------------> | → dispatches to registered handler + * |<-- response(void) ------------------ | + * | | + * |<-- request(state.get) --------------- | ← SDK client call from plugin code + * |--- response(result) ----------------> | + * | | + * |--- request(shutdown) ---------------> | → calls plugin.onShutdown() + * |<-- response(void) ------------------ | + * | (process exits) + * ``` + * + * @see PLUGIN_SPEC.md §12 — Process Model + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ + +import path from "node:path"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; + +import type { PaperclipPlugin } from "./define-plugin.js"; +import type { + PluginHealthDiagnostics, + PluginConfigValidationResult, + PluginWebhookInput, +} from "./define-plugin.js"; +import type { + PluginContext, + PluginEvent, + PluginJobContext, + PluginLauncherRegistration, + ScopeKey, + ToolRunContext, + ToolResult, + EventFilter, + AgentSessionEvent, +} from "./types.js"; +import type { + JsonRpcId, + JsonRpcRequest, + JsonRpcResponse, + InitializeParams, + InitializeResult, + ConfigChangedParams, + ValidateConfigParams, + OnEventParams, + RunJobParams, + GetDataParams, + PerformActionParams, + ExecuteToolParams, + WorkerToHostMethodName, + WorkerToHostMethods, +} from "./protocol.js"; +import { + JSONRPC_VERSION, + JSONRPC_ERROR_CODES, + PLUGIN_RPC_ERROR_CODES, + createRequest, + createSuccessResponse, + createErrorResponse, + createNotification, + parseMessage, + serializeMessage, + isJsonRpcRequest, + isJsonRpcResponse, + isJsonRpcNotification, + isJsonRpcSuccessResponse, + isJsonRpcErrorResponse, + JsonRpcParseError, + JsonRpcCallError, +} from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for starting the worker-side RPC host. + */ +export interface WorkerRpcHostOptions { + /** + * The plugin definition returned by `definePlugin()`. + * + * The worker entrypoint should import its plugin and pass it here. + */ + plugin: PaperclipPlugin; + + /** + * Input stream to read JSON-RPC messages from. + * Defaults to `process.stdin`. + */ + stdin?: NodeJS.ReadableStream; + + /** + * Output stream to write JSON-RPC messages to. + * Defaults to `process.stdout`. + */ + stdout?: NodeJS.WritableStream; + + /** + * Default timeout (ms) for worker→host RPC calls. + * Defaults to 30 000 ms. + */ + rpcTimeoutMs?: number; +} + +/** + * A running worker RPC host instance. + * + * Returned by `startWorkerRpcHost()`. Callers (usually just the worker + * bootstrap) hold a reference so they can inspect status or force-stop. + */ +export interface WorkerRpcHost { + /** Whether the host is currently running and listening for messages. */ + readonly running: boolean; + + /** + * Stop the RPC host immediately. Closes readline, rejects pending + * outbound calls, and does NOT call the plugin's shutdown hook (that + * should have already been called via the `shutdown` RPC method). + */ + stop(): void; +} + +// --------------------------------------------------------------------------- +// Internal: event registration +// --------------------------------------------------------------------------- + +interface EventRegistration { + name: string; + filter?: EventFilter; + fn: (event: PluginEvent) => Promise; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default timeout for worker→host RPC calls. */ +const DEFAULT_RPC_TIMEOUT_MS = 30_000; + +// --------------------------------------------------------------------------- +// startWorkerRpcHost +// --------------------------------------------------------------------------- + +/** + * Options for runWorker when testing (optional stdio to avoid using process streams). + * When both stdin and stdout are provided, the "is main module" check is skipped + * and the host is started with these streams. Used by tests. + */ +export interface RunWorkerOptions { + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +} + +/** + * Start the worker when this module is the process entrypoint. + * + * Call this at the bottom of your worker file so that when the host runs + * `node dist/worker.js`, the RPC host starts and the process stays alive. + * When the module is imported (e.g. for re-exports or tests), nothing runs. + * + * When `options.stdin` and `options.stdout` are provided (e.g. in tests), + * the main-module check is skipped and the host is started with those streams. + * + * @example + * ```ts + * const plugin = definePlugin({ ... }); + * export default plugin; + * runWorker(plugin, import.meta.url); + * ``` + */ +export function runWorker( + plugin: PaperclipPlugin, + moduleUrl: string, + options?: RunWorkerOptions, +): WorkerRpcHost | void { + if ( + options?.stdin != null && + options?.stdout != null + ) { + return startWorkerRpcHost({ + plugin, + stdin: options.stdin, + stdout: options.stdout, + }); + } + const entry = process.argv[1]; + if (typeof entry !== "string") return; + const thisFile = path.resolve(fileURLToPath(moduleUrl)); + const entryPath = path.resolve(entry); + if (thisFile === entryPath) { + startWorkerRpcHost({ plugin }); + } +} + +/** + * Start the worker-side RPC host. + * + * This function is typically called from a thin bootstrap script that is the + * actual entrypoint of the child process: + * + * ```ts + * // worker-bootstrap.ts + * import plugin from "./worker.js"; + * import { startWorkerRpcHost } from "@paperclipai/plugin-sdk"; + * + * startWorkerRpcHost({ plugin }); + * ``` + * + * The host begins listening on stdin immediately. It does NOT call + * `plugin.definition.setup()` yet — that happens when the host sends the + * `initialize` RPC. + * + * @returns A handle for inspecting or stopping the RPC host + */ +export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost { + const { plugin } = options; + const stdinStream = options.stdin ?? process.stdin; + const stdoutStream = options.stdout ?? process.stdout; + const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + let running = true; + let initialized = false; + let manifest: PaperclipPluginManifestV1 | null = null; + let currentConfig: Record = {}; + + // Plugin handler registrations (populated during setup()) + const eventHandlers: EventRegistration[] = []; + const jobHandlers = new Map Promise>(); + const launcherRegistrations = new Map(); + const dataHandlers = new Map) => Promise>(); + const actionHandlers = new Map) => Promise>(); + const toolHandlers = new Map; + fn: (params: unknown, runCtx: ToolRunContext) => Promise; + }>(); + + // Agent session event callbacks (populated by sendMessage, cleared by close) + const sessionEventCallbacks = new Map void>(); + + // Pending outbound (worker→host) requests + const pendingRequests = new Map void; + timer: ReturnType; + }>(); + let nextOutboundId = 1; + const MAX_OUTBOUND_ID = Number.MAX_SAFE_INTEGER - 1; + + // ----------------------------------------------------------------------- + // Outbound messaging (worker → host) + // ----------------------------------------------------------------------- + + function sendMessage(message: unknown): void { + if (!running) return; + const serialized = serializeMessage(message as any); + stdoutStream.write(serialized); + } + + /** + * Send a typed JSON-RPC request to the host and await the response. + */ + function callHost( + method: M, + params: WorkerToHostMethods[M][0], + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (!running) { + reject(new Error(`Cannot call "${method}" — worker RPC host is not running`)); + return; + } + + if (nextOutboundId >= MAX_OUTBOUND_ID) { + nextOutboundId = 1; + } + const id = nextOutboundId++; + const timeout = timeoutMs ?? rpcTimeoutMs; + let settled = false; + + const settle = (fn: (value: T) => void, value: T): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + pendingRequests.delete(id); + fn(value); + }; + + const timer = setTimeout(() => { + settle( + reject, + new JsonRpcCallError({ + code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, + message: `Worker→host call "${method}" timed out after ${timeout}ms`, + }), + ); + }, timeout); + + pendingRequests.set(id, { + resolve: (response: JsonRpcResponse) => { + if (isJsonRpcSuccessResponse(response)) { + settle(resolve, response.result as WorkerToHostMethods[M][1]); + } else if (isJsonRpcErrorResponse(response)) { + settle(reject, new JsonRpcCallError(response.error)); + } else { + settle(reject, new Error(`Unexpected response format for "${method}"`)); + } + }, + timer, + }); + + try { + const request = createRequest(method, params, id); + sendMessage(request); + } catch (err) { + settle(reject, err instanceof Error ? err : new Error(String(err))); + } + }); + } + + /** + * Send a JSON-RPC notification to the host (fire-and-forget). + */ + function notifyHost(method: string, params: unknown): void { + try { + sendMessage(createNotification(method, params)); + } catch { + // Swallow — the host may have closed stdin + } + } + + // ----------------------------------------------------------------------- + // Build the PluginContext (SDK surface for plugin code) + // ----------------------------------------------------------------------- + + function buildContext(): PluginContext { + return { + get manifest() { + if (!manifest) throw new Error("Plugin context accessed before initialization"); + return manifest; + }, + + config: { + async get() { + return callHost("config.get", {} as Record); + }, + }, + + events: { + on( + name: string, + filterOrFn: EventFilter | ((event: PluginEvent) => Promise), + maybeFn?: (event: PluginEvent) => Promise, + ): () => void { + let registration: EventRegistration; + if (typeof filterOrFn === "function") { + registration = { name, fn: filterOrFn }; + } else { + if (!maybeFn) throw new Error("Event handler function is required"); + registration = { name, filter: filterOrFn, fn: maybeFn }; + } + eventHandlers.push(registration); + return () => { + const idx = eventHandlers.indexOf(registration); + if (idx !== -1) eventHandlers.splice(idx, 1); + }; + }, + + async emit(name: string, companyId: string, payload: unknown): Promise { + await callHost("events.emit", { name, companyId, payload }); + }, + }, + + jobs: { + register(key: string, fn: (job: PluginJobContext) => Promise): void { + jobHandlers.set(key, fn); + }, + }, + + launchers: { + register(launcher: PluginLauncherRegistration): void { + launcherRegistrations.set(launcher.id, launcher); + }, + }, + + http: { + async fetch(url: string, init?: RequestInit): Promise { + const serializedInit: Record = {}; + if (init) { + if (init.method) serializedInit.method = init.method; + if (init.headers) { + // Normalize headers to a plain object + if (init.headers instanceof Headers) { + const obj: Record = {}; + init.headers.forEach((v, k) => { obj[k] = v; }); + serializedInit.headers = obj; + } else if (Array.isArray(init.headers)) { + const obj: Record = {}; + for (const [k, v] of init.headers) obj[k] = v; + serializedInit.headers = obj; + } else { + serializedInit.headers = init.headers; + } + } + if (init.body !== undefined && init.body !== null) { + serializedInit.body = typeof init.body === "string" + ? init.body + : String(init.body); + } + } + + const result = await callHost("http.fetch", { + url, + init: Object.keys(serializedInit).length > 0 ? serializedInit : undefined, + }); + + // Reconstruct a Response-like object from the serialized result + return new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }); + }, + }, + + secrets: { + async resolve(secretRef: string): Promise { + return callHost("secrets.resolve", { secretRef }); + }, + }, + + assets: { + async upload( + filename: string, + contentType: string, + data: Buffer | Uint8Array, + ): Promise<{ assetId: string; url: string }> { + // Base64-encode binary data for JSON serialization + const base64 = Buffer.from(data).toString("base64"); + return callHost("assets.upload", { + filename, + contentType, + data: base64, + }); + }, + + async getUrl(assetId: string): Promise { + return callHost("assets.getUrl", { assetId }); + }, + }, + + activity: { + async log(entry): Promise { + await callHost("activity.log", { + companyId: entry.companyId, + message: entry.message, + entityType: entry.entityType, + entityId: entry.entityId, + metadata: entry.metadata, + }); + }, + }, + + state: { + async get(input: ScopeKey): Promise { + return callHost("state.get", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + }); + }, + + async set(input: ScopeKey, value: unknown): Promise { + await callHost("state.set", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + value, + }); + }, + + async delete(input: ScopeKey): Promise { + await callHost("state.delete", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + }); + }, + }, + + entities: { + async upsert(input) { + return callHost("entities.upsert", { + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId, + externalId: input.externalId, + title: input.title, + status: input.status, + data: input.data, + }); + }, + + async list(query) { + return callHost("entities.list", { + entityType: query.entityType, + scopeKind: query.scopeKind, + scopeId: query.scopeId, + externalId: query.externalId, + limit: query.limit, + offset: query.offset, + }); + }, + }, + + projects: { + async list(input) { + return callHost("projects.list", { + companyId: input.companyId, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(projectId: string, companyId: string) { + return callHost("projects.get", { projectId, companyId }); + }, + + async listWorkspaces(projectId: string, companyId: string) { + return callHost("projects.listWorkspaces", { projectId, companyId }); + }, + + async getPrimaryWorkspace(projectId: string, companyId: string) { + return callHost("projects.getPrimaryWorkspace", { projectId, companyId }); + }, + + async getWorkspaceForIssue(issueId: string, companyId: string) { + return callHost("projects.getWorkspaceForIssue", { issueId, companyId }); + }, + }, + + companies: { + async list(input) { + return callHost("companies.list", { + limit: input?.limit, + offset: input?.offset, + }); + }, + + async get(companyId: string) { + return callHost("companies.get", { companyId }); + }, + }, + + issues: { + async list(input) { + return callHost("issues.list", { + companyId: input.companyId, + projectId: input.projectId, + assigneeAgentId: input.assigneeAgentId, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(issueId: string, companyId: string) { + return callHost("issues.get", { issueId, companyId }); + }, + + async create(input) { + return callHost("issues.create", { + companyId: input.companyId, + projectId: input.projectId, + goalId: input.goalId, + parentId: input.parentId, + title: input.title, + description: input.description, + priority: input.priority, + assigneeAgentId: input.assigneeAgentId, + }); + }, + + async update(issueId: string, patch, companyId: string) { + return callHost("issues.update", { + issueId, + patch: patch as Record, + companyId, + }); + }, + + async listComments(issueId: string, companyId: string) { + return callHost("issues.listComments", { issueId, companyId }); + }, + + async createComment(issueId: string, body: string, companyId: string) { + return callHost("issues.createComment", { issueId, body, companyId }); + }, + }, + + agents: { + async list(input) { + return callHost("agents.list", { + companyId: input.companyId, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(agentId: string, companyId: string) { + return callHost("agents.get", { agentId, companyId }); + }, + + async pause(agentId: string, companyId: string) { + return callHost("agents.pause", { agentId, companyId }); + }, + + async resume(agentId: string, companyId: string) { + return callHost("agents.resume", { agentId, companyId }); + }, + + async invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }) { + return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason }); + }, + + sessions: { + async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) { + return callHost("agents.sessions.create", { + agentId, + companyId, + taskKey: opts?.taskKey, + reason: opts?.reason, + }); + }, + + async list(agentId: string, companyId: string) { + return callHost("agents.sessions.list", { agentId, companyId }); + }, + + async sendMessage(sessionId: string, companyId: string, opts: { + prompt: string; + reason?: string; + onEvent?: (event: AgentSessionEvent) => void; + }) { + if (opts.onEvent) { + sessionEventCallbacks.set(sessionId, opts.onEvent); + } + try { + return await callHost("agents.sessions.sendMessage", { + sessionId, + companyId, + prompt: opts.prompt, + reason: opts.reason, + }); + } catch (err) { + sessionEventCallbacks.delete(sessionId); + throw err; + } + }, + + async close(sessionId: string, companyId: string) { + sessionEventCallbacks.delete(sessionId); + await callHost("agents.sessions.close", { sessionId, companyId }); + }, + }, + }, + + goals: { + async list(input) { + return callHost("goals.list", { + companyId: input.companyId, + level: input.level, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(goalId: string, companyId: string) { + return callHost("goals.get", { goalId, companyId }); + }, + + async create(input) { + return callHost("goals.create", { + companyId: input.companyId, + title: input.title, + description: input.description, + level: input.level, + status: input.status, + parentId: input.parentId, + ownerAgentId: input.ownerAgentId, + }); + }, + + async update(goalId: string, patch, companyId: string) { + return callHost("goals.update", { + goalId, + patch: patch as Record, + companyId, + }); + }, + }, + + data: { + register(key: string, handler: (params: Record) => Promise): void { + dataHandlers.set(key, handler); + }, + }, + + actions: { + register(key: string, handler: (params: Record) => Promise): void { + actionHandlers.set(key, handler); + }, + }, + + streams: (() => { + // Track channel → companyId so emit/close don't require companyId + const channelCompanyMap = new Map(); + return { + open(channel: string, companyId: string): void { + channelCompanyMap.set(channel, companyId); + notifyHost("streams.open", { channel, companyId }); + }, + emit(channel: string, event: unknown): void { + const companyId = channelCompanyMap.get(channel) ?? ""; + notifyHost("streams.emit", { channel, companyId, event }); + }, + close(channel: string): void { + const companyId = channelCompanyMap.get(channel) ?? ""; + channelCompanyMap.delete(channel); + notifyHost("streams.close", { channel, companyId }); + }, + }; + })(), + + tools: { + register( + name: string, + declaration: Pick, + fn: (params: unknown, runCtx: ToolRunContext) => Promise, + ): void { + toolHandlers.set(name, { declaration, fn }); + }, + }, + + metrics: { + async write(name: string, value: number, tags?: Record): Promise { + await callHost("metrics.write", { name, value, tags }); + }, + }, + + logger: { + info(message: string, meta?: Record): void { + notifyHost("log", { level: "info", message, meta }); + }, + warn(message: string, meta?: Record): void { + notifyHost("log", { level: "warn", message, meta }); + }, + error(message: string, meta?: Record): void { + notifyHost("log", { level: "error", message, meta }); + }, + debug(message: string, meta?: Record): void { + notifyHost("log", { level: "debug", message, meta }); + }, + }, + }; + } + + const ctx = buildContext(); + + // ----------------------------------------------------------------------- + // Inbound message handling (host → worker) + // ----------------------------------------------------------------------- + + /** + * Handle an incoming JSON-RPC request from the host. + * + * Dispatches to the correct handler based on the method name. + */ + async function handleHostRequest(request: JsonRpcRequest): Promise { + const { id, method, params } = request; + + try { + const result = await dispatchMethod(method, params); + sendMessage(createSuccessResponse(id, result ?? null)); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + // Propagate specific error codes from handler errors (e.g. + // METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to + // WORKER_ERROR for untyped exceptions. + const errorCode = + typeof (err as any)?.code === "number" + ? (err as any).code + : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; + + sendMessage(createErrorResponse(id, errorCode, errorMessage)); + } + } + + /** + * Dispatch a host→worker method call to the appropriate handler. + */ + async function dispatchMethod(method: string, params: unknown): Promise { + switch (method) { + case "initialize": + return handleInitialize(params as InitializeParams); + + case "health": + return handleHealth(); + + case "shutdown": + return handleShutdown(); + + case "validateConfig": + return handleValidateConfig(params as ValidateConfigParams); + + case "configChanged": + return handleConfigChanged(params as ConfigChangedParams); + + case "onEvent": + return handleOnEvent(params as OnEventParams); + + case "runJob": + return handleRunJob(params as RunJobParams); + + case "handleWebhook": + return handleWebhook(params as PluginWebhookInput); + + case "getData": + return handleGetData(params as GetDataParams); + + case "performAction": + return handlePerformAction(params as PerformActionParams); + + case "executeTool": + return handleExecuteTool(params as ExecuteToolParams); + + default: + throw Object.assign( + new Error(`Unknown method: ${method}`), + { code: JSONRPC_ERROR_CODES.METHOD_NOT_FOUND }, + ); + } + } + + // ----------------------------------------------------------------------- + // Host→Worker method handlers + // ----------------------------------------------------------------------- + + async function handleInitialize(params: InitializeParams): Promise { + if (initialized) { + throw new Error("Worker already initialized"); + } + + manifest = params.manifest; + currentConfig = params.config; + + // Call the plugin's setup function + await plugin.definition.setup(ctx); + + initialized = true; + + // Report which optional methods this plugin implements + const supportedMethods: string[] = []; + if (plugin.definition.onValidateConfig) supportedMethods.push("validateConfig"); + if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged"); + if (plugin.definition.onHealth) supportedMethods.push("health"); + if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); + + return { ok: true, supportedMethods }; + } + + async function handleHealth(): Promise { + if (plugin.definition.onHealth) { + return plugin.definition.onHealth(); + } + // Default: report OK if the worker is alive + return { status: "ok" }; + } + + async function handleShutdown(): Promise { + if (plugin.definition.onShutdown) { + await plugin.definition.onShutdown(); + } + + // Schedule cleanup after we send the response. + // Use setImmediate to let the response flush before exiting. + // Only call process.exit() when running with real process streams. + // When custom streams are provided (tests), just clean up. + setImmediate(() => { + cleanup(); + if (!options.stdin && !options.stdout) { + process.exit(0); + } + }); + } + + async function handleValidateConfig( + params: ValidateConfigParams, + ): Promise { + if (!plugin.definition.onValidateConfig) { + throw Object.assign( + new Error("validateConfig is not implemented by this plugin"), + { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, + ); + } + return plugin.definition.onValidateConfig(params.config); + } + + async function handleConfigChanged(params: ConfigChangedParams): Promise { + currentConfig = params.config; + + if (plugin.definition.onConfigChanged) { + await plugin.definition.onConfigChanged(params.config); + } + } + + async function handleOnEvent(params: OnEventParams): Promise { + const event = params.event; + + for (const registration of eventHandlers) { + // Check event type match + const exactMatch = registration.name === event.eventType; + const wildcardPluginAll = + registration.name === "plugin.*" && + event.eventType.startsWith("plugin."); + const wildcardPluginOne = + registration.name.endsWith(".*") && + event.eventType.startsWith(registration.name.slice(0, -1)); + + if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; + + // Check filter + if (registration.filter && !allowsEvent(registration.filter, event)) continue; + + try { + await registration.fn(event); + } catch (err) { + // Log error but continue processing other handlers so one failing + // handler doesn't prevent the rest from running. + notifyHost("log", { + level: "error", + message: `Event handler for "${registration.name}" failed: ${ + err instanceof Error ? err.message : String(err) + }`, + meta: { eventType: event.eventType, stack: err instanceof Error ? err.stack : undefined }, + }); + } + } + } + + async function handleRunJob(params: RunJobParams): Promise { + const handler = jobHandlers.get(params.job.jobKey); + if (!handler) { + throw new Error(`No handler registered for job "${params.job.jobKey}"`); + } + await handler(params.job); + } + + async function handleWebhook(params: PluginWebhookInput): Promise { + if (!plugin.definition.onWebhook) { + throw Object.assign( + new Error("handleWebhook is not implemented by this plugin"), + { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, + ); + } + await plugin.definition.onWebhook(params); + } + + async function handleGetData(params: GetDataParams): Promise { + const handler = dataHandlers.get(params.key); + if (!handler) { + throw new Error(`No data handler registered for key "${params.key}"`); + } + return handler( + params.renderEnvironment === undefined + ? params.params + : { ...params.params, renderEnvironment: params.renderEnvironment }, + ); + } + + async function handlePerformAction(params: PerformActionParams): Promise { + const handler = actionHandlers.get(params.key); + if (!handler) { + throw new Error(`No action handler registered for key "${params.key}"`); + } + return handler( + params.renderEnvironment === undefined + ? params.params + : { ...params.params, renderEnvironment: params.renderEnvironment }, + ); + } + + async function handleExecuteTool(params: ExecuteToolParams): Promise { + const entry = toolHandlers.get(params.toolName); + if (!entry) { + throw new Error(`No tool handler registered for "${params.toolName}"`); + } + return entry.fn(params.parameters, params.runContext); + } + + // ----------------------------------------------------------------------- + // Event filter helper + // ----------------------------------------------------------------------- + + function allowsEvent(filter: EventFilter, event: PluginEvent): boolean { + const payload = event.payload as Record | undefined; + + if (filter.companyId !== undefined) { + const companyId = event.companyId ?? String(payload?.companyId ?? ""); + if (companyId !== filter.companyId) return false; + } + + if (filter.projectId !== undefined) { + const projectId = event.entityType === "project" + ? event.entityId + : String(payload?.projectId ?? ""); + if (projectId !== filter.projectId) return false; + } + + if (filter.agentId !== undefined) { + const agentId = event.entityType === "agent" + ? event.entityId + : String(payload?.agentId ?? ""); + if (agentId !== filter.agentId) return false; + } + + return true; + } + + // ----------------------------------------------------------------------- + // Inbound response handling (host → worker, response to our outbound call) + // ----------------------------------------------------------------------- + + function handleHostResponse(response: JsonRpcResponse): void { + const id = response.id; + if (id === null || id === undefined) return; + + const pending = pendingRequests.get(id); + if (!pending) return; + + clearTimeout(pending.timer); + pendingRequests.delete(id); + pending.resolve(response); + } + + // ----------------------------------------------------------------------- + // Incoming line handler + // ----------------------------------------------------------------------- + + function handleLine(line: string): void { + if (!line.trim()) return; + + let message: unknown; + try { + message = parseMessage(line); + } catch (err) { + if (err instanceof JsonRpcParseError) { + // Send parse error response + sendMessage( + createErrorResponse( + null, + JSONRPC_ERROR_CODES.PARSE_ERROR, + `Parse error: ${err.message}`, + ), + ); + } + return; + } + + if (isJsonRpcResponse(message)) { + // This is a response to one of our outbound worker→host calls + handleHostResponse(message); + } else if (isJsonRpcRequest(message)) { + // This is a host→worker RPC call — dispatch it + handleHostRequest(message as JsonRpcRequest).catch((err) => { + // Unhandled error in the async handler — send error response + const errorMessage = err instanceof Error ? err.message : String(err); + const errorCode = (err as any)?.code ?? PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; + try { + sendMessage( + createErrorResponse( + (message as JsonRpcRequest).id, + typeof errorCode === "number" ? errorCode : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR, + errorMessage, + ), + ); + } catch { + // Cannot send response, stdout may be closed + } + }); + } else if (isJsonRpcNotification(message)) { + // Dispatch host→worker push notifications + const notif = message as { method: string; params?: unknown }; + if (notif.method === "agents.sessions.event" && notif.params) { + const event = notif.params as AgentSessionEvent; + const cb = sessionEventCallbacks.get(event.sessionId); + if (cb) cb(event); + } + } + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + function cleanup(): void { + running = false; + + // Close readline + if (readline) { + readline.close(); + readline = null; + } + + // Reject all pending outbound calls + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.resolve( + createErrorResponse( + id, + PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, + "Worker RPC host is shutting down", + ) as JsonRpcResponse, + ); + } + pendingRequests.clear(); + sessionEventCallbacks.clear(); + } + + // ----------------------------------------------------------------------- + // Bootstrap: wire up stdin readline + // ----------------------------------------------------------------------- + + let readline: ReadlineInterface | null = createInterface({ + input: stdinStream as NodeJS.ReadableStream, + crlfDelay: Infinity, + }); + + readline.on("line", handleLine); + + // If stdin closes, we should exit gracefully + readline.on("close", () => { + if (running) { + cleanup(); + if (!options.stdin && !options.stdout) { + process.exit(0); + } + } + }); + + // Handle uncaught errors in the worker process. + // Only install these when using the real process streams (not in tests + // where the caller provides custom streams). + if (!options.stdin && !options.stdout) { + process.on("uncaughtException", (err) => { + notifyHost("log", { + level: "error", + message: `Uncaught exception: ${err.message}`, + meta: { stack: err.stack }, + }); + // Give the notification a moment to flush, then exit + setTimeout(() => process.exit(1), 100); + }); + + process.on("unhandledRejection", (reason) => { + const message = reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + notifyHost("log", { + level: "error", + message: `Unhandled rejection: ${message}`, + meta: { stack }, + }); + }); + } + + // ----------------------------------------------------------------------- + // Return the handle + // ----------------------------------------------------------------------- + + return { + get running() { + return running; + }, + + stop() { + cleanup(); + }, + }; +} diff --git a/packages/plugins/sdk/tsconfig.json b/packages/plugins/sdk/tsconfig.json new file mode 100644 index 00000000..d62ebdff --- /dev/null +++ b/packages/plugins/sdk/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node", "react"] + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bf2d3665..9aa3a002 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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,311 @@ 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", + "assets.write", + "assets.read", + "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]; + +/** + * 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]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a222f27..99315943 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 { @@ -129,6 +162,28 @@ export type { AgentEnvConfig, CompanySecret, SecretProviderDescriptor, + JsonSchema, + PluginJobDeclaration, + PluginWebhookDeclaration, + PluginToolDeclaration, + PluginUiSlotDeclaration, + PluginLauncherActionDeclaration, + PluginLauncherRenderDeclaration, + PluginLauncherRenderContextSnapshot, + PluginLauncherDeclaration, + PluginMinimumHostVersion, + PluginUiDeclaration, + PaperclipPluginManifestV1, + PluginRecord, + PluginStateRecord, + PluginConfig, + PluginCompanySettings, + CompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, } from "./types/index.js"; export { @@ -238,6 +293,45 @@ export { type CompanyPortabilityExport, type CompanyPortabilityPreview, type CompanyPortabilityImport, + jsonSchemaSchema, + pluginJobDeclarationSchema, + pluginWebhookDeclarationSchema, + pluginToolDeclarationSchema, + pluginUiSlotDeclarationSchema, + pluginLauncherActionDeclarationSchema, + pluginLauncherRenderDeclarationSchema, + pluginLauncherDeclarationSchema, + pluginManifestV1Schema, + installPluginSchema, + upsertPluginConfigSchema, + patchPluginConfigSchema, + upsertPluginCompanySettingsSchema, + updateCompanyPluginAvailabilitySchema, + listCompanyPluginAvailabilitySchema, + 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 UpsertPluginCompanySettings, + type UpdateCompanyPluginAvailability, + type ListCompanyPluginAvailability, + type UpdatePluginStatus, + type UninstallPlugin, + type PluginStateScopeKey, + type SetPluginState, + type ListPluginState, } from "./validators/index.js"; export { API_PREFIX, API } from "./api.js"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 07862c58..7dfb0a33 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -79,3 +79,27 @@ 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, + PluginCompanySettings, + CompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, +} from "./plugin.js"; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts new file mode 100644 index 00000000..6254a7bb --- /dev/null +++ b/packages/shared/src/types/plugin.ts @@ -0,0 +1,545 @@ +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; + +// --------------------------------------------------------------------------- +// 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 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; +} + +/** + * 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 "`. */ + 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; + /** 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; +} + +// --------------------------------------------------------------------------- +// Company Plugin Availability / Settings +// --------------------------------------------------------------------------- + +/** + * Domain type for a plugin's company-scoped settings row as persisted in the + * `plugin_company_settings` table. + * + * This is separate from instance-wide `PluginConfig`: the plugin remains + * installed globally, while each company can store its own plugin settings and + * availability state independently. + */ +export interface PluginCompanySettings { + /** UUID primary key. */ + id: string; + /** FK to `companies.id`. */ + companyId: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Explicit availability override for this company/plugin pair. */ + enabled: boolean; + /** Company-scoped plugin settings payload. */ + settingsJson: Record; + /** Most recent company-scoped validation or availability error, if any. */ + lastError: string | null; + /** Timestamp when the settings row was created. */ + createdAt: Date; + /** Timestamp of the most recent settings update. */ + updatedAt: Date; +} + +/** + * API response shape describing whether a plugin is available to a specific + * company and, when present, the company-scoped settings row backing that + * availability. + */ +export interface CompanyPluginAvailability { + companyId: string; + pluginId: string; + /** Stable manifest/plugin key for display and route generation. */ + pluginKey: string; + /** Human-readable plugin name. */ + pluginDisplayName: string; + /** Current instance-wide plugin lifecycle status. */ + pluginStatus: PluginStatus; + /** + * Whether the plugin is currently available to the company. + * When no `plugin_company_settings` row exists yet, the plugin is enabled + * by default for the company. + */ + available: boolean; + /** Company-scoped settings, defaulting to an empty object when unavailable. */ + settingsJson: Record; + /** Most recent company-scoped error, if any. */ + lastError: string | null; + /** Present when availability is backed by a persisted settings row. */ + createdAt: Date | null; + /** Present when availability is backed by a persisted settings row. */ + updatedAt: Date | null; +} + +/** + * 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; + /** 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; + /** Webhook headers. */ + headers: Record; + /** ISO 8601 start timestamp. */ + startedAt: Date | null; + /** ISO 8601 finish timestamp. */ + finishedAt: Date | null; + /** ISO 8601 creation timestamp. */ + createdAt: Date; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ad74a1e8..687dd1ad 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -137,3 +137,45 @@ export { type UpdateMemberPermissions, type UpdateUserCompanyAccess, } from "./access.js"; + +export { + jsonSchemaSchema, + pluginJobDeclarationSchema, + pluginWebhookDeclarationSchema, + pluginToolDeclarationSchema, + pluginUiSlotDeclarationSchema, + pluginLauncherActionDeclarationSchema, + pluginLauncherRenderDeclarationSchema, + pluginLauncherDeclarationSchema, + pluginManifestV1Schema, + installPluginSchema, + upsertPluginConfigSchema, + patchPluginConfigSchema, + upsertPluginCompanySettingsSchema, + updateCompanyPluginAvailabilitySchema, + listCompanyPluginAvailabilitySchema, + 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 UpsertPluginCompanySettings, + type UpdateCompanyPluginAvailability, + type ListCompanyPluginAvailability, + type UpdatePluginStatus, + type UninstallPlugin, + type PluginStateScopeKey, + type SetPluginState, + type ListPluginState, +} from "./plugin.js"; diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts new file mode 100644 index 00000000..be9b2d5b --- /dev/null +++ b/packages/shared/src/validators/plugin.ts @@ -0,0 +1,694 @@ +import { z } from "zod"; +import { + 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, +} from "../constants.js"; + +// --------------------------------------------------------------------------- +// JSON Schema placeholder – a permissive validator for JSON Schema objects +// --------------------------------------------------------------------------- + +/** + * Permissive validator for JSON Schema objects. Accepts any `Record` + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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(), + 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"], + }); + } +}); + +export type PluginUiSlotDeclarationInput = z.infer; + +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; + +/** + * 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; + +/** + * 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; + +// --------------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------------- +// 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; + +/** + * 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; + +// --------------------------------------------------------------------------- +// Company plugin availability / settings schemas +// --------------------------------------------------------------------------- + +/** + * Schema for creating or replacing company-scoped plugin settings. + * + * Company-specific settings are stored separately from instance-level + * `plugin_config`, allowing the host to expose a company availability toggle + * without changing the global install state of the plugin. + */ +export const upsertPluginCompanySettingsSchema = z.object({ + settingsJson: z.record(z.unknown()).optional().default({}), + lastError: z.string().nullable().optional(), +}); + +export type UpsertPluginCompanySettings = z.infer; + +/** + * Schema for mutating a plugin's availability for a specific company. + * + * `available=false` lets callers disable access without uninstalling the + * plugin globally. Optional `settingsJson` supports carrying company-specific + * configuration alongside the availability update. + */ +export const updateCompanyPluginAvailabilitySchema = z.object({ + available: z.boolean(), + settingsJson: z.record(z.unknown()).optional(), + lastError: z.string().nullable().optional(), +}); + +export type UpdateCompanyPluginAvailability = z.infer; + +/** + * Query schema for company plugin availability list endpoints. + */ +export const listCompanyPluginAvailabilitySchema = z.object({ + available: z.boolean().optional(), +}); + +export type ListCompanyPluginAvailability = z.infer; + +// --------------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------------- +// 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; + +// --------------------------------------------------------------------------- +// 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; + +/** + * 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; + +/** + * 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; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 014e1412..4ac3cfcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - packages/* - packages/adapters/* + - packages/plugins/* + - packages/plugins/examples/* - server - ui - cli diff --git a/scripts/ensure-plugin-build-deps.mjs b/scripts/ensure-plugin-build-deps.mjs new file mode 100644 index 00000000..f8470da1 --- /dev/null +++ b/scripts/ensure-plugin-build-deps.mjs @@ -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); + } +} diff --git a/server/package.json b/server/package.json index cd30cf13..2e56ae5d 100644 --- a/server/package.json +++ b/server/package.json @@ -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,7 +43,10 @@ "@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", "detect-port": "^2.1.0", "dotenv": "^17.0.1", diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts new file mode 100644 index 00000000..53ed57bb --- /dev/null +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -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); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6871552a..52e06782 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; }, ) { 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,75 @@ export async function createApp( api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); + const hostServicesDisposers = new Map void>(); + const workerManager = createPluginWorkerManager(); + const pluginRegistry = pluginRegistryService(db); + const eventBus = createPluginEventBus({ + async isPluginEnabledForCompany(pluginKey, companyId) { + const plugin = await pluginRegistry.getByKey(pluginKey); + if (!plugin) return false; + const availability = await pluginRegistry.getCompanyAvailability(companyId, plugin.id); + return availability?.available ?? true; + }, + }); + 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 +218,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 +274,33 @@ 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 = createPluginDevWatcher( + lifecycle, + async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, + ); + void loader.loadAll().then((result) => { + if (!result) return; + for (const loaded of result.results) { + if (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; } diff --git a/server/src/routes/plugin-ui-static.ts b/server/src/routes/plugin-ui-static.ts new file mode 100644 index 00000000..2784f593 --- /dev/null +++ b/server/src/routes/plugin-ui-static.ts @@ -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 = { + ".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 /node_modules// + 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 }).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; +} diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts new file mode 100644 index 00000000..2d6382a9 --- /dev/null +++ b/server/src/routes/plugins.ts @@ -0,0 +1,2417 @@ +/** + * @fileoverview Plugin management REST API routes + * + * This module provides Express routes for managing the complete plugin lifecycle: + * - Listing and filtering plugins by status + * - Installing plugins from npm or local paths + * - Uninstalling plugins (soft delete or hard purge) + * - Enabling/disabling plugins + * - Running health diagnostics + * - Upgrading plugins + * - Retrieving UI slot contributions for frontend rendering + * - Discovering and executing plugin-contributed agent tools + * + * All routes require board-level authentication (assertBoard middleware). + * + * @module server/routes/plugins + * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { Router } from "express"; +import type { Request, Response } from "express"; +import { and, desc, eq, gte } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db"; +import type { + PluginStatus, + PaperclipPluginManifestV1, + PluginBridgeErrorCode, + PluginLauncherRenderContextSnapshot, + UpdateCompanyPluginAvailability, +} from "@paperclipai/shared"; +import { + PLUGIN_STATUSES, + updateCompanyPluginAvailabilitySchema, +} from "@paperclipai/shared"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; +import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; +import { logActivity } from "../services/activity-log.js"; +import { publishGlobalLiveEvent } from "../services/live-events.js"; +import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js"; +import type { PluginJobStore } from "../services/plugin-job-store.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; +import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; +import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { validateInstanceConfig } from "../services/plugin-config-validator.js"; + +/** UI slot declaration extracted from plugin manifest */ +type PluginUiSlotDeclaration = NonNullable["slots"]>[number]; +/** Launcher declaration extracted from plugin manifest */ +type PluginLauncherDeclaration = NonNullable[number]; + +/** + * Normalized UI contribution for frontend slot host consumption. + * Only includes plugins in 'ready' state with non-empty slot declarations. + */ +type PluginUiContribution = { + pluginId: string; + pluginKey: string; + displayName: string; + version: string; + updatedAt: string; + /** + * Relative path within the plugin's UI directory to the entry module + * (e.g. `"index.js"`). The frontend constructs the full import URL as + * `/_plugins/${pluginId}/ui/${uiEntryFile}`. + */ + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +}; + +/** Request body for POST /api/plugins/install */ +interface PluginInstallRequest { + /** npm package name (e.g., @paperclip/plugin-linear) or local path */ + packageName: string; + /** Target version for npm packages (optional, defaults to latest) */ + version?: string; + /** True if packageName is a local filesystem path */ + isLocalPath?: boolean; +} + +interface AvailablePluginExample { + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: "example"; +} + +/** Response body for GET /api/plugins/:pluginId/health */ +interface PluginHealthCheckResult { + pluginId: string; + status: string; + healthy: boolean; + checks: Array<{ + name: string; + passed: boolean; + message?: string; + }>; + lastError?: string; +} + +/** UUID v4 regex used for plugin ID route resolution. */ +const UUID_REGEX = + /^[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 __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../../.."); + +const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ + { + packageName: "@paperclipai/plugin-hello-world-example", + pluginKey: "paperclip.hello-world-example", + displayName: "Hello World Widget (Example)", + description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", + localPath: "packages/plugins/examples/plugin-hello-world-example", + tag: "example", + }, + { + packageName: "@paperclipai/plugin-file-browser-example", + pluginKey: "paperclip-file-browser-example", + displayName: "File Browser (Example)", + description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.", + localPath: "packages/plugins/examples/plugin-file-browser-example", + tag: "example", + }, +]; + +function listBundledPluginExamples(): AvailablePluginExample[] { + return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => { + const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath); + if (!existsSync(absoluteLocalPath)) return []; + return [{ ...plugin, localPath: absoluteLocalPath }]; + }); +} + +/** + * Resolve a plugin by either database ID or plugin key. + * + * Lookup order: + * - UUID-like IDs: getById first, then getByKey. + * - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById. + * - Other non-UUID IDs: try getById first (test/memory registries may allow this), + * then fallback to getByKey. Any UUID parse error from getById is ignored. + * + * @param registry - The plugin registry service instance + * @param pluginId - Either a database UUID or plugin key (manifest id) + * @returns Plugin record or null if not found + */ +async function resolvePlugin( + registry: ReturnType, + pluginId: string, +) { + const isUuid = UUID_REGEX.test(pluginId); + const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/"); + + // Scoped package IDs are valid plugin keys but invalid UUIDs. + // Skip getById() entirely to avoid Postgres uuid parse errors. + if (isScopedPackageKey && !isUuid) { + return registry.getByKey(pluginId); + } + + try { + const byId = await registry.getById(pluginId); + if (byId) return byId; + } catch (error) { + const maybeCode = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + // Ignore invalid UUID cast errors and continue with key lookup. + if (maybeCode !== "22P02") { + throw error; + } + } + + return registry.getByKey(pluginId); +} + +async function isPluginAvailableForCompany( + registry: ReturnType, + companyId: string, + pluginId: string, +): Promise { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + return availability?.available === true; +} + +/** + * Optional dependencies for plugin job scheduling routes. + * + * When provided, job-related routes (list jobs, list runs, trigger job) are + * mounted. When omitted, the routes return 501 Not Implemented. + */ +export interface PluginRouteJobDeps { + /** The job scheduler instance. */ + scheduler: PluginJobScheduler; + /** The job persistence store. */ + jobStore: PluginJobStore; +} + +/** + * Optional dependencies for plugin webhook routes. + * + * When provided, the webhook ingestion route is enabled. When omitted, + * webhook POST requests return 501 Not Implemented. + */ +export interface PluginRouteWebhookDeps { + /** The worker manager for dispatching handleWebhook RPC calls. */ + workerManager: PluginWorkerManager; +} + +/** + * Optional dependencies for plugin tool routes. + * + * When provided, tool discovery and execution routes are enabled. + * When omitted, the tool routes return 501 Not Implemented. + */ +export interface PluginRouteToolDeps { + /** The tool dispatcher for listing and executing plugin tools. */ + toolDispatcher: PluginToolDispatcher; +} + +/** + * Optional dependencies for plugin UI bridge routes. + * + * When provided, the getData and performAction bridge proxy routes are enabled, + * allowing plugin UI components to communicate with their worker backend via + * `usePluginData()` and `usePluginAction()` hooks. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export interface PluginRouteBridgeDeps { + /** The worker manager for dispatching getData/performAction RPC calls. */ + workerManager: PluginWorkerManager; + /** Optional stream bus for SSE push from worker to UI. */ + streamBus?: PluginStreamBus; +} + +/** Request body for POST /api/plugins/tools/execute */ +interface PluginToolExecuteRequest { + /** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */ + tool: string; + /** Parameters matching the tool's declared JSON Schema. */ + parameters?: unknown; + /** Agent run context. */ + runContext: ToolRunContext; +} + +/** + * Create Express router for plugin management API. + * + * Routes provided: + * + * | Method | Path | Description | + * |--------|------|-------------| + * | GET | /plugins | List all plugins (optional ?status= filter) | + * | GET | /plugins/ui-contributions | Get UI slots from ready plugins | + * | GET | /plugins/:pluginId | Get single plugin by ID or key | + * | POST | /plugins/install | Install from npm or local path | + * | DELETE | /plugins/:pluginId | Uninstall (optional ?purge=true) | + * | POST | /plugins/:pluginId/enable | Enable a plugin | + * | POST | /plugins/:pluginId/disable | Disable a plugin | + * | GET | /plugins/:pluginId/health | Run health diagnostics | + * | POST | /plugins/:pluginId/upgrade | Upgrade to newer version | + * | GET | /plugins/:pluginId/jobs | List jobs for a plugin | + * | GET | /plugins/:pluginId/jobs/:jobId/runs | List runs for a job | + * | POST | /plugins/:pluginId/jobs/:jobId/trigger | Manually trigger a job | + * | POST | /plugins/:pluginId/webhooks/:endpointKey | Receive inbound webhook | + * | GET | /plugins/tools | List all available plugin tools | + * | GET | /plugins/tools?pluginId=... | List tools for a specific plugin | + * | POST | /plugins/tools/execute | Execute a plugin tool | + * | GET | /plugins/:pluginId/config | Get current plugin config | + * | POST | /plugins/:pluginId/config | Save (upsert) plugin config | + * | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC | + * | GET | /companies/:companyId/plugins | List company-scoped plugin availability | + * | GET | /companies/:companyId/plugins/:pluginId | Get company-scoped plugin availability | + * | PUT | /companies/:companyId/plugins/:pluginId | Save company-scoped plugin availability/settings | + * | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker | + * | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker | + * | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) | + * | POST | /plugins/:pluginId/actions/:key | Proxy performAction to plugin worker (key in URL) | + * | GET | /plugins/:pluginId/bridge/stream/:channel | SSE stream from worker to UI | + * | GET | /plugins/:pluginId/dashboard | Aggregated health dashboard data | + * + * **Route Ordering Note:** Static routes (like /ui-contributions, /tools) must be + * registered before parameterized routes (like /:pluginId) to prevent Express from + * matching them as a plugin ID. + * + * @param db - Database connection instance + * @param jobDeps - Optional job scheduling dependencies + * @param webhookDeps - Optional webhook ingestion dependencies + * @param toolDeps - Optional tool dispatcher dependencies + * @param bridgeDeps - Optional bridge proxy dependencies for getData/performAction + * @returns Express router with plugin routes mounted + */ +export function pluginRoutes( + db: Db, + loader: ReturnType, + jobDeps?: PluginRouteJobDeps, + webhookDeps?: PluginRouteWebhookDeps, + toolDeps?: PluginRouteToolDeps, + bridgeDeps?: PluginRouteBridgeDeps, +) { + const router = Router(); + const registry = pluginRegistryService(db); + const lifecycle = pluginLifecycleManager(db, { + loader, + workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, + }); + + async function resolvePluginAuditCompanyIds(req: Request): Promise { + if (typeof (db as { select?: unknown }).select === "function") { + const rows = await db + .select({ id: companies.id }) + .from(companies); + return rows.map((row) => row.id); + } + + if (req.actor.type === "agent" && req.actor.companyId) { + return [req.actor.companyId]; + } + + if (req.actor.type === "board") { + return req.actor.companyIds ?? []; + } + + return []; + } + + async function logPluginMutationActivity( + req: Request, + action: string, + entityId: string, + details: Record, + ): Promise { + const companyIds = await resolvePluginAuditCompanyIds(req); + if (companyIds.length === 0) return; + + const actor = getActorInfo(req); + await Promise.all(companyIds.map((companyId) => + logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action, + entityType: "plugin", + entityId, + details, + }))); + } + + /** + * GET /api/plugins + * + * List all installed plugins, optionally filtered by lifecycle status. + * + * Query params: + * - `status` (optional): Filter by lifecycle status. Must be one of the + * values in `PLUGIN_STATUSES` (`installed`, `ready`, `error`, + * `upgrade_pending`, `uninstalled`). Returns HTTP 400 if the value is + * not a recognised status string. + * + * Response: `PluginRecord[]` + */ + router.get("/plugins", async (req, res) => { + assertBoard(req); + const rawStatus = req.query.status; + if (rawStatus !== undefined) { + if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { + res.status(400).json({ + error: `Invalid status '${String(rawStatus)}'. Must be one of: ${PLUGIN_STATUSES.join(", ")}`, + }); + return; + } + } + const status = rawStatus as PluginStatus | undefined; + const plugins = status + ? await registry.listByStatus(status) + : await registry.listInstalled(); + res.json(plugins); + }); + + /** + * GET /api/plugins/examples + * + * Return first-party example plugins bundled in this repo, if present. + * These can be installed through the normal local-path install flow. + */ + router.get("/plugins/examples", async (req, res) => { + assertBoard(req); + res.json(listBundledPluginExamples()); + }); + + // IMPORTANT: Static routes must come before parameterized routes + // to avoid Express matching "ui-contributions" as a :pluginId + + /** + * GET /api/plugins/ui-contributions + * + * Return UI contributions from all plugins in 'ready' state. + * Used by the frontend to discover plugin UI slots and launcher metadata. + * + * The response is normalized for the frontend slot host: + * - Only includes plugins with at least one declared UI slot or launcher + * - Excludes plugins with null/missing manifestJson (defensive) + * - Slots are extracted from manifest.ui.slots + * - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers + * + * Query params: + * - `companyId` (optional): filters out plugins disabled for the target + * company and applies `assertCompanyAccess` + * + * Example response: + * ```json + * [ + * { + * "pluginId": "plg_123", + * "pluginKey": "paperclip.claude-usage", + * "displayName": "Claude Usage", + * "version": "1.0.0", + * "uiEntryFile": "index.js", + * "slots": [], + * "launchers": [ + * { + * "id": "claude-usage-toolbar", + * "displayName": "Claude Usage", + * "placementZone": "toolbarButton", + * "action": { "type": "openModal", "target": "ClaudeUsageView" }, + * "render": { "environment": "hostOverlay", "bounds": "wide" } + * } + * ] + * } + * ] + * ``` + * + * Response: PluginUiContribution[] + */ + router.get("/plugins/ui-contributions", async (req, res) => { + assertBoard(req); + const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; + if (companyId) { + assertCompanyAccess(req, companyId); + } + + const plugins = await registry.listByStatus("ready"); + const availablePluginIds = companyId + ? new Set( + (await registry.listCompanyAvailability(companyId, { available: true })) + .map((entry) => entry.pluginId), + ) + : null; + + const contributions: PluginUiContribution[] = plugins + .filter((plugin) => availablePluginIds === null || availablePluginIds.has(plugin.id)) + .map((plugin) => { + // Safety check: manifestJson should always exist for ready plugins, but guard against null + const manifest = plugin.manifestJson; + if (!manifest) return null; + + const uiMetadata = getPluginUiContributionMetadata(manifest); + if (!uiMetadata) return null; + + return { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + displayName: manifest.displayName, + version: plugin.version, + updatedAt: plugin.updatedAt.toISOString(), + uiEntryFile: uiMetadata.uiEntryFile, + slots: uiMetadata.slots, + launchers: uiMetadata.launchers, + }; + }) + .filter((item): item is PluginUiContribution => item !== null); + res.json(contributions); + }); + + // =========================================================================== + // Company-scoped plugin settings / availability routes + // =========================================================================== + + /** + * GET /api/companies/:companyId/plugins + * + * List every installed plugin as it applies to a specific company. Plugins + * are enabled by default; rows in `plugin_company_settings` only store + * explicit overrides and any company-scoped settings payload. + * + * Query params: + * - `available` (optional): `true` or `false` filter + */ + router.get("/companies/:companyId/plugins", async (req, res) => { + assertBoard(req); + const { companyId } = req.params; + assertCompanyAccess(req, companyId); + + let available: boolean | undefined; + const rawAvailable = req.query.available; + if (rawAvailable !== undefined) { + if (rawAvailable === "true") available = true; + else if (rawAvailable === "false") available = false; + else { + res.status(400).json({ error: '"available" must be "true" or "false"' }); + return; + } + } + + const result = await registry.listCompanyAvailability(companyId, { available }); + res.json(result); + }); + + /** + * GET /api/companies/:companyId/plugins/:pluginId + * + * Resolve one plugin's effective availability for a company, whether that + * result comes from the default-enabled baseline or a persisted override row. + */ + router.get("/companies/:companyId/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { companyId, pluginId } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin || plugin.status === "uninstalled") { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const result = await registry.getCompanyAvailability(companyId, plugin.id); + if (!result) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + res.json(result); + }); + + /** + * PUT /api/companies/:companyId/plugins/:pluginId + * + * Persist a company-scoped availability override. This never changes the + * instance-wide install state of the plugin; it only controls whether the + * selected company can see UI contributions and invoke plugin-backed actions. + */ + router.put("/companies/:companyId/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { companyId, pluginId } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin || plugin.status === "uninstalled") { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const parsed = updateCompanyPluginAvailabilitySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid request body" }); + return; + } + + try { + const result = await registry.updateCompanyAvailability( + companyId, + plugin.id, + parsed.data as UpdateCompanyPluginAvailability, + ); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "plugin.company_settings.updated", + entityType: "plugin_company_settings", + entityId: `${companyId}:${plugin.id}`, + details: { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + available: result.available, + settingsJson: result.settingsJson, + lastError: result.lastError, + }, + }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Tool discovery and execution routes + // =========================================================================== + + /** + * GET /api/plugins/tools + * + * List all available plugin-contributed tools in an agent-friendly format. + * + * Query params: + * - `pluginId` (optional): Filter to tools from a specific plugin + * + * Response: `AgentToolDescriptor[]` + * Errors: 501 if tool dispatcher is not configured + */ + router.get("/plugins/tools", async (req, res) => { + assertBoard(req); + + if (!toolDeps) { + res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); + return; + } + + const pluginId = req.query.pluginId as string | undefined; + const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; + if (companyId) { + assertCompanyAccess(req, companyId); + } + + const filter = pluginId ? { pluginId } : undefined; + const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); + if (!companyId) { + res.json(tools); + return; + } + + const availablePluginIds = new Set( + (await registry.listCompanyAvailability(companyId, { available: true })) + .map((entry) => entry.pluginId), + ); + res.json(tools.filter((tool) => availablePluginIds.has(tool.pluginId))); + }); + + /** + * Reject company-scoped plugin access when the plugin is disabled for the + * target company. This guard is reused across UI bridge and tool execution + * endpoints so every runtime surface honors the same availability rule. + */ + async function enforceCompanyPluginAvailability( + companyId: string, + pluginId: string, + res: Response, + ): Promise { + if (!await isPluginAvailableForCompany(registry, companyId, pluginId)) { + res.status(403).json({ + error: `Plugin "${pluginId}" is not enabled for company "${companyId}"`, + }); + return false; + } + + return true; + } + + /** + * POST /api/plugins/tools/execute + * + * Execute a plugin-contributed tool by its namespaced name. + * + * This is the primary endpoint used by the agent service to invoke + * plugin tools during an agent run. + * + * Request body: + * - `tool`: Fully namespaced tool name (e.g., "acme.linear:search-issues") + * - `parameters`: Parameters matching the tool's declared JSON Schema + * - `runContext`: Agent run context with agentId, runId, companyId, projectId + * + * Response: `ToolExecutionResult` + * Errors: + * - 400 if request validation fails + * - 404 if tool is not found + * - 501 if tool dispatcher is not configured + * - 502 if the plugin worker is unavailable or the RPC call fails + */ + router.post("/plugins/tools/execute", async (req, res) => { + assertBoard(req); + + if (!toolDeps) { + res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); + return; + } + + const body = (req.body as PluginToolExecuteRequest | undefined); + if (!body) { + res.status(400).json({ error: "Request body is required" }); + return; + } + + const { tool, parameters, runContext } = body; + + // Validate required fields + if (!tool || typeof tool !== "string") { + res.status(400).json({ error: '"tool" is required and must be a string' }); + return; + } + + if (!runContext || typeof runContext !== "object") { + res.status(400).json({ error: '"runContext" is required and must be an object' }); + return; + } + + if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) { + res.status(400).json({ + error: '"runContext" must include agentId, runId, companyId, and projectId', + }); + return; + } + + assertCompanyAccess(req, runContext.companyId); + + // Verify the tool exists + const registeredTool = toolDeps.toolDispatcher.getTool(tool); + if (!registeredTool) { + res.status(404).json({ error: `Tool "${tool}" not found` }); + return; + } + + if (!await enforceCompanyPluginAvailability(runContext.companyId, registeredTool.pluginDbId, res)) { + return; + } + + try { + const result = await toolDeps.toolDispatcher.executeTool( + tool, + parameters ?? {}, + runContext, + ); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + // Distinguish between "worker not running" (502) and other errors (500) + if (message.includes("not running") || message.includes("worker")) { + res.status(502).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } + }); + + /** + * POST /api/plugins/install + * + * Install a plugin from npm or a local filesystem path. + * + * Request body: + * - packageName: npm package name or local path (required) + * - version: Target version for npm packages (optional) + * - isLocalPath: Set true if packageName is a local path + * + * The installer: + * 1. Downloads from npm or loads from local path + * 2. Validates the manifest (schema + capability consistency) + * 3. Registers in the database + * 4. Transitions to `ready` state if no new capability approval is needed + * + * Response: `PluginRecord` + * + * Errors: + * - `400` — validation failure or install error (package not found, bad manifest, etc.) + * - `500` — installation succeeded but manifest is missing (indicates a loader bug) + */ + router.post("/plugins/install", async (req, res) => { + assertBoard(req); + const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; + + // Input validation + if (!packageName || typeof packageName !== "string") { + res.status(400).json({ error: "packageName is required and must be a string" }); + return; + } + + if (version !== undefined && typeof version !== "string") { + res.status(400).json({ error: "version must be a string if provided" }); + return; + } + + if (isLocalPath !== undefined && typeof isLocalPath !== "boolean") { + res.status(400).json({ error: "isLocalPath must be a boolean if provided" }); + return; + } + + // Validate package name format + const trimmedPackage = packageName.trim(); + if (trimmedPackage.length === 0) { + res.status(400).json({ error: "packageName cannot be empty" }); + return; + } + + // Basic security check for package name (prevent injection) + if (!isLocalPath && /[<>:"|?*]/.test(trimmedPackage)) { + res.status(400).json({ error: "packageName contains invalid characters" }); + return; + } + + try { + const installOptions = isLocalPath + ? { localPath: trimmedPackage } + : { packageName: trimmedPackage, version: version?.trim() }; + + const discovered = await loader.installPlugin(installOptions); + + if (!discovered.manifest) { + res.status(500).json({ error: "Plugin installed but manifest is missing" }); + return; + } + + // Transition to ready state + const existingPlugin = await registry.getByKey(discovered.manifest.id); + if (existingPlugin) { + await lifecycle.load(existingPlugin.id); + // Plugins should be enabled by default for all companies after install. + // Best-effort: default behavior is still enabled when no row exists. + try { + await registry.seedEnabledForAllCompanies(existingPlugin.id); + } catch { + // no-op + } + const updated = await registry.getById(existingPlugin.id); + await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { + pluginId: existingPlugin.id, + pluginKey: existingPlugin.pluginKey, + packageName: updated?.packageName ?? existingPlugin.packageName, + version: updated?.version ?? existingPlugin.version, + source: isLocalPath ? "local_path" : "npm", + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: existingPlugin.id, action: "installed" } }); + res.json(updated); + } else { + // This shouldn't happen since installPlugin already registers in the DB + res.status(500).json({ error: "Plugin installed but not found in registry" }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // UI Bridge proxy routes (getData / performAction) + // =========================================================================== + + /** Request body for POST /api/plugins/:pluginId/bridge/data */ + interface PluginBridgeDataRequest { + /** Plugin-defined data key (e.g. `"sync-health"`). */ + key: string; + /** Optional company scope for enforcing company plugin availability. */ + companyId?: string; + /** Optional context and query parameters from the UI. */ + params?: Record; + /** Optional host launcher/render metadata for the worker bridge call. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } + + /** Request body for POST /api/plugins/:pluginId/bridge/action */ + interface PluginBridgeActionRequest { + /** Plugin-defined action key (e.g. `"resync"`). */ + key: string; + /** Optional company scope for enforcing company plugin availability. */ + companyId?: string; + /** Optional parameters from the UI. */ + params?: Record; + /** Optional host launcher/render metadata for the worker bridge call. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } + + /** Response envelope for bridge errors. */ + interface PluginBridgeErrorResponse { + code: PluginBridgeErrorCode; + message: string; + details?: unknown; + } + + /** + * Map a worker RPC error to a bridge-level error code. + * + * JsonRpcCallError carries numeric codes from the plugin RPC error code space. + * This helper maps them to the string error codes defined in PluginBridgeErrorCode. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + function mapRpcErrorToBridgeError(err: unknown): PluginBridgeErrorResponse { + if (err instanceof JsonRpcCallError) { + switch (err.code) { + case PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE: + return { + code: "WORKER_UNAVAILABLE", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED: + return { + code: "CAPABILITY_DENIED", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.TIMEOUT: + return { + code: "TIMEOUT", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.WORKER_ERROR: + return { + code: "WORKER_ERROR", + message: err.message, + details: err.data, + }; + default: + return { + code: "UNKNOWN", + message: err.message, + details: err.data, + }; + } + } + + const message = err instanceof Error ? err.message : String(err); + + // Worker not running — surface as WORKER_UNAVAILABLE + if (message.includes("not running") || message.includes("not registered")) { + return { + code: "WORKER_UNAVAILABLE", + message, + }; + } + + return { + code: "UNKNOWN", + message, + }; + } + + /** + * POST /api/plugins/:pluginId/bridge/data + * + * Proxy a `getData` call from the plugin UI to the plugin worker. + * + * This is the server-side half of the `usePluginData(key, params)` bridge hook. + * The frontend sends a POST with the data key and optional params; the host + * forwards the call to the worker via the `getData` RPC method and returns + * the result. + * + * Request body: + * - `key`: Plugin-defined data key (e.g. `"sync-health"`) + * - `params`: Optional query parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `getData` handler + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/bridge/data", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + // Validate request body + const body = req.body as PluginBridgeDataRequest | undefined; + if (!body || !body.key || typeof body.key !== "string") { + res.status(400).json({ error: '"key" is required and must be a string' }); + return; + } + + if (body.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "getData", + { + key: body.key, + params: body.params ?? {}, + renderEnvironment: body.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + /** + * POST /api/plugins/:pluginId/bridge/action + * + * Proxy a `performAction` call from the plugin UI to the plugin worker. + * + * This is the server-side half of the `usePluginAction(key)` bridge hook. + * The frontend sends a POST with the action key and optional params; the host + * forwards the call to the worker via the `performAction` RPC method and + * returns the result. + * + * Request body: + * - `key`: Plugin-defined action key (e.g. `"resync"`) + * - `params`: Optional parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `performAction` handler + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/bridge/action", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + // Validate request body + const body = req.body as PluginBridgeActionRequest | undefined; + if (!body || !body.key || typeof body.key !== "string") { + res.status(400).json({ error: '"key" is required and must be a string' }); + return; + } + + if (body.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "performAction", + { + key: body.key, + params: body.params ?? {}, + renderEnvironment: body.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // URL-keyed bridge routes (key as path parameter) + // =========================================================================== + + /** + * POST /api/plugins/:pluginId/data/:key + * + * Proxy a `getData` call from the plugin UI to the plugin worker, with the + * data key specified as a URL path parameter instead of in the request body. + * + * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/data`. + * The frontend bridge hooks use this endpoint for cleaner URLs. + * + * Request body (optional): + * - `params`: Optional query parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `getData` handler wrapped as `{ data: T }` + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/data/:key", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId, key } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + const body = req.body as { + companyId?: string; + params?: Record; + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } | undefined; + + if (body?.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "getData", + { + key, + params: body?.params ?? {}, + renderEnvironment: body?.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + /** + * POST /api/plugins/:pluginId/actions/:key + * + * Proxy a `performAction` call from the plugin UI to the plugin worker, with + * the action key specified as a URL path parameter instead of in the request body. + * + * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/action`. + * The frontend bridge hooks use this endpoint for cleaner URLs. + * + * Request body (optional): + * - `params`: Optional parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `performAction` handler wrapped as `{ data: T }` + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/actions/:key", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId, key } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + const body = req.body as { + companyId?: string; + params?: Record; + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } | undefined; + + if (body?.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "performAction", + { + key, + params: body?.params ?? {}, + renderEnvironment: body?.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // SSE stream bridge route + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/bridge/stream/:channel + * + * Server-Sent Events endpoint for real-time streaming from plugin worker to UI. + * + * The worker pushes events via `ctx.streams.emit(channel, event)` which arrive + * as JSON-RPC notifications to the host, get published on the PluginStreamBus, + * and are fanned out to all connected SSE clients matching (pluginId, channel, + * companyId). + * + * Query parameters: + * - `companyId` (required): Scope events to a specific company + * + * SSE event types: + * - `message`: A data event from the worker (default) + * - `open`: The worker opened the stream channel + * - `close`: The worker closed the stream channel — client should disconnect + * + * Errors: + * - 400 if companyId is missing + * - 404 if plugin not found + * - 501 if bridge deps or stream bus are not configured + */ + router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps?.streamBus) { + res.status(501).json({ error: "Plugin stream bridge is not enabled" }); + return; + } + + const { pluginId, channel } = req.params; + const companyId = req.query.companyId as string | undefined; + + if (!companyId) { + res.status(400).json({ error: '"companyId" query parameter is required' }); + return; + } + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + assertCompanyAccess(req, companyId); + + if (!await enforceCompanyPluginAvailability(companyId, plugin.id, res)) { + return; + } + + // Set SSE headers + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + + // Send initial comment to establish the connection + res.write(":ok\n\n"); + + let unsubscribed = false; + const safeUnsubscribe = () => { + if (!unsubscribed) { + unsubscribed = true; + unsubscribe(); + } + }; + + const unsubscribe = bridgeDeps.streamBus.subscribe( + plugin.id, + channel, + companyId, + (event, eventType) => { + if (unsubscribed || !res.writable) return; + try { + if (eventType !== "message") { + res.write(`event: ${eventType}\n`); + } + res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch { + // Connection closed or write error — stop delivering + safeUnsubscribe(); + } + }, + ); + + req.on("close", safeUnsubscribe); + res.on("error", safeUnsubscribe); + }); + + /** + * GET /api/plugins/:pluginId + * + * Get detailed information about a single plugin. + * + * The :pluginId parameter accepts either: + * - Database UUID (e.g., "abc123-def456") + * - Plugin key (e.g., "acme.linear") + * + * Response: PluginRecord + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Enrich with worker capabilities when available + const worker = bridgeDeps?.workerManager.getWorker(plugin.id); + const supportsConfigTest = worker + ? worker.supportedMethods.includes("validateConfig") + : false; + + res.json({ ...plugin, supportsConfigTest }); + }); + + /** + * DELETE /api/plugins/:pluginId + * + * Uninstall a plugin. + * + * Query params: + * - purge: If "true", permanently delete all plugin data (hard delete) + * Otherwise, soft-delete with 30-day data retention + * + * Response: PluginRecord (the deleted record) + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.delete("/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const purge = req.query.purge === "true"; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.unload(plugin.id, purge); + await logPluginMutationActivity(req, "plugin.uninstalled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + purge, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "uninstalled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/enable + * + * Enable a plugin that is currently disabled or in error state. + * + * Transitions the plugin to 'ready' state after loading and validation. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/enable", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.enable(plugin.id); + await logPluginMutationActivity(req, "plugin.enabled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + version: result?.version ?? plugin.version, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "enabled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/disable + * + * Disable a running plugin. + * + * Request body (optional): + * - reason: Human-readable reason for disabling + * + * The plugin transitions to 'installed' state and stops processing events. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/disable", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const body = req.body as { reason?: string } | undefined; + const reason = body?.reason; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.disable(plugin.id, reason); + await logPluginMutationActivity(req, "plugin.disabled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + reason: reason ?? null, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "disabled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * GET /api/plugins/:pluginId/health + * + * Run health diagnostics on a plugin. + * + * Performs the following checks: + * 1. Registry: Plugin is registered in the database + * 2. Manifest: Manifest is valid and parseable + * 3. Status: Plugin is in 'ready' state + * 4. Error state: Plugin has no unhandled errors + * + * Response: PluginHealthCheckResult + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/health", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const checks: PluginHealthCheckResult["checks"] = []; + + // Check 1: Plugin is registered + checks.push({ + name: "registry", + passed: true, + message: "Plugin found in registry", + }); + + // Check 2: Manifest is valid + const hasValidManifest = Boolean(plugin.manifestJson?.id); + checks.push({ + name: "manifest", + passed: hasValidManifest, + message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", + }); + + // Check 3: Plugin status + const isHealthy = plugin.status === "ready"; + checks.push({ + name: "status", + passed: isHealthy, + message: `Current status: ${plugin.status}`, + }); + + // Check 4: No last error + const hasNoError = !plugin.lastError; + if (!hasNoError) { + checks.push({ + name: "error_state", + passed: false, + message: plugin.lastError ?? undefined, + }); + } + + const result: PluginHealthCheckResult = { + pluginId: plugin.id, + status: plugin.status, + healthy: isHealthy && hasValidManifest && hasNoError, + checks, + lastError: plugin.lastError ?? undefined, + }; + + res.json(result); + }); + + /** + * GET /api/plugins/:pluginId/logs + * + * Query recent log entries for a plugin. + * + * Query params: + * - limit: Maximum number of entries (default 25, max 500) + * - level: Filter by log level (info, warn, error, debug) + * - since: ISO timestamp to filter logs newer than this time + * + * Response: Array of log entries, newest first. + */ + router.get("/plugins/:pluginId/logs", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500); + const level = req.query.level as string | undefined; + const since = req.query.since as string | undefined; + + const conditions = [eq(pluginLogs.pluginId, plugin.id)]; + if (level) { + conditions.push(eq(pluginLogs.level, level)); + } + if (since) { + const sinceDate = new Date(since); + if (!isNaN(sinceDate.getTime())) { + conditions.push(gte(pluginLogs.createdAt, sinceDate)); + } + } + + const rows = await db + .select() + .from(pluginLogs) + .where(and(...conditions)) + .orderBy(desc(pluginLogs.createdAt)) + .limit(limit); + + res.json(rows); + }); + + /** + * POST /api/plugins/:pluginId/upgrade + * + * Upgrade a plugin to a newer version. + * + * Request body (optional): + * - version: Target version (defaults to latest) + * + * If the upgrade adds new capabilities, the plugin transitions to + * 'upgrade_pending' state for board approval. Otherwise, it goes + * directly to 'ready'. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/upgrade", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const body = req.body as { version?: string } | undefined; + const version = body?.version; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + // Upgrade the plugin - this would typically: + // 1. Download the new version + // 2. Compare capabilities + // 3. If new capabilities, mark as upgrade_pending + // 4. Otherwise, transition to ready + const result = await lifecycle.upgrade(plugin.id, version); + await logPluginMutationActivity(req, "plugin.upgraded", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + previousVersion: plugin.version, + version: result?.version ?? plugin.version, + targetVersion: version ?? null, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "upgraded" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Plugin configuration routes + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/config + * + * Retrieve the current instance configuration for a plugin. + * + * Returns the `PluginConfig` record if one exists, or `null` if the plugin + * has not yet been configured. + * + * Response: `PluginConfig | null` + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/config", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const config = await registry.getConfig(plugin.id); + res.json(config); + }); + + /** + * POST /api/plugins/:pluginId/config + * + * Save (create or replace) the instance configuration for a plugin. + * + * The caller provides the full `configJson` object. The server persists it + * via `registry.upsertConfig()`. + * + * Request body: + * - `configJson`: Configuration values matching the plugin's `instanceConfigSchema` + * + * Response: `PluginConfig` + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + */ + router.post("/plugins/:pluginId/config", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const body = req.body as { configJson?: Record } | undefined; + if (!body?.configJson || typeof body.configJson !== "object") { + res.status(400).json({ error: '"configJson" is required and must be an object' }); + return; + } + + // Strip devUiUrl unless the caller is an instance admin. devUiUrl activates + // a dev-proxy in the static file route that could be abused for SSRF if any + // board-level user were allowed to set it. + if ( + "devUiUrl" in body.configJson && + !(req.actor.type === "board" && req.actor.isInstanceAdmin) + ) { + delete body.configJson.devUiUrl; + } + + // Validate configJson against the plugin's instanceConfigSchema (if declared). + // This ensures CLI/API callers get the same validation the UI performs client-side. + const schema = plugin.manifestJson?.instanceConfigSchema; + if (schema && Object.keys(schema).length > 0) { + const validation = validateInstanceConfig(body.configJson, schema); + if (!validation.valid) { + res.status(400).json({ + error: "Configuration does not match the plugin's instanceConfigSchema", + fieldErrors: validation.errors, + }); + return; + } + } + + try { + const result = await registry.upsertConfig(plugin.id, { + configJson: body.configJson, + }); + await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + configKeyCount: Object.keys(body.configJson).length, + }); + + // Notify the running worker about the config change (PLUGIN_SPEC §25.4.4). + // If the worker implements onConfigChanged, send the new config via RPC. + // If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks + // up the new config on re-initialize. If no worker is running, skip. + if (bridgeDeps?.workerManager.isRunning(plugin.id)) { + try { + await bridgeDeps.workerManager.call( + plugin.id, + "configChanged", + { config: body.configJson }, + ); + } catch (rpcErr) { + if ( + rpcErr instanceof JsonRpcCallError && + rpcErr.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED + ) { + // Worker doesn't handle live config — restart it. + try { + await lifecycle.restartWorker(plugin.id); + } catch { + // Restart failure is non-fatal for the config save response. + } + } + // Other RPC errors (timeout, unavailable) are non-fatal — config is + // already persisted and will take effect on next worker restart. + } + } + + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/config/test + * + * Test a plugin configuration without persisting it by calling the plugin + * worker's `validateConfig` RPC method. + * + * Only works when the plugin's worker implements `onValidateConfig`. + * If the worker does not implement the method, returns + * `{ valid: false, supported: false, message: "..." }` with HTTP 200. + * + * Request body: + * - `configJson`: Configuration values to validate + * + * Response: `{ valid: boolean; message?: string; supported?: boolean }` + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps (worker manager) are not configured + * - 502 if the worker is unavailable + */ + router.post("/plugins/:pluginId/config/test", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + if (plugin.status !== "ready") { + res.status(400).json({ + error: `Plugin is not ready (current status: ${plugin.status})`, + }); + return; + } + + const body = req.body as { configJson?: Record } | undefined; + if (!body?.configJson || typeof body.configJson !== "object") { + res.status(400).json({ error: '"configJson" is required and must be an object' }); + return; + } + + // Fast schema-level rejection before hitting the worker RPC. + const schema = plugin.manifestJson?.instanceConfigSchema; + if (schema && Object.keys(schema).length > 0) { + const validation = validateInstanceConfig(body.configJson, schema); + if (!validation.valid) { + res.status(400).json({ + error: "Configuration does not match the plugin's instanceConfigSchema", + fieldErrors: validation.errors, + }); + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "validateConfig", + { config: body.configJson }, + ); + + // The worker returns PluginConfigValidationResult { ok, warnings?, errors? } + // Map to the frontend-expected shape { valid, message? } + if (result.ok) { + const warningText = result.warnings?.length + ? `Warnings: ${result.warnings.join("; ")}` + : undefined; + res.json({ valid: true, message: warningText }); + } else { + const errorText = result.errors?.length + ? result.errors.join("; ") + : "Configuration validation failed."; + res.json({ valid: false, message: errorText }); + } + } catch (err) { + // If the worker does not implement validateConfig, return a structured response + if ( + err instanceof JsonRpcCallError && + err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED + ) { + res.json({ + valid: false, + supported: false, + message: "This plugin does not support configuration testing.", + }); + return; + } + + // Worker unavailable or other RPC errors + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // Job scheduling routes + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/jobs + * + * List all scheduled jobs for a plugin. + * + * Query params: + * - `status` (optional): Filter by job status (`active`, `paused`, `failed`) + * + * Response: PluginJobRecord[] + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/jobs", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const rawStatus = req.query.status as string | undefined; + const validStatuses = ["active", "paused", "failed"]; + if (rawStatus !== undefined && !validStatuses.includes(rawStatus)) { + res.status(400).json({ + error: `Invalid status '${rawStatus}'. Must be one of: ${validStatuses.join(", ")}`, + }); + return; + } + + try { + const jobs = await jobDeps.jobStore.listJobs( + plugin.id, + rawStatus as "active" | "paused" | "failed" | undefined, + ); + res.json(jobs); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } + }); + + /** + * GET /api/plugins/:pluginId/jobs/:jobId/runs + * + * List execution history for a specific job. + * + * Query params: + * - `limit` (optional): Maximum number of runs to return (default: 50) + * + * Response: PluginJobRunRecord[] + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId, jobId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); + if (!job) { + res.status(404).json({ error: "Job not found" }); + return; + } + + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 25; + if (isNaN(limit) || limit < 1 || limit > 500) { + res.status(400).json({ error: "limit must be a number between 1 and 500" }); + return; + } + + try { + const runs = await jobDeps.jobStore.listRunsByJob(jobId, limit); + res.json(runs); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/jobs/:jobId/trigger + * + * Manually trigger a job execution outside its cron schedule. + * + * Creates a run with `trigger: "manual"` and dispatches immediately. + * The response returns before the job completes (non-blocking). + * + * Response: `{ runId: string, jobId: string }` + * Errors: + * - 404 if plugin not found + * - 400 if job not found, not active, already running, or worker unavailable + */ + router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId, jobId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); + if (!job) { + res.status(404).json({ error: "Job not found" }); + return; + } + + try { + const result = await jobDeps.scheduler.triggerJob(jobId, "manual"); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Webhook ingestion route + // =========================================================================== + + /** + * POST /api/plugins/:pluginId/webhooks/:endpointKey + * + * Receive an inbound webhook delivery for a plugin. + * + * This route is called by external systems (e.g. GitHub, Linear, Stripe) to + * deliver webhook payloads to a plugin. The host validates that: + * 1. The plugin exists and is in 'ready' state + * 2. The plugin declares the `webhooks.receive` capability + * 3. The `endpointKey` matches a declared webhook in the manifest + * + * The delivery is recorded in the `plugin_webhook_deliveries` table and + * dispatched to the worker via the `handleWebhook` RPC method. + * + * **Note:** This route does NOT require board authentication — webhook + * endpoints must be publicly accessible for external callers. Signature + * verification is the plugin's responsibility. + * + * Response: `{ deliveryId: string, status: string }` + * Errors: + * - 404 if plugin not found or endpointKey not declared + * - 400 if plugin is not in ready state or lacks webhooks.receive capability + * - 502 if the worker is unavailable or the RPC call fails + */ + router.post("/plugins/:pluginId/webhooks/:endpointKey", async (req, res) => { + if (!webhookDeps) { + res.status(501).json({ error: "Webhook ingestion is not enabled" }); + return; + } + + const { pluginId, endpointKey } = req.params; + + // Step 1: Resolve the plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Step 2: Validate the plugin is in 'ready' state + if (plugin.status !== "ready") { + res.status(400).json({ + error: `Plugin is not ready (current status: ${plugin.status})`, + }); + return; + } + + // Step 3: Validate the plugin has webhooks.receive capability + const manifest = plugin.manifestJson; + if (!manifest) { + res.status(400).json({ error: "Plugin manifest is missing" }); + return; + } + + const capabilities = manifest.capabilities ?? []; + if (!capabilities.includes("webhooks.receive")) { + res.status(400).json({ + error: "Plugin does not have the webhooks.receive capability", + }); + return; + } + + // Step 4: Validate the endpointKey exists in the manifest's webhook declarations + const declaredWebhooks = manifest.webhooks ?? []; + const webhookDecl = declaredWebhooks.find( + (w) => w.endpointKey === endpointKey, + ); + if (!webhookDecl) { + res.status(404).json({ + error: `Webhook endpoint '${endpointKey}' is not declared by this plugin`, + }); + return; + } + + // Step 5: Extract request data + const requestId = randomUUID(); + const rawHeaders: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + rawHeaders[key] = value; + } else if (Array.isArray(value)) { + rawHeaders[key] = value.join(", "); + } + } + + // Use the raw buffer stashed by the express.json() `verify` callback. + // This preserves the exact bytes the provider signed, whereas + // JSON.stringify(req.body) would re-serialize and break HMAC verification. + const stashedRaw = (req as unknown as { rawBody?: Buffer }).rawBody; + const rawBody = stashedRaw ? stashedRaw.toString("utf-8") : ""; + const parsedBody = req.body as unknown; + const payload = (req.body as Record | undefined) ?? {}; + + // Step 6: Record the delivery in the database + const startedAt = new Date(); + const [delivery] = await db + .insert(pluginWebhookDeliveries) + .values({ + pluginId: plugin.id, + webhookKey: endpointKey, + status: "pending", + payload, + headers: rawHeaders, + startedAt, + }) + .returning({ id: pluginWebhookDeliveries.id }); + + // Step 7: Dispatch to the worker via handleWebhook RPC + try { + await webhookDeps.workerManager.call(plugin.id, "handleWebhook", { + endpointKey, + headers: req.headers as Record, + rawBody, + parsedBody, + requestId, + }); + + // Step 8: Update delivery record to success + const finishedAt = new Date(); + const durationMs = finishedAt.getTime() - startedAt.getTime(); + await db + .update(pluginWebhookDeliveries) + .set({ + status: "success", + durationMs, + finishedAt, + }) + .where(eq(pluginWebhookDeliveries.id, delivery.id)); + + res.status(200).json({ + deliveryId: delivery.id, + status: "success", + }); + } catch (err) { + // Step 8 (error): Update delivery record to failed + const finishedAt = new Date(); + const durationMs = finishedAt.getTime() - startedAt.getTime(); + const errorMessage = err instanceof Error ? err.message : String(err); + + await db + .update(pluginWebhookDeliveries) + .set({ + status: "failed", + durationMs, + error: errorMessage, + finishedAt, + }) + .where(eq(pluginWebhookDeliveries.id, delivery.id)); + + res.status(502).json({ + deliveryId: delivery.id, + status: "failed", + error: errorMessage, + }); + } + }); + + // =========================================================================== + // Plugin health dashboard — aggregated diagnostics for the settings page + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/dashboard + * + * Aggregated health dashboard data for a plugin's settings page. + * + * Returns worker diagnostics (status, uptime, crash history), recent job + * runs, recent webhook deliveries, and the current health check result — + * all in a single response to avoid multiple round-trips. + * + * Response: PluginDashboardData + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/dashboard", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // --- Worker diagnostics --- + let worker: { + status: string; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; + } | null = null; + + // Try bridgeDeps first (primary source for worker manager), fallback to webhookDeps + const wm = bridgeDeps?.workerManager ?? webhookDeps?.workerManager ?? null; + if (wm) { + const handle = wm.getWorker(plugin.id); + if (handle) { + const diag = handle.diagnostics(); + worker = { + status: diag.status, + pid: diag.pid, + uptime: diag.uptime, + consecutiveCrashes: diag.consecutiveCrashes, + totalCrashes: diag.totalCrashes, + pendingRequests: diag.pendingRequests, + lastCrashAt: diag.lastCrashAt, + nextRestartAt: diag.nextRestartAt, + }; + } + } + + // --- Recent job runs (last 10, newest first) --- + let recentJobRuns: Array<{ + id: string; + jobId: string; + jobKey?: string; + trigger: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + }> = []; + + if (jobDeps) { + try { + const runs = await jobDeps.jobStore.listRunsByPlugin(plugin.id, undefined, 10); + // Also fetch job definitions so we can include jobKey + const jobs = await jobDeps.jobStore.listJobs(plugin.id); + const jobKeyMap = new Map(jobs.map((j) => [j.id, j.jobKey])); + + recentJobRuns = runs + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((r) => ({ + id: r.id, + jobId: r.jobId, + jobKey: jobKeyMap.get(r.jobId) ?? undefined, + trigger: r.trigger, + status: r.status, + durationMs: r.durationMs, + error: r.error, + startedAt: r.startedAt ? new Date(r.startedAt).toISOString() : null, + finishedAt: r.finishedAt ? new Date(r.finishedAt).toISOString() : null, + createdAt: new Date(r.createdAt).toISOString(), + })); + } catch { + // Job data unavailable — leave empty + } + } + + // --- Recent webhook deliveries (last 10, newest first) --- + let recentWebhookDeliveries: Array<{ + id: string; + webhookKey: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + }> = []; + + try { + const deliveries = await db + .select({ + id: pluginWebhookDeliveries.id, + webhookKey: pluginWebhookDeliveries.webhookKey, + status: pluginWebhookDeliveries.status, + durationMs: pluginWebhookDeliveries.durationMs, + error: pluginWebhookDeliveries.error, + startedAt: pluginWebhookDeliveries.startedAt, + finishedAt: pluginWebhookDeliveries.finishedAt, + createdAt: pluginWebhookDeliveries.createdAt, + }) + .from(pluginWebhookDeliveries) + .where(eq(pluginWebhookDeliveries.pluginId, plugin.id)) + .orderBy(desc(pluginWebhookDeliveries.createdAt)) + .limit(10); + + recentWebhookDeliveries = deliveries.map((d) => ({ + id: d.id, + webhookKey: d.webhookKey, + status: d.status, + durationMs: d.durationMs, + error: d.error, + startedAt: d.startedAt ? d.startedAt.toISOString() : null, + finishedAt: d.finishedAt ? d.finishedAt.toISOString() : null, + createdAt: d.createdAt.toISOString(), + })); + } catch { + // Webhook data unavailable — leave empty + } + + // --- Health check (same logic as GET /health) --- + const checks: PluginHealthCheckResult["checks"] = []; + + checks.push({ + name: "registry", + passed: true, + message: "Plugin found in registry", + }); + + const hasValidManifest = Boolean(plugin.manifestJson?.id); + checks.push({ + name: "manifest", + passed: hasValidManifest, + message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", + }); + + const isHealthy = plugin.status === "ready"; + checks.push({ + name: "status", + passed: isHealthy, + message: `Current status: ${plugin.status}`, + }); + + const hasNoError = !plugin.lastError; + if (!hasNoError) { + checks.push({ + name: "error_state", + passed: false, + message: plugin.lastError ?? undefined, + }); + } + + const health: PluginHealthCheckResult = { + pluginId: plugin.id, + status: plugin.status, + healthy: isHealthy && hasValidManifest && hasNoError, + checks, + lastError: plugin.lastError ?? undefined, + }; + + res.json({ + pluginId: plugin.id, + worker, + recentJobRuns, + recentWebhookDeliveries, + health, + checkedAt: new Date().toISOString(), + }); + }); + + return router; +} diff --git a/server/src/services/cron.ts b/server/src/services/cron.ts new file mode 100644 index 00000000..2b9e09e3 --- /dev/null +++ b/server/src/services/cron.ts @@ -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(); + + // 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; + } + } +} diff --git a/server/src/services/live-events.ts b/server/src/services/live-events.ts index 1421d07d..7db40d49 100644 --- a/server/src/services/live-events.ts +++ b/server/src/services/live-events.ts @@ -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); +} diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts new file mode 100644 index 00000000..56b22f58 --- /dev/null +++ b/server/src/services/plugin-capability-validator.ts @@ -0,0 +1,451 @@ +/** + * 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 = { + // 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"], + "assets.list": ["assets.read"], + "assets.get": ["assets.read"], + + // Data write operations + "issues.create": ["issues.create"], + "issues.update": ["issues.update"], + "issue.comments.create": ["issue.comments.create"], + "assets.upload": ["assets.write"], + "assets.delete": ["assets.write"], + "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 = { + 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 = { + 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 { + 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]; + }, + }; +} diff --git a/server/src/services/plugin-config-validator.ts b/server/src/services/plugin-config-validator.ts new file mode 100644 index 00000000..9e064572 --- /dev/null +++ b/server/src/services/plugin-config-validator.ts @@ -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, + 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 }; +} diff --git a/server/src/services/plugin-dev-watcher.ts b/server/src/services/plugin-dev-watcher.ts new file mode 100644 index 00000000..156b2368 --- /dev/null +++ b/server/src/services/plugin-dev-watcher.ts @@ -0,0 +1,189 @@ +/** + * 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. + * + * @see PLUGIN_SPEC.md §27.2 — Local Development Workflow + */ +import { watch, type FSWatcher } from "node:fs"; +import { existsSync } 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; + +export interface PluginDevWatcherFsDeps { + existsSync?: typeof existsSync; + watch?: typeof watch; +} + +/** + * 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(); + const debounceTimers = new Map>(); + const fileExists = fsDeps?.existsSync ?? existsSync; + const watchFs = fsDeps?.watch ?? watch; + + 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 watcher = watchFs(absPath, { recursive: true }, (_event, filename) => { + // Ignore node_modules and hidden files inside the plugin dir + if ( + filename && + (filename.includes("node_modules") || filename.startsWith(".")) + ) { + return; + } + + // Debounce: multiple rapid file changes collapse into one restart + const existing = debounceTimers.get(pluginId); + if (existing) clearTimeout(existing); + + debounceTimers.set( + pluginId, + setTimeout(() => { + debounceTimers.delete(pluginId); + log.info( + { pluginId, changedFile: filename }, + "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), + ); + }); + + watchers.set(pluginId, watcher); + log.info( + { pluginId, packagePath: absPath }, + "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 watcher = watchers.get(pluginId); + if (watcher) { + watcher.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 { + 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, + }; +} diff --git a/server/src/services/plugin-event-bus.ts b/server/src/services/plugin-event-bus.ts new file mode 100644 index 00000000..78184b47 --- /dev/null +++ b/server/src/services/plugin-event-bus.ts @@ -0,0 +1,515 @@ +/** + * 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..`. + * - 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; +} + +// --------------------------------------------------------------------------- +// 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 | 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; +} + +// --------------------------------------------------------------------------- +// Company availability checker +// --------------------------------------------------------------------------- + +/** + * Callback that checks whether a plugin is enabled for a given company. + * + * The event bus calls this during `emit()` to enforce company-scoped delivery: + * events are only delivered to a plugin if the plugin is enabled for the + * company that owns the event. + * + * Implementations should be fast — the bus caches results internally with a + * short TTL so the checker is not invoked on every single event. + * + * @param pluginKey The plugin registry key — the string passed to `forPlugin()` + * (e.g. `"acme.linear"`). This is the same key used throughout the bus + * internally and should not be confused with a numeric or UUID plugin ID. + * @param companyId UUID of the company to check availability for. + * + * Return `true` if the plugin is enabled (or if no settings row exists, i.e. + * default-enabled), `false` if the company has explicitly disabled the plugin. + */ +export type CompanyAvailabilityChecker = ( + pluginKey: string, + companyId: string, +) => Promise; + +/** + * Options for {@link createPluginEventBus}. + */ +export interface PluginEventBusOptions { + /** + * Optional checker that gates event delivery per company. + * + * When provided, the bus will skip delivery to a plugin if the checker + * returns `false` for the `(pluginKey, event.companyId)` pair, where + * `pluginKey` is the registry key supplied to `forPlugin()`. Results are + * cached with a short TTL (30 s) to avoid excessive lookups. + * + * When omitted, no company-scoping is applied (useful in tests). + */ + isPluginEnabledForCompany?: CompanyAvailabilityChecker; +} + +// Default cache TTL in milliseconds (30 seconds). +const AVAILABILITY_CACHE_TTL_MS = 30_000; + +// Maximum number of entries in the availability cache before it is cleared. +// Prevents unbounded memory growth in long-running processes with many unique +// (pluginKey, companyId) pairs. A full clear is intentionally simple — the +// cache is advisory (performance only) and a miss merely triggers one extra +// async lookup. +const MAX_AVAILABILITY_CACHE_SIZE = 10_000; + +// --------------------------------------------------------------------------- +// 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(options?: PluginEventBusOptions): PluginEventBus { + const checker = options?.isPluginEnabledForCompany ?? null; + + // Subscription registry: pluginKey → list of subscriptions + const registry = new Map(); + + // Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt } + const availabilityCache = new Map(); + + function cacheKey(pluginKey: string, companyId: string): string { + return `${pluginKey}\0${companyId}`; + } + + /** + * Check whether a plugin is enabled for a company, using the cached result + * when available and falling back to the injected checker. + */ + async function isEnabledForCompany(pluginKey: string, companyId: string): Promise { + if (!checker) return true; + + const key = cacheKey(pluginKey, companyId); + const cached = availabilityCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.enabled; + } + + const enabled = await checker(pluginKey, companyId); + if (availabilityCache.size >= MAX_AVAILABILITY_CACHE_SIZE) { + availabilityCache.clear(); + } + availabilityCache.set(key, { enabled, expiresAt: Date.now() + AVAILABILITY_CACHE_TTL_MS }); + return enabled; + } + + /** + * 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 { + const errors: Array<{ pluginId: string; error: unknown }> = []; + const promises: Promise[] = []; + + // Pre-compute company availability for all registered plugins when the + // event carries a companyId and a checker is configured. This batches + // the (potentially async) lookups so we don't interleave them with + // handler dispatch. + let disabledPlugins: Set | null = null; + if (checker && event.companyId) { + const pluginKeys = Array.from(registry.keys()); + const checks = await Promise.all( + pluginKeys.map(async (pluginKey) => ({ + pluginKey, + enabled: await isEnabledForCompany(pluginKey, event.companyId!), + })), + ); + disabledPlugins = new Set(checks.filter((c) => !c.enabled).map((c) => c.pluginKey)); + } + + for (const [pluginId, subs] of registry) { + // Skip delivery to plugins that are disabled for this company. + if (disabledPlugins?.has(pluginId)) continue; + + 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), + maybeFn?: (event: PluginEvent) => Promise, + ): void { + let filter: EventFilter | null = null; + let handler: (event: PluginEvent) => Promise; + + 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..` 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 { + 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; + + /** + * 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; + subscribe( + eventPattern: PluginEventType | `plugin.${string}`, + filter: EventFilter, + fn: (event: PluginEvent) => Promise, + ): void; + + /** + * Emit a plugin-namespaced event. The bus automatically prepends + * `plugin..` 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; + + /** + * Remove all subscriptions registered by this plugin. + */ + clear(): void; +} diff --git a/server/src/services/plugin-host-service-cleanup.ts b/server/src/services/plugin-host-service-cleanup.ts new file mode 100644 index 00000000..cd197f7f --- /dev/null +++ b/server/src/services/plugin-host-service-cleanup.ts @@ -0,0 +1,59 @@ +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; + +type LifecycleLike = Pick; + +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 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); + }, + }; +} diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts new file mode 100644 index 00000000..d7910e97 --- /dev/null +++ b/server/src/services/plugin-host-services.ts @@ -0,0 +1,1077 @@ +import type { Db } from "@paperclipai/db"; +import { pluginLogs, agentTaskSessions as agentTaskSessionsTable } from "@paperclipai/db"; +import { eq, and, like, desc } from "drizzle-orm"; +import type { + HostServices, + Company, + Agent, + Project, + Issue, + Goal, + PluginWorkspace, + IssueComment, +} from "@paperclipai/plugin-sdk"; +import { companyService } from "./companies.js"; +import { agentService } from "./agents.js"; +import { projectService } from "./projects.js"; +import { issueService } from "./issues.js"; +import { goalService } from "./goals.js"; +import { heartbeatService } from "./heartbeat.js"; +import { subscribeCompanyLiveEvents } from "./live-events.js"; +import { randomUUID } from "node:crypto"; +import { activityService } from "./activity.js"; +import { costService } from "./costs.js"; +import { assetService } from "./assets.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import { pluginStateStore } from "./plugin-state-store.js"; +import { createPluginSecretsHandler } from "./plugin-secrets-handler.js"; +import { logActivity } from "./activity-log.js"; +import type { PluginEventBus } from "./plugin-event-bus.js"; +import { lookup as dnsLookup } from "node:dns/promises"; +import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// SSRF protection for plugin HTTP fetch +// --------------------------------------------------------------------------- + +/** Maximum time (ms) a plugin fetch request may take before being aborted. */ +const PLUGIN_FETCH_TIMEOUT_MS = 30_000; + +/** Maximum time (ms) to wait for a DNS lookup before aborting. */ +const DNS_LOOKUP_TIMEOUT_MS = 5_000; + +/** Only these protocols are allowed for plugin HTTP requests. */ +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +/** + * Check if an IP address is in a private/reserved range (RFC 1918, loopback, + * link-local, etc.) that plugins should never be able to reach. + * + * Handles IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) which Node's + * dns.lookup may return depending on OS configuration. + */ +function isPrivateIP(ip: string): boolean { + const lower = ip.toLowerCase(); + + // Unwrap IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) and re-check as IPv4 + const v4MappedMatch = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (v4MappedMatch && v4MappedMatch[1]) return isPrivateIP(v4MappedMatch[1]); + + // IPv4 patterns + if (ip.startsWith("10.")) return true; + if (ip.startsWith("172.")) { + const second = parseInt(ip.split(".")[1]!, 10); + if (second >= 16 && second <= 31) return true; + } + if (ip.startsWith("192.168.")) return true; + if (ip.startsWith("127.")) return true; // loopback + if (ip.startsWith("169.254.")) return true; // link-local + if (ip === "0.0.0.0") return true; + + // IPv6 patterns + if (lower === "::1") return true; // loopback + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA + if (lower.startsWith("fe80")) return true; // link-local + if (lower === "::") return true; + + return false; +} + +/** + * Validate a URL for plugin fetch: protocol whitelist + private IP blocking. + * + * SSRF Prevention Strategy: + * 1. Parse and validate the URL syntax + * 2. Enforce protocol whitelist (http/https only) + * 3. Resolve the hostname to IP(s) via DNS + * 4. Validate that ALL resolved IPs are non-private + * 5. Pin the first safe IP into the URL so fetch() does not re-resolve DNS + * + * This prevents DNS rebinding attacks where an attacker controls DNS to + * resolve to a safe IP during validation, then to a private IP when fetch() runs. + * + * @returns Request-routing metadata used to connect directly to the resolved IP + * while preserving the original hostname for HTTP Host and TLS SNI. + */ +interface ValidatedFetchTarget { + parsedUrl: URL; + resolvedAddress: string; + hostHeader: string; + tlsServername?: string; + useTls: boolean; +} + +async function validateAndResolveFetchUrl(urlString: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + throw new Error(`Invalid URL: ${urlString}`); + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Disallowed protocol "${parsed.protocol}" — only http: and https: are permitted`, + ); + } + + // Resolve the hostname to an IP and check for private ranges. + // We pin the resolved IP into the URL to eliminate the TOCTOU window + // between DNS resolution here and the second resolution fetch() would do. + const originalHostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + const hostHeader = parsed.host; // includes port if non-default + + // Race the DNS lookup against a timeout to prevent indefinite hangs + // when DNS is misconfigured or unresponsive. + const dnsPromise = dnsLookup(originalHostname, { all: true }); + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`DNS lookup timed out after ${DNS_LOOKUP_TIMEOUT_MS}ms for ${originalHostname}`)), + DNS_LOOKUP_TIMEOUT_MS, + ); + }); + + try { + const results = await Promise.race([dnsPromise, timeoutPromise]); + if (results.length === 0) { + throw new Error(`DNS resolution returned no results for ${originalHostname}`); + } + + // Filter to only non-private IPs instead of rejecting the entire request + // when some IPs are private. This handles multi-homed hosts that resolve + // to both private and public addresses. + const safeResults = results.filter((entry) => !isPrivateIP(entry.address)); + if (safeResults.length === 0) { + throw new Error( + `All resolved IPs for ${originalHostname} are in private/reserved ranges`, + ); + } + + const resolved = safeResults[0]!; + return { + parsedUrl: parsed, + resolvedAddress: resolved.address, + hostHeader, + tlsServername: parsed.protocol === "https:" && isIP(originalHostname) === 0 + ? originalHostname + : undefined, + useTls: parsed.protocol === "https:", + }; + } catch (err) { + // Re-throw our own errors; wrap DNS failures + if (err instanceof Error && ( + err.message.startsWith("All resolved IPs") || + err.message.startsWith("DNS resolution returned") || + err.message.startsWith("DNS lookup timed out") + )) throw err; + throw new Error(`DNS resolution failed for ${originalHostname}: ${(err as Error).message}`); + } +} + +function buildPinnedRequestOptions( + target: ValidatedFetchTarget, + init?: RequestInit, +): { options: HttpRequestOptions & { servername?: string }; body: string | undefined } { + const headers = new Headers(init?.headers); + const method = init?.method ?? "GET"; + const body = init?.body === undefined || init?.body === null + ? undefined + : typeof init.body === "string" + ? init.body + : String(init.body); + + headers.set("Host", target.hostHeader); + if (body !== undefined && !headers.has("content-length") && !headers.has("transfer-encoding")) { + headers.set("content-length", String(Buffer.byteLength(body))); + } + + const pathname = `${target.parsedUrl.pathname}${target.parsedUrl.search}`; + const auth = target.parsedUrl.username || target.parsedUrl.password + ? `${decodeURIComponent(target.parsedUrl.username)}:${decodeURIComponent(target.parsedUrl.password)}` + : undefined; + + return { + options: { + protocol: target.parsedUrl.protocol, + host: target.resolvedAddress, + port: target.parsedUrl.port + ? Number(target.parsedUrl.port) + : target.useTls + ? 443 + : 80, + path: pathname, + method, + headers: Object.fromEntries(headers.entries()), + auth, + servername: target.tlsServername, + }, + body, + }; +} + +async function executePinnedHttpRequest( + target: ValidatedFetchTarget, + init: RequestInit | undefined, + signal: AbortSignal, +): Promise<{ status: number; statusText: string; headers: Record; body: string }> { + const { options, body } = buildPinnedRequestOptions(target, init); + + const response = await new Promise((resolve, reject) => { + const requestFn = target.useTls ? httpsRequest : httpRequest; + const req = requestFn({ ...options, signal }, resolve); + + req.on("error", reject); + + if (body !== undefined) { + req.write(body); + } + req.end(); + }); + + const MAX_RESPONSE_BODY_BYTES = 200 * 1024 * 1024; // 200 MB + const chunks: Buffer[] = []; + let totalBytes = 0; + await new Promise((resolve, reject) => { + response.on("data", (chunk: Buffer | string) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buf.length; + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + chunks.length = 0; + response.destroy(new Error(`Response body exceeded ${MAX_RESPONSE_BODY_BYTES} bytes`)); + return; + } + chunks.push(buf); + }); + response.on("end", resolve); + response.on("error", reject); + }); + + const headers: Record = {}; + for (const [key, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + headers[key] = value.join(", "); + } else if (value !== undefined) { + headers[key] = value; + } + } + + return { + status: response.statusCode ?? 500, + statusText: response.statusMessage ?? "", + headers, + body: Buffer.concat(chunks).toString("utf8"), + }; +} + +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 sanitizeWorkspaceText(value: string): string { + const trimmed = value.trim(); + if (!trimmed || UUID_PATTERN.test(trimmed)) return ""; + return trimmed; +} + +function sanitizeWorkspacePath(cwd: string | null): string { + if (!cwd) return ""; + return looksLikePath(cwd) ? cwd.trim() : ""; +} + +function sanitizeWorkspaceName(name: string, fallbackPath: string): string { + const safeName = sanitizeWorkspaceText(name); + if (safeName && !looksLikePath(safeName)) { + return safeName; + } + const normalized = fallbackPath.trim().replace(/[\\/]+$/, ""); + const segments = normalized.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? "Workspace"; +} + +// --------------------------------------------------------------------------- +// Buffered plugin log writes +// --------------------------------------------------------------------------- + +/** How many buffered log entries trigger an immediate flush. */ +const LOG_BUFFER_FLUSH_SIZE = 100; + +/** How often (ms) the buffer is flushed regardless of size. */ +const LOG_BUFFER_FLUSH_INTERVAL_MS = 5_000; + +/** Max length for a single plugin log message (bytes/chars). */ +const MAX_LOG_MESSAGE_LENGTH = 10_000; + +/** Max serialised JSON size for plugin log meta objects. */ +const MAX_LOG_META_JSON_LENGTH = 50_000; + +/** Max length for a metric name. */ +const MAX_METRIC_NAME_LENGTH = 500; + +/** Pino reserved field names that plugins must not overwrite. */ +const PINO_RESERVED_KEYS = new Set([ + "level", + "time", + "pid", + "hostname", + "msg", + "v", +]); + +/** Truncate a string to `max` characters, appending a marker if truncated. */ +function truncStr(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + "...[truncated]"; +} + +/** Sanitise a plugin-supplied meta object: enforce size limit and strip reserved keys. */ +function sanitiseMeta(meta: Record | null | undefined): Record | null { + if (meta == null) return null; + // Strip pino reserved keys + const cleaned: Record = {}; + for (const [k, v] of Object.entries(meta)) { + if (!PINO_RESERVED_KEYS.has(k)) { + cleaned[k] = v; + } + } + // Enforce total serialised size + let json: string; + try { + json = JSON.stringify(cleaned); + } catch { + return { _sanitised: true, _error: "meta was not JSON-serialisable" }; + } + if (json.length > MAX_LOG_META_JSON_LENGTH) { + return { _sanitised: true, _error: `meta exceeded ${MAX_LOG_META_JSON_LENGTH} chars` }; + } + return cleaned; +} + +interface BufferedLogEntry { + db: Db; + pluginId: string; + level: string; + message: string; + meta: Record | null; +} + +const _logBuffer: BufferedLogEntry[] = []; + +/** + * Flush all buffered log entries to the database in a single batch insert per + * unique db instance. Errors are swallowed with a console.error fallback so + * flushing never crashes the process. + */ +export async function flushPluginLogBuffer(): Promise { + if (_logBuffer.length === 0) return; + + // Drain the buffer atomically so concurrent flushes don't double-insert. + const entries = _logBuffer.splice(0, _logBuffer.length); + + // Group entries by db identity so multi-db scenarios are handled correctly. + const byDb = new Map(); + for (const entry of entries) { + const group = byDb.get(entry.db); + if (group) { + group.push(entry); + } else { + byDb.set(entry.db, [entry]); + } + } + + for (const [dbInstance, group] of byDb) { + const values = group.map((e) => ({ + pluginId: e.pluginId, + level: e.level, + message: e.message, + meta: e.meta, + })); + try { + await dbInstance.insert(pluginLogs).values(values); + } catch (err) { + try { + logger.warn({ err, count: values.length }, "Failed to batch-persist plugin logs to DB"); + } catch { + console.error("[plugin-host-services] Batch log flush failed:", err); + } + } + } +} + +/** Interval handle for the periodic log flush. */ +const _logFlushInterval = setInterval(() => { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Periodic log flush error:", err); + }); +}, LOG_BUFFER_FLUSH_INTERVAL_MS); + +// Allow the interval to be unref'd so it doesn't keep the process alive in tests. +if (_logFlushInterval.unref) _logFlushInterval.unref(); + +/** + * buildHostServices — creates a concrete implementation of the `HostServices` + * interface for a specific plugin. + * + * This implementation delegates to the core Paperclip domain services, + * providing the bridge between the plugin worker's SDK and the host platform. + * + * @param db - Database connection instance. + * @param pluginId - The UUID of the plugin installation record. + * @param pluginKey - The unique identifier from the plugin manifest (e.g., "acme.linear"). + * @param eventBus - The system-wide event bus for publishing plugin events. + * @returns An object implementing the HostServices interface for the plugin SDK. + */ +/** Maximum time (ms) to keep a session event subscription alive before forcing cleanup. */ +const SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes + +export function buildHostServices( + db: Db, + pluginId: string, + pluginKey: string, + eventBus: PluginEventBus, + notifyWorker?: (method: string, params: unknown) => void, +): HostServices & { dispose(): void } { + const registry = pluginRegistryService(db); + const stateStore = pluginStateStore(db); + const secretsHandler = createPluginSecretsHandler({ db, pluginId }); + const companies = companyService(db); + const agents = agentService(db); + const heartbeat = heartbeatService(db); + const projects = projectService(db); + const issues = issueService(db); + const goals = goalService(db); + const activity = activityService(db); + const costs = costService(db); + const assets = assetService(db); + const scopedBus = eventBus.forPlugin(pluginKey); + + // Track active session event subscriptions for cleanup + const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType }>(); + let disposed = false; + + const ensureCompanyId = (companyId?: string) => { + if (!companyId) throw new Error("companyId is required for this operation"); + return companyId; + }; + + /** + * Verify that this plugin is enabled for the given company. + * Throws if the plugin is disabled or unavailable, preventing + * worker-driven access to companies that have not opted in. + */ + const ensurePluginAvailableForCompany = async (companyId: string) => { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + if (!availability || !availability.available) { + throw new Error( + `Plugin "${pluginKey}" is not enabled for company "${companyId}"`, + ); + } + }; + + const inCompany = ( + record: T | null | undefined, + companyId: string, + ): record is T => Boolean(record && record.companyId === companyId); + + const requireInCompany = ( + entityName: string, + record: T | null | undefined, + companyId: string, + ): T => { + if (!inCompany(record, companyId)) { + throw new Error(`${entityName} not found`); + } + return record; + }; + + return { + config: { + async get() { + const configRow = await registry.getConfig(pluginId); + return configRow?.configJson ?? {}; + }, + }, + + state: { + async get(params) { + return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, { + scopeId: params.scopeId, + namespace: params.namespace, + }); + }, + async set(params) { + await stateStore.set(pluginId, { + scopeKind: params.scopeKind as any, + scopeId: params.scopeId, + namespace: params.namespace, + stateKey: params.stateKey, + value: params.value, + }); + }, + async delete(params) { + await stateStore.delete(pluginId, params.scopeKind as any, params.stateKey, { + scopeId: params.scopeId, + namespace: params.namespace, + }); + }, + }, + + entities: { + async upsert(params) { + return registry.upsertEntity(pluginId, params as any) as any; + }, + async list(params) { + return registry.listEntities(pluginId, params as any) as any; + }, + }, + + events: { + async emit(params) { + if (params.companyId) { + await ensurePluginAvailableForCompany(params.companyId); + } + await scopedBus.emit(params.name, params.companyId, params.payload); + }, + }, + + http: { + async fetch(params) { + // SSRF protection: validate protocol whitelist + block private IPs. + // Resolve once, then connect directly to that IP to prevent DNS rebinding. + const target = await validateAndResolveFetchUrl(params.url); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PLUGIN_FETCH_TIMEOUT_MS); + + try { + const init = params.init as RequestInit | undefined; + return await executePinnedHttpRequest(target, init, controller.signal); + } finally { + clearTimeout(timeout); + } + }, + }, + + secrets: { + async resolve(params) { + return secretsHandler.resolve(params); + }, + }, + + assets: { + async upload(params) { + void params; + throw new Error("Plugin asset uploads are not supported in this build."); + }, + async getUrl(params) { + void params; + throw new Error("Plugin asset URLs are not supported in this build."); + }, + }, + + activity: { + async log(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + await logActivity(db, { + companyId, + actorType: "system", + actorId: pluginId, + action: params.message, + entityType: params.entityType ?? "plugin", + entityId: params.entityId ?? pluginId, + details: params.metadata, + }); + }, + }, + + metrics: { + async write(params) { + const safeName = truncStr(String(params.name ?? ""), MAX_METRIC_NAME_LENGTH); + logger.debug({ pluginId, name: safeName, value: params.value, tags: params.tags }, "Plugin metric write"); + + // Persist metrics to plugin_logs via the batch buffer (same path as + // logger.log) so they benefit from batched writes and are flushed + // reliably on shutdown. Using level "metric" makes them queryable + // alongside regular logs via the same API (§26). + _logBuffer.push({ + db, + pluginId, + level: "metric", + message: safeName, + meta: sanitiseMeta({ value: params.value, tags: params.tags ?? null }), + }); + if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Triggered metric flush failed:", err); + }); + } + }, + }, + + logger: { + async log(params) { + const { level, meta } = params; + const safeMessage = truncStr(String(params.message ?? ""), MAX_LOG_MESSAGE_LENGTH); + const safeMeta = sanitiseMeta(meta); + const pluginLogger = logger.child({ service: "plugin-worker", pluginId }); + const logFields = { + ...safeMeta, + pluginLogLevel: level, + pluginTimestamp: new Date().toISOString(), + }; + + if (level === "error") pluginLogger.error(logFields, `[plugin] ${safeMessage}`); + else if (level === "warn") pluginLogger.warn(logFields, `[plugin] ${safeMessage}`); + else if (level === "debug") pluginLogger.debug(logFields, `[plugin] ${safeMessage}`); + else pluginLogger.info(logFields, `[plugin] ${safeMessage}`); + + // Persist to plugin_logs table via the module-level batch buffer (§26.1). + // Fire-and-forget — logging should never block the worker. + _logBuffer.push({ + db, + pluginId, + level: level ?? "info", + message: safeMessage, + meta: safeMeta, + }); + if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Triggered log flush failed:", err); + }); + } + }, + }, + + companies: { + async list(_params) { + const allCompanies = (await companies.list()) as Company[]; + if (allCompanies.length === 0) return []; + + // Batch query: fetch all company settings for this plugin in one query + // instead of N+1 individual getCompanyAvailability() calls. + const companyIds = allCompanies.map((c) => c.id); + const disabledCompanyIds = await registry.getDisabledCompanyIds(companyIds, pluginId); + return allCompanies.filter((c) => !disabledCompanyIds.has(c.id)); + }, + async get(params) { + await ensurePluginAvailableForCompany(params.companyId); + return (await companies.getById(params.companyId)) as Company; + }, + }, + + projects: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await projects.list(companyId)) as Project[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + return (inCompany(project, companyId) ? project : null) as Project | null; + }, + async listWorkspaces(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + if (!inCompany(project, companyId)) return []; + const rows = await projects.listWorkspaces(params.projectId); + return rows.map((row) => { + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }); + }, + async getPrimaryWorkspace(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + if (!inCompany(project, companyId)) return null; + const row = project.primaryWorkspace; + if (!row) return null; + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }, + + async getWorkspaceForIssue(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return null; + const projectId = (issue as Record).projectId as string | null; + if (!projectId) return null; + const project = await projects.getById(projectId); + if (!inCompany(project, companyId)) return null; + const row = project.primaryWorkspace; + if (!row) return null; + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }, + }, + + issues: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await issues.list(companyId, params as any)) as Issue[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = await issues.getById(params.issueId); + return (inCompany(issue, companyId) ? issue : null) as Issue | null; + }, + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await issues.create(companyId, params as any)) as Issue; + }, + async update(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + return (await issues.update(params.issueId, params.patch as any)) as Issue; + }, + async listComments(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + return (await issues.listComments(params.issueId)) as IssueComment[]; + }, + async createComment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + return (await issues.addComment( + params.issueId, + params.body, + {}, + )) as IssueComment; + }, + }, + + agents: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await agents.list(companyId); + return rows.filter((agent) => !params.status || agent.status === params.status) as Agent[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + return (inCompany(agent, companyId) ? agent : null) as Agent | null; + }, + async pause(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + return (await agents.pause(params.agentId)) as Agent; + }, + async resume(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + return (await agents.resume(params.agentId)) as Agent; + }, + async invoke(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + const run = await heartbeat.wakeup(params.agentId, { + source: "automation", + triggerDetail: "system", + reason: params.reason ?? null, + payload: { prompt: params.prompt }, + requestedByActorType: "system", + requestedByActorId: pluginId, + }); + if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); + return { runId: run.id }; + }, + }, + + goals: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await goals.list(companyId); + return rows.filter((goal) => + (!params.level || goal.level === params.level) && + (!params.status || goal.status === params.status), + ) as Goal[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const goal = await goals.getById(params.goalId); + return (inCompany(goal, companyId) ? goal : null) as Goal | null; + }, + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await goals.create(companyId, { + title: params.title, + description: params.description, + level: params.level as any, + status: params.status as any, + parentId: params.parentId, + ownerAgentId: params.ownerAgentId, + })) as Goal; + }, + async update(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Goal", await goals.getById(params.goalId), companyId); + return (await goals.update(params.goalId, params.patch as any)) as Goal; + }, + }, + + agentSessions: { + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + const taskKey = params.taskKey ?? `plugin:${pluginKey}:session:${randomUUID()}`; + + const row = await db + .insert(agentTaskSessionsTable) + .values({ + companyId, + agentId: params.agentId, + adapterType: agent!.adapterType, + taskKey, + sessionParamsJson: null, + sessionDisplayId: null, + lastRunId: null, + lastError: null, + }) + .returning() + .then((rows) => rows[0]); + + return { + sessionId: row!.id, + agentId: params.agentId, + companyId, + status: "active" as const, + createdAt: row!.createdAt.toISOString(), + }; + }, + + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await db + .select() + .from(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.agentId, params.agentId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .orderBy(desc(agentTaskSessionsTable.createdAt)); + + return rows.map((row) => ({ + sessionId: row.id, + agentId: row.agentId, + companyId: row.companyId, + status: "active" as const, + createdAt: row.createdAt.toISOString(), + })); + }, + + async sendMessage(params) { + if (disposed) { + throw new Error("Host services have been disposed"); + } + + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + + // Verify session exists and belongs to this plugin + const session = await db + .select() + .from(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.id, params.sessionId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .then((rows) => rows[0] ?? null); + if (!session) throw new Error(`Session not found: ${params.sessionId}`); + + const run = await heartbeat.wakeup(session.agentId, { + source: "automation", + triggerDetail: "system", + reason: params.reason ?? null, + payload: { prompt: params.prompt }, + contextSnapshot: { + taskKey: session.taskKey, + wakeSource: "automation", + wakeTriggerDetail: "system", + }, + requestedByActorType: "system", + requestedByActorId: pluginId, + }); + if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); + + // Subscribe to live events and forward to the plugin worker as notifications. + // Track the subscription so it can be cleaned up on dispose() if the run + // never reaches a terminal status (hang, crash, network partition). + if (notifyWorker) { + const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); + + const cleanup = () => { + unsubscribe(); + clearTimeout(timeoutTimer); + activeSubscriptions.delete(entry); + }; + + const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => { + const payload = event.payload as Record | undefined; + if (!payload || payload.runId !== run.id) return; + + if (event.type === "heartbeat.run.log" || event.type === "heartbeat.run.event") { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: (payload.seq as number) ?? 0, + eventType: "chunk", + stream: (payload.stream as string) ?? null, + message: (payload.chunk as string) ?? (payload.message as string) ?? null, + payload: payload, + }); + } else if (event.type === "heartbeat.run.status") { + const status = payload.status as string; + if (TERMINAL_STATUSES.has(status)) { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: 0, + eventType: status === "succeeded" ? "done" : "error", + stream: "system", + message: status === "succeeded" ? "Run completed" : `Run ${status}`, + payload: payload, + }); + cleanup(); + } else { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: 0, + eventType: "status", + stream: "system", + message: `Run status: ${status}`, + payload: payload, + }); + } + } + }); + + // Safety-net timeout: if the run never reaches a terminal status, + // force-cleanup the subscription to prevent unbounded leaks. + const timeoutTimer = setTimeout(() => { + logger.warn( + { pluginId, pluginKey, runId: run.id }, + "session event subscription timed out — forcing cleanup", + ); + cleanup(); + }, SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS); + + const entry = { unsubscribe, timer: timeoutTimer }; + activeSubscriptions.add(entry); + } + + return { runId: run.id }; + }, + + async close(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const deleted = await db + .delete(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.id, params.sessionId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .returning() + .then((rows) => rows.length); + if (deleted === 0) throw new Error(`Session not found: ${params.sessionId}`); + }, + }, + + /** + * Clean up all active session event subscriptions and flush any buffered + * log entries. Must be called when the plugin worker is stopped, crashed, + * or unloaded to prevent leaked listeners and lost log entries. + */ + dispose() { + disposed = true; + + // Snapshot to avoid iterator invalidation from concurrent sendMessage() calls + const snapshot = Array.from(activeSubscriptions); + activeSubscriptions.clear(); + + for (const entry of snapshot) { + clearTimeout(entry.timer); + entry.unsubscribe(); + } + + // Flush any buffered log entries synchronously-as-possible on dispose. + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] dispose() log flush failed:", err); + }); + }, + }; +} diff --git a/server/src/services/plugin-job-coordinator.ts b/server/src/services/plugin-job-coordinator.ts new file mode 100644 index 00000000..bb0df083 --- /dev/null +++ b/server/src/services/plugin-job-coordinator.ts @@ -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 { + 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 { + 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 { + 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"); + }, + }; +} diff --git a/server/src/services/plugin-job-scheduler.ts b/server/src/services/plugin-job-scheduler.ts new file mode 100644 index 00000000..09a6b878 --- /dev/null +++ b/server/src/services/plugin-job-scheduler.ts @@ -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; + + /** + * 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; + + /** + * 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; + + /** + * Run a single scheduler tick immediately (for testing). + * + * @internal + */ + tick(): Promise; + + /** + * 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 | null = null; + + /** Whether the scheduler is running. */ + let running = false; + + /** Set of job IDs currently being executed (for overlap prevention). */ + const activeJobs = new Set(); + + /** 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 { + // 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[] = []; + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + log.info({ pluginId }, "registering plugin with job scheduler"); + await ensureNextRunTimestamps(pluginId); + } + + async function unregisterPlugin(pluginId: string): Promise { + 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, + }; +} diff --git a/server/src/services/plugin-job-store.ts b/server/src/services/plugin-job-store.ts new file mode 100644 index 00000000..f5fdbce1 --- /dev/null +++ b/server/src/services/plugin-job-store.ts @@ -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 { + 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 { + 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(); + + // 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts new file mode 100644 index 00000000..d3e93677 --- /dev/null +++ b/server/src/services/plugin-lifecycle.ts @@ -0,0 +1,807 @@ +/** + * 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 = { + 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 = 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; + + /** + * Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state. + * Transitions → `ready`. + */ + enable(pluginId: string): Promise; + + /** + * Disable a running plugin. + * Transitions `ready` → `disabled`. + */ + disable(pluginId: string, reason?: string): Promise; + + /** + * 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; + + /** + * Mark a plugin as errored (e.g. worker crash, health-check failure). + * Transitions → `error`. + */ + markError(pluginId: string, error: string): Promise; + + /** + * Mark a plugin as requiring upgrade approval. + * Transitions `ready` → `upgrade_pending`. + */ + markUpgradePending(pluginId: string): Promise; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * Get the current lifecycle state for a plugin. + */ + getStatus(pluginId: string): Promise; + + /** + * Check whether a transition is allowed from the plugin's current state. + */ + canTransition(pluginId: string, to: PluginStatus): Promise; + + /** + * Subscribe to lifecycle events. + */ + on( + event: K, + listener: (payload: LifecycleEventPayload) => void, + ): void; + + /** + * Unsubscribe from lifecycle events. + */ + off( + event: K, + listener: (payload: LifecycleEventPayload) => void, + ): void; + + /** + * Subscribe to a lifecycle event once. + */ + once( + event: K, + listener: (payload: LifecycleEventPayload) => 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 { + 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 { + 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 { + 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 { + 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}`, + ); + } + } + + // ----------------------------------------------------------------------- + // 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 { + 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 { + 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 { + 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.`, + ); + } + + // Stop the worker before transitioning state + await stopWorkerIfRunning(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 { + const plugin = await requirePlugin(pluginId); + + // If already uninstalled and removeData, hard-delete + if (plugin.status === "uninstalled") { + if (removeData) { + 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.`, + ); + } + + // Stop the worker before uninstalling + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + // 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 { + // 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 stopWorkerIfRunning(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 { + // Stop the worker while waiting for operator approval of new capabilities + const plugin = await requirePlugin(pluginId); + await stopWorkerIfRunning(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 { + 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", + ); + + // Stop the current worker before upgrading on disk + await stopWorkerIfRunning(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 { + 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 { + 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 { + 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 { + const plugin = await registry.getById(pluginId); + return plugin?.status ?? null; + }, + + // -- canTransition ---------------------------------------------------- + async canTransition(pluginId: string, to: PluginStatus): Promise { + 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); + }, + }; +} diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts new file mode 100644 index 00000000..7041bc23 --- /dev/null +++ b/server/src/services/plugin-loader.ts @@ -0,0 +1,1852 @@ +/** + * PluginLoader — discovery, installation, and runtime activation of plugins. + * + * This service is the entry point for the plugin system's I/O boundary: + * + * 1. **Discovery** — Scans the local plugin directory + * (`~/.paperclip/plugins/`) and `node_modules` for packages matching + * the `paperclip-plugin-*` naming convention. Aggregates results with + * path-based deduplication. + * + * 2. **Installation** — `installPlugin()` downloads from npm (or reads a + * local path), validates the manifest, checks capability consistency, + * and persists the install record. + * + * 3. **Runtime activation** — `activatePlugin()` wires up a loaded plugin + * with all runtime services: resolves its entrypoint, builds + * capability-gated host handlers, spawns a worker process, syncs job + * declarations, registers event subscriptions, and discovers tools. + * + * 4. **Shutdown** — `shutdownAll()` gracefully stops all active workers + * and unregisters runtime hooks. + * + * @see PLUGIN_SPEC.md §8 — Plugin Discovery + * @see PLUGIN_SPEC.md §10 — Package Contract + * @see PLUGIN_SPEC.md §12 — Process Model + */ +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { Db } from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginLauncherDeclaration, + PluginRecord, + PluginUiSlotDeclaration, +} from "@paperclipai/shared"; +import { logger } from "../middleware/logger.js"; +import { pluginManifestValidator } from "./plugin-manifest-validator.js"; +import { pluginCapabilityValidator } from "./plugin-capability-validator.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import type { PluginWorkerManager, WorkerStartOptions, WorkerToHostHandlers } from "./plugin-worker-manager.js"; +import type { PluginEventBus } from "./plugin-event-bus.js"; +import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; +import type { PluginJobStore } from "./plugin-job-store.js"; +import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Naming convention for npm-published Paperclip plugins. + * Packages matching this pattern are considered Paperclip plugins. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +export const NPM_PLUGIN_PACKAGE_PREFIX = "paperclip-plugin-"; + +/** + * Default local plugin directory. The loader scans this directory for + * locally-installed plugin packages. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + */ +export const DEFAULT_LOCAL_PLUGIN_DIR = path.join( + os.homedir(), + ".paperclip", + "plugins", +); + +const DEV_TSX_LOADER_PATH = path.resolve(__dirname, "../../../cli/node_modules/tsx/dist/loader.mjs"); + +// --------------------------------------------------------------------------- +// Discovery result types +// --------------------------------------------------------------------------- + +/** + * A plugin package found during discovery from any source. + */ +export interface DiscoveredPlugin { + /** Absolute path to the root of the npm package directory. */ + packagePath: string; + /** The npm package name as declared in package.json. */ + packageName: string; + /** Semver version from package.json. */ + version: string; + /** Source that found this package. */ + source: PluginSource; + /** The parsed and validated manifest if available, null if discovery-only. */ + manifest: PaperclipPluginManifestV1 | null; +} + +/** + * Sources from which plugins can be discovered. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + */ +export type PluginSource = + | "local-filesystem" // ~/.paperclip/plugins/ local directory + | "npm" // npm packages matching paperclip-plugin-* convention + | "registry"; // future: remote plugin registry URL + +type ParsedSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[]; +}; + +/** + * Result of a discovery scan. + */ +export interface PluginDiscoveryResult { + /** Plugins successfully discovered and validated. */ + discovered: DiscoveredPlugin[]; + /** Packages found but with validation errors. */ + errors: Array<{ packagePath: string; packageName: string; error: string }>; + /** Source(s) that were scanned. */ + sources: PluginSource[]; +} + +// --------------------------------------------------------------------------- +// Loader options +// --------------------------------------------------------------------------- + +/** + * Options for the plugin loader service. + */ +export interface PluginLoaderOptions { + /** + * Path to the local plugin directory to scan. + * Defaults to ~/.paperclip/plugins/ + */ + localPluginDir?: string; + + /** + * Whether to scan the local filesystem directory for plugins. + * Defaults to true. + */ + enableLocalFilesystem?: boolean; + + /** + * Whether to discover installed npm packages matching the paperclip-plugin-* + * naming convention. + * Defaults to true. + */ + enableNpmDiscovery?: boolean; + + /** + * Future: URL of the remote plugin registry to query. + * When set, the loader will also fetch available plugins from this endpoint. + * Registry support is not yet implemented; this field is reserved. + */ + registryUrl?: string; +} + +// --------------------------------------------------------------------------- +// Install options +// --------------------------------------------------------------------------- + +/** + * Options for installing a single plugin package. + */ +export interface PluginInstallOptions { + /** + * npm package name to install (e.g. "paperclip-plugin-linear" or "@acme/plugin-linear"). + * Either packageName or localPath must be set. + */ + packageName?: string; + + /** + * Absolute or relative path to a local plugin directory for development installs. + * When set, the plugin is loaded from this path without npm install. + * Either packageName or localPath must be set. + */ + localPath?: string; + + /** + * Version specifier passed to npm install (e.g. "^1.2.0", "latest"). + * Ignored when localPath is set. + */ + version?: string; + + /** + * Plugin install directory where packages are managed. + * Defaults to the localPluginDir configured on the service. + */ + installDir?: string; +} + +// --------------------------------------------------------------------------- +// Runtime options — services needed for initializing loaded plugins +// --------------------------------------------------------------------------- + +/** + * Runtime services passed to the loader for plugin initialization. + * + * When these are provided, the loader can fully activate plugins (spawn + * workers, register event subscriptions, sync jobs, register tools). + * When omitted, the loader operates in discovery/install-only mode. + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + * @see PLUGIN_SPEC.md §12 — Process Model + */ +export interface PluginRuntimeServices { + /** Worker process manager for spawning and managing plugin workers. */ + workerManager: PluginWorkerManager; + /** Event bus for registering plugin event subscriptions. */ + eventBus: PluginEventBus; + /** Job scheduler for registering plugin cron jobs. */ + jobScheduler: PluginJobScheduler; + /** Job store for syncing manifest job declarations to the DB. */ + jobStore: PluginJobStore; + /** Tool dispatcher for registering plugin-contributed agent tools. */ + toolDispatcher: PluginToolDispatcher; + /** Lifecycle manager for state transitions and worker lifecycle events. */ + lifecycleManager: PluginLifecycleManager; + /** + * Factory that creates worker-to-host RPC handlers for a given plugin. + * + * The returned handlers service worker→host calls (e.g. state.get, + * events.emit, config.get). Each plugin gets its own set of handlers + * scoped to its capabilities and plugin ID. + */ + buildHostHandlers: (pluginId: string, manifest: PaperclipPluginManifestV1) => WorkerToHostHandlers; + /** + * Host instance information passed to the worker during initialization. + * Includes the instance ID and host version. + */ + instanceInfo: { + instanceId: string; + hostVersion: string; + }; +} + +// --------------------------------------------------------------------------- +// Load results +// --------------------------------------------------------------------------- + +/** + * Result of activating (loading) a single plugin at runtime. + * + * Contains the plugin record, activation status, and any error that + * occurred during the process. + */ +export interface PluginLoadResult { + /** The plugin record from the database. */ + plugin: PluginRecord; + /** Whether the plugin was successfully activated. */ + success: boolean; + /** Error message if activation failed. */ + error?: string; + /** Which subsystems were registered during activation. */ + registered: { + /** True if the worker process was started. */ + worker: boolean; + /** Number of event subscriptions registered (from manifest event declarations). */ + eventSubscriptions: number; + /** Number of job declarations synced to the database. */ + jobs: number; + /** Number of webhook endpoints declared in manifest. */ + webhooks: number; + /** Number of agent tools registered. */ + tools: number; + }; +} + +/** + * Result of activating all ready plugins at server startup. + */ +export interface PluginLoadAllResult { + /** Total number of plugins that were attempted. */ + total: number; + /** Number of plugins successfully activated. */ + succeeded: number; + /** Number of plugins that failed to activate. */ + failed: number; + /** Per-plugin results. */ + results: PluginLoadResult[]; +} + +/** + * Normalized UI contribution metadata extracted from a plugin manifest. + * + * The host serves all plugin UI bundles from the manifest's `entrypoints.ui` + * directory and currently expects the bundle entry module to be `index.js`. + */ +export interface PluginUiContributionMetadata { + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +} + +// --------------------------------------------------------------------------- +// Service interface +// --------------------------------------------------------------------------- + +export interface PluginLoader { + /** + * Discover all available plugins from configured sources. + * + * This performs a non-destructive scan of all enabled sources and returns + * the discovered plugins with their parsed manifests. No installs or DB + * writes happen during discovery. + * + * @param npmSearchDirs - Optional override for node_modules directories to search. + * Passed through to discoverFromNpm. When omitted the defaults are used. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + discoverAll(npmSearchDirs?: string[]): Promise; + + /** + * Scan the local filesystem plugin directory for installed plugin packages. + * + * Reads the plugin directory, attempts to load each subdirectory as an npm + * package, and validates the plugin manifest. + * + * @param dir - Directory to scan (defaults to configured localPluginDir). + */ + discoverFromLocalFilesystem(dir?: string): Promise; + + /** + * Discover Paperclip plugins installed as npm packages in the current + * Node.js environment matching the "paperclip-plugin-*" naming convention. + * + * Looks for packages in node_modules that match the naming convention. + * + * @param searchDirs - node_modules directories to search (defaults to process cwd resolution). + */ + discoverFromNpm(searchDirs?: string[]): Promise; + + /** + * Load and parse the plugin manifest from a package directory. + * + * Reads the package.json, finds the manifest entrypoint declared under + * the "paperclipPlugin.manifest" key, loads the manifest module, and + * validates it against the plugin manifest schema. + * + * Returns null if the package is not a Paperclip plugin. + * Throws if the package is a Paperclip plugin but the manifest is invalid. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ + loadManifest(packagePath: string): Promise; + + /** + * Install a plugin package and register it in the database. + * + * Follows the install process described in PLUGIN_SPEC.md §8.3: + * 1. Resolve npm package / local path. + * 2. Install into the plugin directory (npm install). + * 3. Read and validate plugin manifest. + * 4. Reject incompatible plugin API versions. + * 5. Validate manifest capabilities. + * 6. Persist install record in Postgres. + * 7. Return the discovered plugin for the caller to use. + * + * Worker spawning and lifecycle management are handled by the caller + * (pluginLifecycleManager and the server startup orchestration). + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + installPlugin(options: PluginInstallOptions): Promise; + + /** + * Upgrade an already-installed plugin to a newer version. + * + * Similar to installPlugin, but: + * 1. Requires the plugin to already exist in the database. + * 2. Uses the existing packageName if not provided in options. + * 3. Updates the existing plugin record instead of creating a new one. + * 4. Returns the old and new manifests for capability comparison. + * + * @see PLUGIN_SPEC.md §25.3 — Upgrade Lifecycle + */ + upgradePlugin(pluginId: string, options: Omit): Promise<{ + oldManifest: PaperclipPluginManifestV1; + newManifest: PaperclipPluginManifestV1; + discovered: DiscoveredPlugin; + }>; + + /** + * Check whether a plugin API version is supported by this host. + */ + isSupportedApiVersion(apiVersion: number): boolean; + + /** + * Get the local plugin directory this loader is configured to use. + */ + getLocalPluginDir(): string; + + // ----------------------------------------------------------------------- + // Runtime initialization (requires PluginRuntimeServices) + // ----------------------------------------------------------------------- + + /** + * Load and activate all plugins that are in `ready` status. + * + * This is the main server-startup orchestration method. For each plugin + * that is persisted as `ready`, it: + * 1. Resolves the worker entrypoint from the manifest. + * 2. Spawns the worker process via the worker manager. + * 3. Syncs job declarations from the manifest to the `plugin_jobs` table. + * 4. Registers the plugin with the job scheduler. + * 5. Registers event subscriptions declared in the manifest (scoped via the event bus). + * 6. Registers agent tools from the manifest via the tool dispatcher. + * + * Plugins that fail to activate are marked as `error` in the database. + * Activation failures are non-fatal — other plugins continue loading. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * Throws if runtime services are not available. + * + * @returns Aggregated results for all attempted plugin loads. + * + * @see PLUGIN_SPEC.md §8.4 — Server-Start Plugin Loading + * @see PLUGIN_SPEC.md §12 — Process Model + */ + loadAll(): Promise; + + /** + * Activate a single plugin that is in `installed` or `ready` status. + * + * Used after a fresh install (POST /api/plugins/install) or after + * enabling a previously disabled plugin. Performs the same subsystem + * registration as `loadAll()` but for a single plugin. + * + * If the plugin is in `installed` status, transitions it to `ready` + * via the lifecycle manager before spawning the worker. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * + * @param pluginId - UUID of the plugin to activate + * @returns The activation result for this plugin + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + loadSingle(pluginId: string): Promise; + + /** + * Deactivate a single plugin — stop its worker and unregister all + * subsystem registrations (events, jobs, tools). + * + * Used during plugin disable, uninstall, and before upgrade. Does NOT + * change the plugin's status in the database — that is the caller's + * responsibility (via the lifecycle manager). + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * + * @param pluginId - UUID of the plugin to deactivate + * @param pluginKey - The plugin key (manifest ID) for scoped cleanup + * + * @see PLUGIN_SPEC.md §8.5 — Uninstall Process + */ + unloadSingle(pluginId: string, pluginKey: string): Promise; + + /** + * Stop all managed plugin workers. Called during server shutdown. + * + * Stops the job scheduler and then stops all workers via the worker + * manager. Does NOT change plugin statuses in the database — plugins + * remain in `ready` so they are restarted on next boot. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + */ + shutdownAll(): Promise; + + /** + * Whether runtime services are available for plugin activation. + */ + hasRuntimeServices(): boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Check whether a package name matches the Paperclip plugin naming convention. + * Accepts both the "paperclip-plugin-" prefix and scoped "@scope/plugin-" packages. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +export function isPluginPackageName(name: string): boolean { + if (name.startsWith(NPM_PLUGIN_PACKAGE_PREFIX)) return true; + // Also accept scoped packages like @acme/plugin-linear or @paperclipai/plugin-* + if (name.includes("/")) { + const localPart = name.split("/")[1] ?? ""; + return localPart.startsWith("plugin-"); + } + return false; +} + +/** + * Read and parse a package.json from a directory path. + * Returns null if no package.json exists. + */ +async function readPackageJson( + dir: string, +): Promise | null> { + const pkgPath = path.join(dir, "package.json"); + if (!existsSync(pkgPath)) return null; + + try { + const raw = await readFile(pkgPath, "utf-8"); + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +/** + * Resolve the manifest entrypoint from a package.json and package root. + * + * The spec defines a "paperclipPlugin" key in package.json with a "manifest" + * subkey pointing to the manifest module. This helper resolves the path. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +function resolveManifestPath( + packageRoot: string, + pkgJson: Record, +): string | null { + const paperclipPlugin = pkgJson["paperclipPlugin"]; + if ( + paperclipPlugin !== null && + typeof paperclipPlugin === "object" && + !Array.isArray(paperclipPlugin) + ) { + const manifestRelPath = (paperclipPlugin as Record)[ + "manifest" + ]; + if (typeof manifestRelPath === "string") { + // NOTE: the resolved path is returned as-is even if the file does not yet + // exist on disk (e.g. the package has not been built). Callers MUST guard + // with existsSync() before passing the path to loadManifestFromPath(). + return path.resolve(packageRoot, manifestRelPath); + } + } + + // Fallback: look for dist/manifest.js as a convention + const conventionalPath = path.join(packageRoot, "dist", "manifest.js"); + if (existsSync(conventionalPath)) { + return conventionalPath; + } + + // Fallback: look for manifest.js at package root + const rootManifestPath = path.join(packageRoot, "manifest.js"); + if (existsSync(rootManifestPath)) { + return rootManifestPath; + } + + return null; +} + +function parseSemver(version: string): ParsedSemver | null { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) return null; + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ? match[4].split(".") : [], + }; +} + +function compareIdentifiers(left: string, right: string): number { + const leftIsNumeric = /^\d+$/.test(left); + const rightIsNumeric = /^\d+$/.test(right); + + if (leftIsNumeric && rightIsNumeric) { + return Number(left) - Number(right); + } + + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + return left.localeCompare(right); +} + +function compareSemver(left: string, right: string): number { + const leftParsed = parseSemver(left); + const rightParsed = parseSemver(right); + + if (!leftParsed || !rightParsed) { + throw new Error(`Invalid semver comparison: '${left}' vs '${right}'`); + } + + const coreOrder = ( + ["major", "minor", "patch"] as const + ).map((key) => leftParsed[key] - rightParsed[key]).find((delta) => delta !== 0); + if (coreOrder) { + return coreOrder; + } + + if (leftParsed.prerelease.length === 0 && rightParsed.prerelease.length === 0) { + return 0; + } + if (leftParsed.prerelease.length === 0) return 1; + if (rightParsed.prerelease.length === 0) return -1; + + const maxLength = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length); + for (let index = 0; index < maxLength; index += 1) { + const leftId = leftParsed.prerelease[index]; + const rightId = rightParsed.prerelease[index]; + if (leftId === undefined) return -1; + if (rightId === undefined) return 1; + + const diff = compareIdentifiers(leftId, rightId); + if (diff !== 0) return diff; + } + + return 0; +} + +function getMinimumHostVersion(manifest: PaperclipPluginManifestV1): string | undefined { + return manifest.minimumHostVersion ?? manifest.minimumPaperclipVersion; +} + +/** + * Extract UI contribution metadata from a manifest for route serialization. + * + * Returns `null` when the plugin does not declare any UI slots or launchers. + * Launcher declarations are aggregated from both the legacy top-level + * `launchers` field and the preferred `ui.launchers` field. + */ +export function getPluginUiContributionMetadata( + manifest: PaperclipPluginManifestV1, +): PluginUiContributionMetadata | null { + const slots = manifest.ui?.slots ?? []; + const launchers = [ + ...(manifest.launchers ?? []), + ...(manifest.ui?.launchers ?? []), + ]; + + if (slots.length === 0 && launchers.length === 0) { + return null; + } + + return { + uiEntryFile: "index.js", + slots, + launchers, + }; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a PluginLoader service. + * + * The loader is responsible for plugin discovery, installation, and runtime + * activation. It reads plugin packages from the local filesystem and npm, + * validates their manifests, registers them in the database, and — when + * runtime services are provided — initialises worker processes, event + * subscriptions, job schedules, webhook endpoints, and agent tools. + * + * Usage (discovery & install only): + * ```ts + * const loader = pluginLoader(db, { enableLocalFilesystem: true }); + * + * // Discover all available plugins + * const result = await loader.discoverAll(); + * for (const plugin of result.discovered) { + * console.log(plugin.packageName, plugin.manifest?.id); + * } + * + * // Install a specific plugin + * const discovered = await loader.installPlugin({ + * packageName: "paperclip-plugin-linear", + * version: "^1.0.0", + * }); + * ``` + * + * Usage (full runtime activation at server startup): + * ```ts + * const loader = pluginLoader(db, loaderOpts, { + * workerManager, + * eventBus, + * jobScheduler, + * jobStore, + * toolDispatcher, + * lifecycleManager, + * buildHostHandlers: (pluginId, manifest) => ({ ... }), + * instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" }, + * }); + * + * // Load all ready plugins at startup + * const loadResult = await loader.loadAll(); + * console.log(`Loaded ${loadResult.succeeded}/${loadResult.total} plugins`); + * + * // Load a single plugin after install + * const singleResult = await loader.loadSingle(pluginId); + * + * // Shutdown all plugin workers on server exit + * await loader.shutdownAll(); + * ``` + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + * @see PLUGIN_SPEC.md §8.3 — Install Process + * @see PLUGIN_SPEC.md §12 — Process Model + */ +export function pluginLoader( + db: Db, + options: PluginLoaderOptions = {}, + runtimeServices?: PluginRuntimeServices, +): PluginLoader { + const { + localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR, + enableLocalFilesystem = true, + enableNpmDiscovery = true, + } = options; + + const registry = pluginRegistryService(db); + const manifestValidator = pluginManifestValidator(); + const capabilityValidator = pluginCapabilityValidator(); + const log = logger.child({ service: "plugin-loader" }); + const hostVersion = runtimeServices?.instanceInfo.hostVersion; + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Fetch a plugin from npm or local path, then parse and validate its manifest. + * + * This internal helper encapsulates the core plugin retrieval and validation + * logic used by both install and upgrade operations. It handles: + * 1. Resolving the package from npm or local filesystem. + * 2. Installing the package via npm if necessary. + * 3. Reading and parsing the plugin manifest. + * 4. Validating API version compatibility. + * 5. Validating manifest capabilities. + * + * @param installOptions - Options specifying the package to fetch. + * @returns A `DiscoveredPlugin` object containing the validated manifest. + */ + async function fetchAndValidate( + installOptions: PluginInstallOptions, + ): Promise { + const { packageName, localPath, version, installDir } = installOptions; + + if (!packageName && !localPath) { + throw new Error("Either packageName or localPath must be provided"); + } + + const targetInstallDir = installDir ?? localPluginDir; + + // Step 1 & 2: Resolve and install package + let resolvedPackagePath: string; + let resolvedPackageName: string; + + if (localPath) { + // Local path install — validate the directory exists + const absLocalPath = path.resolve(localPath); + if (!existsSync(absLocalPath)) { + throw new Error(`Local plugin path does not exist: ${absLocalPath}`); + } + resolvedPackagePath = absLocalPath; + const pkgJson = await readPackageJson(absLocalPath); + resolvedPackageName = + typeof pkgJson?.["name"] === "string" + ? pkgJson["name"] + : path.basename(absLocalPath); + + log.info( + { localPath: absLocalPath, packageName: resolvedPackageName }, + "plugin-loader: fetching plugin from local path", + ); + } else { + // npm install + const spec = version ? `${packageName}@${version}` : packageName!; + + log.info( + { spec, installDir: targetInstallDir }, + "plugin-loader: fetching plugin from npm", + ); + + try { + // Use execFile (not exec) to avoid shell injection from package name/version. + // --ignore-scripts prevents preinstall/install/postinstall hooks from + // executing arbitrary code on the host before manifest validation. + await execFileAsync( + "npm", + ["install", spec, "--prefix", targetInstallDir, "--save", "--ignore-scripts"], + { timeout: 120_000 }, // 2 minute timeout for npm install + ); + } catch (err) { + throw new Error(`npm install failed for ${spec}: ${String(err)}`); + } + + // Resolve the package path after installation + const nodeModulesPath = path.join(targetInstallDir, "node_modules"); + resolvedPackageName = packageName!; + + // Handle scoped packages + if (resolvedPackageName.startsWith("@")) { + const [scope, name] = resolvedPackageName.split("/"); + resolvedPackagePath = path.join(nodeModulesPath, scope!, name!); + } else { + resolvedPackagePath = path.join(nodeModulesPath, resolvedPackageName); + } + + if (!existsSync(resolvedPackagePath)) { + throw new Error( + `Package directory not found after installation: ${resolvedPackagePath}`, + ); + } + } + + // Step 3: Read and validate plugin manifest + // Note: this.loadManifest (used via current context) + const pkgJson = await readPackageJson(resolvedPackagePath); + if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); + + const manifestPath = resolveManifestPath(resolvedPackagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) { + throw new Error( + `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).`, + ); + } + + const manifest = await loadManifestFromPath(manifestPath); + + // Step 4: Reject incompatible plugin API versions + if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) { + throw new Error( + `Plugin ${manifest.id} declares apiVersion ${manifest.apiVersion} which is not supported by this host. ` + + `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, + ); + } + + // Step 5: Validate manifest capabilities are consistent + const capResult = capabilityValidator.validateManifestCapabilities(manifest); + if (!capResult.allowed) { + throw new Error( + `Plugin ${manifest.id} manifest has inconsistent capabilities. ` + + `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, + ); + } + + // Step 6: Reject plugins that require a newer host than the running server + const minimumHostVersion = getMinimumHostVersion(manifest); + if (minimumHostVersion && hostVersion) { + if (compareSemver(hostVersion, minimumHostVersion) < 0) { + throw new Error( + `Plugin ${manifest.id} requires host version ${minimumHostVersion} or newer, ` + + `but this server is running ${hostVersion}`, + ); + } + } + + // Use the version declared in the manifest (required field per the spec) + const resolvedVersion = manifest.version; + + return { + packagePath: resolvedPackagePath, + packageName: resolvedPackageName, + version: resolvedVersion, + source: localPath ? "local-filesystem" : "npm", + manifest, + }; + } + + /** + * Attempt to load and validate a plugin manifest from a resolved path. + * Returns the manifest on success or throws with a descriptive error. + */ + async function loadManifestFromPath( + manifestPath: string, + ): Promise { + let raw: unknown; + + try { + // Dynamic import works for both .js (ESM) and .cjs (CJS) manifests + const mod = await import(manifestPath) as Record; + // The manifest may be the default export or the module itself + raw = mod["default"] ?? mod; + } catch (err) { + throw new Error( + `Failed to load manifest module at ${manifestPath}: ${String(err)}`, + ); + } + + return manifestValidator.parseOrThrow(raw); + } + + /** + * Build a DiscoveredPlugin from a resolved package directory, or null + * if the package is not a Paperclip plugin. + */ + async function buildDiscoveredPlugin( + packagePath: string, + source: PluginSource, + ): Promise { + const pkgJson = await readPackageJson(packagePath); + if (!pkgJson) return null; + + const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; + const version = typeof pkgJson["version"] === "string" ? pkgJson["version"] : "0.0.0"; + + // Determine if this is a plugin package at all + const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; + const nameMatchesConvention = isPluginPackageName(packageName); + + if (!hasPaperclipPlugin && !nameMatchesConvention) { + return null; + } + + const manifestPath = resolveManifestPath(packagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) { + // Found a potential plugin package but no manifest entry point — treat + // as a discovery-only result with no manifest + return { + packagePath, + packageName, + version, + source, + manifest: null, + }; + } + + try { + const manifest = await loadManifestFromPath(manifestPath); + return { + packagePath, + packageName, + version, + source, + manifest, + }; + } catch (err) { + // Rethrow with context — callers catch and route to the errors array + throw new Error( + `Plugin ${packageName}: ${String(err)}`, + ); + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + return { + // ----------------------------------------------------------------------- + // discoverAll + // ----------------------------------------------------------------------- + + async discoverAll(npmSearchDirs?: string[]): Promise { + const allDiscovered: DiscoveredPlugin[] = []; + const allErrors: Array<{ packagePath: string; packageName: string; error: string }> = []; + const sources: PluginSource[] = []; + + if (enableLocalFilesystem) { + sources.push("local-filesystem"); + const fsResult = await this.discoverFromLocalFilesystem(); + allDiscovered.push(...fsResult.discovered); + allErrors.push(...fsResult.errors); + } + + if (enableNpmDiscovery) { + sources.push("npm"); + const npmResult = await this.discoverFromNpm(npmSearchDirs); + // Deduplicate against already-discovered packages (same package path) + const existingPaths = new Set(allDiscovered.map((d) => d.packagePath)); + for (const plugin of npmResult.discovered) { + if (!existingPaths.has(plugin.packagePath)) { + allDiscovered.push(plugin); + } + } + allErrors.push(...npmResult.errors); + } + + // Future: registry source (options.registryUrl) + if (options.registryUrl) { + sources.push("registry"); + log.warn( + { registryUrl: options.registryUrl }, + "plugin-loader: remote registry discovery is not yet implemented", + ); + } + + log.info( + { + discovered: allDiscovered.length, + errors: allErrors.length, + sources, + }, + "plugin-loader: discovery complete", + ); + + return { discovered: allDiscovered, errors: allErrors, sources }; + }, + + // ----------------------------------------------------------------------- + // discoverFromLocalFilesystem + // ----------------------------------------------------------------------- + + async discoverFromLocalFilesystem(dir?: string): Promise { + const scanDir = dir ?? localPluginDir; + const discovered: DiscoveredPlugin[] = []; + const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; + + if (!existsSync(scanDir)) { + log.debug( + { dir: scanDir }, + "plugin-loader: local plugin directory does not exist, skipping", + ); + return { discovered, errors, sources: ["local-filesystem"] }; + } + + let entries: string[]; + try { + entries = await readdir(scanDir); + } catch (err) { + log.warn({ dir: scanDir, err }, "plugin-loader: failed to read local plugin directory"); + return { discovered, errors, sources: ["local-filesystem"] }; + } + + for (const entry of entries) { + const entryPath = path.join(scanDir, entry); + + // Check if entry is a directory + let entryStat; + try { + entryStat = await stat(entryPath); + } catch { + continue; + } + if (!entryStat.isDirectory()) continue; + + // Handle scoped packages: @scope/plugin-name is a subdirectory + if (entry.startsWith("@")) { + let scopedEntries: string[]; + try { + scopedEntries = await readdir(entryPath); + } catch { + continue; + } + for (const scopedEntry of scopedEntries) { + const scopedPath = path.join(entryPath, scopedEntry); + try { + const scopedStat = await stat(scopedPath); + if (!scopedStat.isDirectory()) continue; + const plugin = await buildDiscoveredPlugin(scopedPath, "local-filesystem"); + if (plugin) discovered.push(plugin); + } catch (err) { + errors.push({ + packagePath: scopedPath, + packageName: `${entry}/${scopedEntry}`, + error: String(err), + }); + } + } + continue; + } + + try { + const plugin = await buildDiscoveredPlugin(entryPath, "local-filesystem"); + if (plugin) discovered.push(plugin); + } catch (err) { + const pkgJson = await readPackageJson(entryPath); + const packageName = + typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; + errors.push({ packagePath: entryPath, packageName, error: String(err) }); + } + } + + log.debug( + { dir: scanDir, discovered: discovered.length, errors: errors.length }, + "plugin-loader: local filesystem scan complete", + ); + + return { discovered, errors, sources: ["local-filesystem"] }; + }, + + // ----------------------------------------------------------------------- + // discoverFromNpm + // ----------------------------------------------------------------------- + + async discoverFromNpm(searchDirs?: string[]): Promise { + const discovered: DiscoveredPlugin[] = []; + const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; + + // Determine the node_modules directories to search. + // When searchDirs is undefined OR empty, fall back to the conventional + // defaults (cwd/node_modules and localPluginDir/node_modules). + // To search nowhere explicitly, pass a non-empty array of non-existent paths. + const dirsToSearch: string[] = searchDirs && searchDirs.length > 0 ? searchDirs : []; + + if (dirsToSearch.length === 0) { + // Default: search node_modules relative to the process working directory + // and also the local plugin dir's node_modules + const cwdNodeModules = path.join(process.cwd(), "node_modules"); + const localNodeModules = path.join(localPluginDir, "node_modules"); + + if (existsSync(cwdNodeModules)) dirsToSearch.push(cwdNodeModules); + if (existsSync(localNodeModules)) dirsToSearch.push(localNodeModules); + } + + for (const nodeModulesDir of dirsToSearch) { + if (!existsSync(nodeModulesDir)) continue; + + let entries: string[]; + try { + entries = await readdir(nodeModulesDir); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(nodeModulesDir, entry); + + // Handle scoped packages (@scope/*) + if (entry.startsWith("@")) { + let scopedEntries: string[]; + try { + scopedEntries = await readdir(entryPath); + } catch { + continue; + } + for (const scopedEntry of scopedEntries) { + const fullName = `${entry}/${scopedEntry}`; + if (!isPluginPackageName(fullName)) continue; + + const scopedPath = path.join(entryPath, scopedEntry); + try { + const plugin = await buildDiscoveredPlugin(scopedPath, "npm"); + if (plugin) discovered.push(plugin); + } catch (err) { + errors.push({ + packagePath: scopedPath, + packageName: fullName, + error: String(err), + }); + } + } + continue; + } + + // Non-scoped packages: check naming convention + if (!isPluginPackageName(entry)) continue; + + let entryStat; + try { + entryStat = await stat(entryPath); + } catch { + continue; + } + if (!entryStat.isDirectory()) continue; + + try { + const plugin = await buildDiscoveredPlugin(entryPath, "npm"); + if (plugin) discovered.push(plugin); + } catch (err) { + const pkgJson = await readPackageJson(entryPath); + const packageName = + typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; + errors.push({ packagePath: entryPath, packageName, error: String(err) }); + } + } + } + + log.debug( + { searchDirs: dirsToSearch, discovered: discovered.length, errors: errors.length }, + "plugin-loader: npm discovery scan complete", + ); + + return { discovered, errors, sources: ["npm"] }; + }, + + // ----------------------------------------------------------------------- + // loadManifest + // ----------------------------------------------------------------------- + + async loadManifest(packagePath: string): Promise { + const pkgJson = await readPackageJson(packagePath); + if (!pkgJson) return null; + + const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; + const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; + const nameMatchesConvention = isPluginPackageName(packageName); + + if (!hasPaperclipPlugin && !nameMatchesConvention) { + return null; + } + + const manifestPath = resolveManifestPath(packagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) return null; + + return loadManifestFromPath(manifestPath); + }, + + // ----------------------------------------------------------------------- + // installPlugin + // ----------------------------------------------------------------------- + + async installPlugin(installOptions: PluginInstallOptions): Promise { + const discovered = await fetchAndValidate(installOptions); + + // Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved) + await registry.install( + { + packageName: discovered.packageName, + packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, + }, + discovered.manifest!, + ); + + log.info( + { + pluginId: discovered.manifest!.id, + packageName: discovered.packageName, + version: discovered.version, + capabilities: discovered.manifest!.capabilities, + }, + "plugin-loader: plugin installed successfully", + ); + + return discovered; + }, + + // ----------------------------------------------------------------------- + // upgradePlugin + // ----------------------------------------------------------------------- + + /** + * Upgrade an already-installed plugin to a newer version. + * + * This method: + * 1. Fetches and validates the new plugin package using `fetchAndValidate`. + * 2. Ensures the new manifest ID matches the existing plugin ID for safety. + * 3. Updates the plugin record in the registry with the new version and manifest. + * + * @param pluginId - The UUID of the plugin to upgrade. + * @param upgradeOptions - Options for the upgrade (packageName, localPath, version). + * @returns The old and new manifests, along with the discovery metadata. + * @throws {Error} If the plugin is not found or if the new manifest ID differs. + */ + async upgradePlugin( + pluginId: string, + upgradeOptions: Omit, + ): Promise<{ + oldManifest: PaperclipPluginManifestV1; + newManifest: PaperclipPluginManifestV1; + discovered: DiscoveredPlugin; + }> { + const plugin = (await registry.getById(pluginId)) as { + id: string; + packageName: string; + manifestJson: PaperclipPluginManifestV1; + } | null; + if (!plugin) throw new Error(`Plugin not found: ${pluginId}`); + + const oldManifest = plugin.manifestJson; + const { + packageName = plugin.packageName, + localPath, + version, + } = upgradeOptions; + + log.info( + { pluginId, packageName, version, localPath }, + "plugin-loader: upgrading plugin", + ); + + // 1. Fetch/Install the new version + const discovered = await fetchAndValidate({ + packageName, + localPath, + version, + installDir: localPluginDir, + }); + + const newManifest = discovered.manifest!; + + // 2. Validate it's the same plugin ID + if (newManifest.id !== oldManifest.id) { + throw new Error( + `Upgrade failed: new manifest ID '${newManifest.id}' does not match existing plugin ID '${oldManifest.id}'`, + ); + } + + // 3. Detect capability escalation — new capabilities not in the old manifest + const oldCaps = new Set(oldManifest.capabilities ?? []); + const newCaps = newManifest.capabilities ?? []; + const escalated = newCaps.filter((c) => !oldCaps.has(c)); + + if (escalated.length > 0) { + log.warn( + { pluginId, escalated, oldVersion: oldManifest.version, newVersion: newManifest.version }, + "plugin-loader: upgrade introduces new capabilities — requires admin approval", + ); + throw new Error( + `Upgrade for "${pluginId}" introduces new capabilities that require approval: ${escalated.join(", ")}. ` + + `The previous version declared [${[...oldCaps].join(", ")}]. ` + + `Please review and approve the capability escalation before upgrading.`, + ); + } + + // 4. Update the existing record + await registry.update(pluginId, { + packageName: discovered.packageName, + version: discovered.version, + manifest: newManifest, + }); + + return { + oldManifest, + newManifest, + discovered, + }; + }, + + // ----------------------------------------------------------------------- + // isSupportedApiVersion + // ----------------------------------------------------------------------- + + isSupportedApiVersion(apiVersion: number): boolean { + return manifestValidator.getSupportedVersions().includes(apiVersion); + }, + + // ----------------------------------------------------------------------- + // getLocalPluginDir + // ----------------------------------------------------------------------- + + getLocalPluginDir(): string { + return localPluginDir; + }, + + // ----------------------------------------------------------------------- + // hasRuntimeServices + // ----------------------------------------------------------------------- + + hasRuntimeServices(): boolean { + return runtimeServices !== undefined; + }, + + // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // loadAll + // ----------------------------------------------------------------------- + + /** + * loadAll — Loads and activates all plugins that are currently in 'ready' status. + * + * This method is typically called during server startup. It fetches all ready + * plugins from the registry and attempts to activate them in parallel using + * Promise.allSettled. Failures in individual plugins do not prevent others from loading. + * + * @returns A promise that resolves with summary statistics of the load operation. + */ + async loadAll(): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot loadAll: no PluginRuntimeServices provided. " + + "Pass runtime services as the third argument to pluginLoader().", + ); + } + + log.info("plugin-loader: loading all ready plugins"); + + // Fetch all plugins in ready status, ordered by installOrder + const readyPlugins = (await registry.listByStatus("ready")) as PluginRecord[]; + + if (readyPlugins.length === 0) { + log.info("plugin-loader: no ready plugins to load"); + return { total: 0, succeeded: 0, failed: 0, results: [] }; + } + + log.info( + { count: readyPlugins.length }, + "plugin-loader: found ready plugins to load", + ); + + // Load plugins in parallel + const results = await Promise.allSettled( + readyPlugins.map((plugin) => activatePlugin(plugin)) + ); + + const loadResults = results.map((r, i) => { + if (r.status === "fulfilled") return r.value; + return { + plugin: readyPlugins[i]!, + success: false, + error: String(r.reason), + registered: { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }; + }); + + const succeeded = loadResults.filter((r) => r.success).length; + const failed = loadResults.filter((r) => !r.success).length; + + log.info( + { + total: readyPlugins.length, + succeeded, + failed, + }, + "plugin-loader: loadAll complete", + ); + + return { + total: readyPlugins.length, + succeeded, + failed, + results: loadResults, + }; + }, + + // ----------------------------------------------------------------------- + // loadSingle + // ----------------------------------------------------------------------- + + /** + * loadSingle — Loads and activates a single plugin by its ID. + * + * This method retrieves the plugin from the registry, ensures it's in a valid + * state, and then calls activatePlugin to start its worker and register its + * capabilities (tools, jobs, etc.). + * + * @param pluginId - The UUID of the plugin to load. + * @returns A promise that resolves with the result of the activation. + */ + async loadSingle(pluginId: string): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot loadSingle: no PluginRuntimeServices provided. " + + "Pass runtime services as the third argument to pluginLoader().", + ); + } + + const plugin = (await registry.getById(pluginId)) as PluginRecord | null; + if (!plugin) { + throw new Error(`Plugin not found: ${pluginId}`); + } + + // If the plugin is in 'installed' status, transition it to 'ready' first. + // lifecycleManager.load() transitions the status AND activates the plugin + // via activateReadyPlugin() → loadSingle() (recursive call with 'ready' + // status) → activatePlugin(). We must NOT call activatePlugin() again here, + // as that would double-start the worker and duplicate registrations. + if (plugin.status === "installed") { + await runtimeServices.lifecycleManager.load(pluginId); + const updated = (await registry.getById(pluginId)) as PluginRecord | null; + if (!updated) throw new Error(`Plugin not found after status update: ${pluginId}`); + return { + plugin: updated, + success: true, + registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }; + } + + if (plugin.status !== "ready") { + throw new Error( + `Cannot load plugin in status '${plugin.status}'. ` + + `Plugin must be in 'installed' or 'ready' status.`, + ); + } + + return activatePlugin(plugin); + }, + + // ----------------------------------------------------------------------- + // unloadSingle + // ----------------------------------------------------------------------- + + async unloadSingle(pluginId: string, pluginKey: string): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot unloadSingle: no PluginRuntimeServices provided.", + ); + } + + log.info( + { pluginId, pluginKey }, + "plugin-loader: unloading single plugin", + ); + + const { + workerManager, + eventBus, + jobScheduler, + toolDispatcher, + } = runtimeServices; + + // 1. Unregister from job scheduler (cancels in-flight runs) + try { + await jobScheduler.unregisterPlugin(pluginId); + } catch (err) { + log.warn( + { pluginId, err: err instanceof Error ? err.message : String(err) }, + "plugin-loader: failed to unregister from job scheduler (best-effort)", + ); + } + + // 2. Clear event subscriptions + eventBus.clearPlugin(pluginKey); + + // 3. Unregister agent tools + toolDispatcher.unregisterPluginTools(pluginKey); + + // 4. Stop the worker process + try { + if (workerManager.isRunning(pluginId)) { + await workerManager.stopWorker(pluginId); + } + } catch (err) { + log.warn( + { pluginId, err: err instanceof Error ? err.message : String(err) }, + "plugin-loader: failed to stop worker during unload (best-effort)", + ); + } + + log.info( + { pluginId, pluginKey }, + "plugin-loader: plugin unloaded successfully", + ); + }, + + // ----------------------------------------------------------------------- + // shutdownAll + // ----------------------------------------------------------------------- + + async shutdownAll(): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot shutdownAll: no PluginRuntimeServices provided.", + ); + } + + log.info("plugin-loader: shutting down all plugins"); + + const { workerManager, jobScheduler } = runtimeServices; + + // 1. Stop the job scheduler tick loop + jobScheduler.stop(); + + // 2. Stop all worker processes + await workerManager.stopAll(); + + log.info("plugin-loader: all plugins shut down"); + }, + }; + + // ------------------------------------------------------------------------- + // Internal: activatePlugin — shared logic for loadAll and loadSingle + // ------------------------------------------------------------------------- + + /** + * Activate a single plugin: spawn its worker, register event subscriptions, + * sync jobs, register tools. + * + * This is the core orchestration logic shared by `loadAll()` and `loadSingle()`. + * Failures are caught and reported in the result; the plugin is marked as + * `error` in the database when activation fails. + */ + async function activatePlugin(plugin: PluginRecord): Promise { + const manifest = plugin.manifestJson; + const pluginId = plugin.id; + const pluginKey = plugin.pluginKey; + + const registered: PluginLoadResult["registered"] = { + worker: false, + eventSubscriptions: 0, + jobs: 0, + webhooks: 0, + tools: 0, + }; + + // Guard: runtime services must exist (callers already checked) + if (!runtimeServices) { + return { + plugin, + success: false, + error: "No runtime services available", + registered, + }; + } + + const { + workerManager, + eventBus, + jobScheduler, + jobStore, + toolDispatcher, + lifecycleManager, + buildHostHandlers, + instanceInfo, + } = runtimeServices; + + try { + log.info( + { pluginId, pluginKey, version: plugin.version }, + "plugin-loader: activating plugin", + ); + + // ------------------------------------------------------------------ + // 1. Resolve worker entrypoint + // ------------------------------------------------------------------ + const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); + + // ------------------------------------------------------------------ + // 2. Build host handlers for this plugin + // ------------------------------------------------------------------ + const hostHandlers = buildHostHandlers(pluginId, manifest); + + // ------------------------------------------------------------------ + // 3. Retrieve plugin config (if any) + // ------------------------------------------------------------------ + let config: Record = {}; + try { + const configRow = await registry.getConfig(pluginId); + if (configRow && typeof configRow === "object" && "configJson" in configRow) { + config = (configRow as { configJson: Record }).configJson ?? {}; + } + } catch { + // Config may not exist yet — use empty object + log.debug({ pluginId }, "plugin-loader: no config found, using empty config"); + } + + // ------------------------------------------------------------------ + // 4. Spawn worker process + // ------------------------------------------------------------------ + const workerOptions: WorkerStartOptions = { + entrypointPath: workerEntrypoint, + manifest, + config, + instanceInfo, + apiVersion: manifest.apiVersion, + hostHandlers, + autoRestart: true, + }; + + // Repo-local plugin installs can resolve workspace TS sources at runtime + // (for example @paperclipai/shared exports). Run those workers through + // the tsx loader so first-party example plugins work in development. + if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { + workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; + } + + await workerManager.startWorker(pluginId, workerOptions); + registered.worker = true; + + log.info( + { pluginId, pluginKey }, + "plugin-loader: worker started", + ); + + // ------------------------------------------------------------------ + // 5. Sync job declarations and register with scheduler + // ------------------------------------------------------------------ + const jobDeclarations = manifest.jobs ?? []; + if (jobDeclarations.length > 0) { + await jobStore.syncJobDeclarations(pluginId, jobDeclarations); + await jobScheduler.registerPlugin(pluginId); + registered.jobs = jobDeclarations.length; + + log.info( + { pluginId, pluginKey, jobs: jobDeclarations.length }, + "plugin-loader: job declarations synced and plugin registered with scheduler", + ); + } + + // ------------------------------------------------------------------ + // 6. Register event subscriptions + // + // Note: Event subscriptions are declared at runtime by the plugin + // worker via the SDK's ctx.events.on() calls. The event bus manages + // per-plugin subscription scoping. Here we ensure the event bus has + // a scoped handle ready for this plugin — the actual subscriptions + // are registered by the host handler layer when the worker calls + // events.subscribe via RPC. + // + // The bus.forPlugin() call creates the scoped handle if needed; + // any previous subscriptions for this plugin are preserved if the + // worker is restarting. + // ------------------------------------------------------------------ + const _scopedBus = eventBus.forPlugin(pluginKey); + registered.eventSubscriptions = eventBus.subscriptionCount(pluginKey); + + log.debug( + { pluginId, pluginKey }, + "plugin-loader: event bus scoped handle ready", + ); + + // ------------------------------------------------------------------ + // 7. Register webhook endpoints (manifest-declared) + // + // Webhooks are statically declared in the manifest. The actual + // endpoint routing is handled by the plugin routes module which + // checks the manifest for declared webhooks. No explicit + // registration step is needed here — the manifest is persisted + // in the DB and the route handler reads it at request time. + // + // We track the count for the result reporting. + // ------------------------------------------------------------------ + const webhookDeclarations = manifest.webhooks ?? []; + registered.webhooks = webhookDeclarations.length; + + if (webhookDeclarations.length > 0) { + log.info( + { pluginId, pluginKey, webhooks: webhookDeclarations.length }, + "plugin-loader: webhook endpoints declared in manifest", + ); + } + + // ------------------------------------------------------------------ + // 8. Register agent tools + // ------------------------------------------------------------------ + const toolDeclarations = manifest.tools ?? []; + if (toolDeclarations.length > 0) { + toolDispatcher.registerPluginTools(pluginKey, manifest); + registered.tools = toolDeclarations.length; + + log.info( + { pluginId, pluginKey, tools: toolDeclarations.length }, + "plugin-loader: agent tools registered", + ); + } + + // ------------------------------------------------------------------ + // Done — plugin fully activated + // ------------------------------------------------------------------ + log.info( + { + pluginId, + pluginKey, + version: plugin.version, + registered, + }, + "plugin-loader: plugin activated successfully", + ); + + return { plugin, success: true, registered }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + log.error( + { pluginId, pluginKey, err: errorMessage }, + "plugin-loader: failed to activate plugin", + ); + + // Mark the plugin as errored in the database so it is not retried + // automatically on next startup without operator intervention. + try { + await lifecycleManager.markError(pluginId, `Activation failed: ${errorMessage}`); + } catch (markErr) { + log.error( + { + pluginId, + err: markErr instanceof Error ? markErr.message : String(markErr), + }, + "plugin-loader: failed to mark plugin as error after activation failure", + ); + } + + return { + plugin, + success: false, + error: errorMessage, + registered, + }; + } + } +} + +// --------------------------------------------------------------------------- +// Worker entrypoint resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the absolute path to a plugin's worker entrypoint from its manifest + * and known install locations. + * + * The manifest `entrypoints.worker` field is relative to the package root. + * We check the local plugin directory (where the package was installed) and + * also the package directory if it was a local-path install. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +function resolveWorkerEntrypoint( + plugin: PluginRecord & { packagePath?: string | null }, + localPluginDir: string, +): string { + const manifest = plugin.manifestJson; + const workerRelPath = manifest.entrypoints.worker; + + // For local-path installs we persist the resolved package path; use it first + if (plugin.packagePath && existsSync(plugin.packagePath)) { + const entrypoint = path.resolve(plugin.packagePath, workerRelPath); + if (entrypoint.startsWith(path.resolve(plugin.packagePath)) && existsSync(entrypoint)) { + return entrypoint; + } + } + + // Try the local plugin directory (standard npm install location) + const packageName = plugin.packageName; + let packageDir: string; + + if (packageName.startsWith("@")) { + // Scoped package: @scope/plugin-name → localPluginDir/node_modules/@scope/plugin-name + const [scope, name] = packageName.split("/"); + packageDir = path.join(localPluginDir, "node_modules", scope!, name!); + } else { + packageDir = path.join(localPluginDir, "node_modules", packageName); + } + + // Also check if the package exists directly under localPluginDir + // (for direct local-path installs or symlinked packages) + const directDir = path.join(localPluginDir, packageName); + + // Try in order: node_modules path, direct path + for (const dir of [packageDir, directDir]) { + const entrypoint = path.resolve(dir, workerRelPath); + + // Security: ensure entrypoint is actually inside the directory (prevent path traversal) + if (!entrypoint.startsWith(path.resolve(dir))) { + continue; + } + + if (existsSync(entrypoint)) { + return entrypoint; + } + } + + // Fallback: try the worker path as-is (absolute or relative to cwd) + // ONLY if it's already an absolute path and we trust the manifest (which we've already validated) + if (path.isAbsolute(workerRelPath) && existsSync(workerRelPath)) { + return workerRelPath; + } + + throw new Error( + `Worker entrypoint not found for plugin "${plugin.pluginKey}". ` + + `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + + `${path.resolve(directDir, workerRelPath)}`, + ); +} diff --git a/server/src/services/plugin-log-retention.ts b/server/src/services/plugin-log-retention.ts new file mode 100644 index 00000000..832ddfd1 --- /dev/null +++ b/server/src/services/plugin-log-retention.ts @@ -0,0 +1,86 @@ +import { lt, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { pluginLogs } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +/** Default retention period: 7 days. */ +const DEFAULT_RETENTION_DAYS = 7; + +/** Maximum rows to delete per sweep to avoid long-running transactions. */ +const DELETE_BATCH_SIZE = 5_000; + +/** Maximum number of batches per sweep to guard against unbounded loops. */ +const MAX_ITERATIONS = 100; + +/** + * Delete plugin log rows older than `retentionDays`. + * + * Deletes in batches of `DELETE_BATCH_SIZE` to keep transaction sizes + * bounded and avoid holding locks for extended periods. + * + * @returns The total number of rows deleted. + */ +export async function prunePluginLogs( + db: Db, + retentionDays: number = DEFAULT_RETENTION_DAYS, +): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + let totalDeleted = 0; + let iterations = 0; + + // Delete in batches to avoid long-running transactions + while (iterations < MAX_ITERATIONS) { + const deleted = await db + .delete(pluginLogs) + .where(lt(pluginLogs.createdAt, cutoff)) + .returning({ id: pluginLogs.id }) + .then((rows) => rows.length); + + totalDeleted += deleted; + iterations++; + + if (deleted < DELETE_BATCH_SIZE) break; + } + + if (iterations >= MAX_ITERATIONS) { + logger.warn( + { totalDeleted, iterations, cutoffDate: cutoff }, + "Plugin log retention hit iteration limit; some logs may remain", + ); + } + + if (totalDeleted > 0) { + logger.info({ totalDeleted, retentionDays }, "Pruned expired plugin logs"); + } + + return totalDeleted; +} + +/** + * Start a periodic plugin log cleanup interval. + * + * @param db - Database connection + * @param intervalMs - How often to run (default: 1 hour) + * @param retentionDays - How many days of logs to keep (default: 7) + * @returns A cleanup function that stops the interval + */ +export function startPluginLogRetention( + db: Db, + intervalMs: number = 60 * 60 * 1_000, + retentionDays: number = DEFAULT_RETENTION_DAYS, +): () => void { + const timer = setInterval(() => { + prunePluginLogs(db, retentionDays).catch((err) => { + logger.warn({ err }, "Plugin log retention sweep failed"); + }); + }, intervalMs); + + // Run once immediately on startup + prunePluginLogs(db, retentionDays).catch((err) => { + logger.warn({ err }, "Initial plugin log retention sweep failed"); + }); + + return () => clearInterval(timer); +} diff --git a/server/src/services/plugin-manifest-validator.ts b/server/src/services/plugin-manifest-validator.ts new file mode 100644 index 00000000..9af6b61c --- /dev/null +++ b/server/src/services/plugin-manifest-validator.ts @@ -0,0 +1,163 @@ +/** + * PluginManifestValidator — schema validation for plugin manifest files. + * + * Uses the shared Zod schema (`pluginManifestV1Schema`) to validate + * manifest payloads. Provides both a safe `parse()` variant (returns + * a result union) and a throwing `parseOrThrow()` for HTTP error + * propagation at install time. + * + * @see PLUGIN_SPEC.md §10 — Plugin Manifest + * @see packages/shared/src/validators/plugin.ts — Zod schema definition + */ +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { PLUGIN_API_VERSION } from "@paperclipai/shared"; +import { badRequest } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Supported manifest API versions +// --------------------------------------------------------------------------- + +/** + * The set of plugin API versions this host can accept. + * When a new API version is introduced, add it here. Old versions should be + * retained until the host drops support for them. + */ +const SUPPORTED_VERSIONS = [PLUGIN_API_VERSION] as const; + +// --------------------------------------------------------------------------- +// Parse result types +// --------------------------------------------------------------------------- + +/** + * Successful parse result. + */ +export interface ManifestParseSuccess { + success: true; + manifest: PaperclipPluginManifestV1; +} + +/** + * Failed parse result. `errors` is a human-readable description of what went + * wrong; `details` is the raw Zod error list for programmatic inspection. + */ +export interface ManifestParseFailure { + success: false; + errors: string; + details: Array<{ path: (string | number)[]; message: string }>; +} + +/** Union of parse outcomes. */ +export type ManifestParseResult = ManifestParseSuccess | ManifestParseFailure; + +// --------------------------------------------------------------------------- +// PluginManifestValidator interface +// --------------------------------------------------------------------------- + +/** + * Service for parsing and validating plugin manifests. + * + * @see PLUGIN_SPEC.md §10 — Plugin Manifest + */ +export interface PluginManifestValidator { + /** + * Try to parse `input` as a plugin manifest. + * + * Returns a {@link ManifestParseSuccess} when the input passes all + * validation rules, or a {@link ManifestParseFailure} with human-readable + * error messages when it does not. + * + * This is the "safe" variant — it never throws. + */ + parse(input: unknown): ManifestParseResult; + + /** + * Parse `input` as a plugin manifest, throwing a 400 HttpError on failure. + * + * Use this at install time when an invalid manifest should surface as an + * HTTP error to the caller. + * + * @throws {HttpError} 400 Bad Request if the manifest is invalid. + */ + parseOrThrow(input: unknown): PaperclipPluginManifestV1; + + /** + * Return the list of plugin API versions supported by this host. + * + * Callers can use this to present the supported version range to operators + * or to decide whether a candidate plugin can be installed. + */ + getSupportedVersions(): readonly number[]; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a {@link PluginManifestValidator}. + * + * Usage: + * ```ts + * const validator = pluginManifestValidator(); + * + * // Safe parse — inspect the result + * const result = validator.parse(rawManifest); + * if (!result.success) { + * console.error(result.errors); + * return; + * } + * const manifest = result.manifest; + * + * // Throwing parse — use at install time + * const manifest = validator.parseOrThrow(rawManifest); + * + * // Check supported versions + * const versions = validator.getSupportedVersions(); // [1] + * ``` + */ +export function pluginManifestValidator(): PluginManifestValidator { + return { + parse(input: unknown): ManifestParseResult { + const result = pluginManifestV1Schema.safeParse(input); + + if (result.success) { + return { + success: true, + manifest: result.data as PaperclipPluginManifestV1, + }; + } + + const details = result.error.errors.map((issue) => ({ + path: issue.path, + message: issue.message, + })); + + const errors = details + .map(({ path, message }) => + path.length > 0 ? `${path.join(".")}: ${message}` : message, + ) + .join("; "); + + return { + success: false, + errors, + details, + }; + }, + + parseOrThrow(input: unknown): PaperclipPluginManifestV1 { + const result = this.parse(input); + + if (!result.success) { + throw badRequest(`Invalid plugin manifest: ${result.errors}`, result.details); + } + + return result.manifest; + }, + + getSupportedVersions(): readonly number[] { + return SUPPORTED_VERSIONS; + }, + }; +} diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts new file mode 100644 index 00000000..eb4495a5 --- /dev/null +++ b/server/src/services/plugin-registry.ts @@ -0,0 +1,963 @@ +import { asc, eq, ne, sql, and, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + plugins, + companies, + pluginConfig, + pluginCompanySettings, + pluginEntities, + pluginJobs, + pluginJobRuns, + pluginWebhookDeliveries, +} from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginStatus, + InstallPlugin, + UpdatePluginStatus, + UpsertPluginConfig, + PatchPluginConfig, + PluginCompanySettings, + CompanyPluginAvailability, + UpsertPluginCompanySettings, + UpdateCompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, + PluginJobStatus, + PluginJobRunStatus, + PluginJobRunTrigger, + PluginWebhookDeliveryStatus, +} from "@paperclipai/shared"; +import { conflict, notFound } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Detect if a Postgres error is a unique-constraint violation on the + * `plugins_plugin_key_idx` unique index. + */ +function isPluginKeyConflict(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const err = error as { code?: string; constraint?: string; constraint_name?: string }; + const constraint = err.constraint ?? err.constraint_name; + return err.code === "23505" && constraint === "plugins_plugin_key_idx"; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * PluginRegistry – CRUD operations for the `plugins` and `plugin_config` + * tables. Follows the same factory-function pattern used by the rest of + * the Paperclip service layer. + * + * This is the lowest-level persistence layer for plugins. Higher-level + * concerns such as lifecycle state-machine enforcement and capability + * gating are handled by {@link pluginLifecycleManager} and + * {@link pluginCapabilityValidator} respectively. + * + * @see PLUGIN_SPEC.md §21.3 — Required Tables + */ +export function pluginRegistryService(db: Db) { + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function getById(id: string) { + return db + .select() + .from(plugins) + .where(eq(plugins.id, id)) + .then((rows) => rows[0] ?? null); + } + + async function getByKey(pluginKey: string) { + return db + .select() + .from(plugins) + .where(eq(plugins.pluginKey, pluginKey)) + .then((rows) => rows[0] ?? null); + } + + async function nextInstallOrder(): Promise { + const result = await db + .select({ maxOrder: sql`coalesce(max(${plugins.installOrder}), 0)` }) + .from(plugins); + return (result[0]?.maxOrder ?? 0) + 1; + } + + /** + * Load the persisted company override row for a plugin, if one exists. + * + * Missing rows are meaningful: the company inherits the default-enabled + * behavior and the caller should treat the plugin as available. + */ + async function getCompanySettingsRow(companyId: string, pluginId: string) { + return db + .select() + .from(pluginCompanySettings) + .where(and( + eq(pluginCompanySettings.companyId, companyId), + eq(pluginCompanySettings.pluginId, pluginId), + )) + .then((rows) => rows[0] ?? null); + } + + /** + * Normalize registry records into the API response returned by company + * plugin availability routes. + * + * The key business rule is captured here: plugins are enabled for a company + * unless an explicit `plugin_company_settings.enabled = false` override says + * otherwise. + */ + function toCompanyAvailability( + companyId: string, + plugin: Awaited>, + settings: PluginCompanySettings | null, + ): CompanyPluginAvailability { + if (!plugin) { + throw notFound("Plugin not found"); + } + + return { + companyId, + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + pluginDisplayName: plugin.manifestJson.displayName, + pluginStatus: plugin.status, + available: settings?.enabled ?? true, + settingsJson: settings?.settingsJson ?? {}, + lastError: settings?.lastError ?? null, + createdAt: settings?.createdAt ?? null, + updatedAt: settings?.updatedAt ?? null, + }; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + // ----- Read ----------------------------------------------------------- + + /** List all registered plugins ordered by install order. */ + list: () => + db + .select() + .from(plugins) + .orderBy(asc(plugins.installOrder)), + + /** + * List installed plugins (excludes soft-deleted/uninstalled). + * Use for Plugin Manager and default API list so uninstalled plugins do not appear. + */ + listInstalled: () => + db + .select() + .from(plugins) + .where(ne(plugins.status, "uninstalled")) + .orderBy(asc(plugins.installOrder)), + + /** List plugins filtered by status. */ + listByStatus: (status: PluginStatus) => + db + .select() + .from(plugins) + .where(eq(plugins.status, status)) + .orderBy(asc(plugins.installOrder)), + + /** Get a single plugin by primary key. */ + getById, + + /** Get a single plugin by its unique `pluginKey`. */ + getByKey, + + // ----- Install / Register -------------------------------------------- + + /** + * Register (install) a new plugin. + * + * The caller is expected to have already resolved and validated the + * manifest from the package. This method persists the plugin row and + * assigns the next install order. + */ + install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => { + const existing = await getByKey(manifest.id); + if (existing) { + if (existing.status !== "uninstalled") { + throw conflict(`Plugin already installed: ${manifest.id}`); + } + + // Reinstall after soft-delete: reactivate the existing row so plugin-scoped + // data and references remain stable across uninstall/reinstall cycles. + return db + .update(plugins) + .set({ + packageName: input.packageName, + packagePath: input.packagePath ?? null, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + status: "installed" as PluginStatus, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(plugins.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + + const installOrder = await nextInstallOrder(); + + try { + const rows = await db + .insert(plugins) + .values({ + pluginKey: manifest.id, + packageName: input.packageName, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + status: "installed" as PluginStatus, + installOrder, + packagePath: input.packagePath ?? null, + }) + .returning(); + return rows[0]; + } catch (error) { + if (isPluginKeyConflict(error)) { + throw conflict(`Plugin already installed: ${manifest.id}`); + } + throw error; + } + }, + + // ----- Update --------------------------------------------------------- + + /** + * Update a plugin's manifest and version (e.g. on upgrade). + * The plugin must already exist. + */ + update: async ( + id: string, + data: { + packageName?: string; + version?: string; + manifest?: PaperclipPluginManifestV1; + }, + ) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + const setClause: Partial & { updatedAt: Date } = { + updatedAt: new Date(), + }; + if (data.packageName !== undefined) setClause.packageName = data.packageName; + if (data.version !== undefined) setClause.version = data.version; + if (data.manifest !== undefined) { + setClause.manifestJson = data.manifest; + setClause.apiVersion = data.manifest.apiVersion; + setClause.categories = data.manifest.categories; + } + + return db + .update(plugins) + .set(setClause) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Status --------------------------------------------------------- + + /** Update a plugin's lifecycle status and optional error message. */ + updateStatus: async (id: string, input: UpdatePluginStatus) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + return db + .update(plugins) + .set({ + status: input.status, + lastError: input.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Uninstall / Remove -------------------------------------------- + + /** + * Uninstall a plugin. + * + * When `removeData` is true the plugin row (and cascaded config) is + * hard-deleted. Otherwise the status is set to `"uninstalled"` for + * a soft-delete that preserves the record. + */ + uninstall: async (id: string, removeData = false) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + if (removeData) { + // Hard delete – plugin_config cascades via FK onDelete + return db + .delete(plugins) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + } + + // Soft delete – mark as uninstalled + return db + .update(plugins) + .set({ + status: "uninstalled" as PluginStatus, + updatedAt: new Date(), + }) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Config --------------------------------------------------------- + + /** Retrieve a plugin's instance configuration. */ + getConfig: (pluginId: string) => + db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null), + + /** + * Create or fully replace a plugin's instance configuration. + * If a config row already exists for the plugin it is replaced; + * otherwise a new row is inserted. + */ + upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginConfig) + .set({ + configJson: input.configJson, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginConfig) + .values({ + pluginId, + configJson: input.configJson, + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Partially update a plugin's instance configuration via shallow merge. + * If no config row exists yet one is created with the supplied values. + */ + patchConfig: async (pluginId: string, input: PatchPluginConfig) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null); + + if (existing) { + const merged = { ...existing.configJson, ...input.configJson }; + return db + .update(pluginConfig) + .set({ + configJson: merged, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginConfig) + .values({ + pluginId, + configJson: input.configJson, + }) + .returning() + .then((rows) => rows[0]); + }, + + // ----- Company-scoped settings ---------------------------------------- + + /** Retrieve a plugin's company-scoped settings row, if any. */ + getCompanySettings: (companyId: string, pluginId: string) => + getCompanySettingsRow(companyId, pluginId), + + /** Create or replace the company-scoped settings row for a plugin. */ + upsertCompanySettings: async ( + companyId: string, + pluginId: string, + input: UpsertPluginCompanySettings, + ) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + if (existing) { + return db + .update(pluginCompanySettings) + .set({ + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0]); + }, + + /** Delete the company-scoped settings row for a plugin if it exists. */ + deleteCompanySettings: async (companyId: string, pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + if (!existing) return null; + + return db + .delete(pluginCompanySettings) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + /** List normalized company-plugin availability records across installed plugins. */ + listCompanyAvailability: async ( + companyId: string, + filter?: { available?: boolean }, + ) => { + const installed = await db + .select() + .from(plugins) + .where(ne(plugins.status, "uninstalled")) + .orderBy(asc(plugins.installOrder)); + + const settingsRows = await db + .select() + .from(pluginCompanySettings) + .where(eq(pluginCompanySettings.companyId, companyId)); + + const settingsByPluginId = new Map(settingsRows.map((row) => [row.pluginId, row])); + const availability = installed.map((plugin) => { + const row = settingsByPluginId.get(plugin.id) ?? null; + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + }); + + if (filter?.available === undefined) return availability; + return availability.filter((item) => item.available === filter.available); + }, + + /** + * Batch-check which companies have this plugin explicitly disabled. + * Returns a Set of companyIds where `enabled = false`. Companies with + * no settings row default to enabled, so they are NOT in the result set. + */ + getDisabledCompanyIds: async (companyIds: string[], pluginId: string): Promise> => { + if (companyIds.length === 0) return new Set(); + const rows = await db + .select({ + companyId: pluginCompanySettings.companyId, + enabled: pluginCompanySettings.enabled, + }) + .from(pluginCompanySettings) + .where(and( + inArray(pluginCompanySettings.companyId, companyIds), + eq(pluginCompanySettings.pluginId, pluginId), + )); + const disabled = new Set(); + for (const row of rows) { + if (!row.enabled) disabled.add(row.companyId); + } + return disabled; + }, + + /** Get the normalized availability record for a single company/plugin pair. */ + getCompanyAvailability: async (companyId: string, pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") return null; + + const settings = await getCompanySettingsRow(companyId, pluginId); + return toCompanyAvailability(companyId, plugin, settings); + }, + + /** Update normalized company availability, persisting or deleting settings as needed. */ + updateCompanyAvailability: async ( + companyId: string, + pluginId: string, + input: UpdateCompanyPluginAvailability, + ) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + + if (!input.available) { + const row = await (existing + ? db + .update(pluginCompanySettings) + .set({ + enabled: false, + settingsJson: input.settingsJson ?? existing.settingsJson, + lastError: input.lastError ?? existing.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]) + : db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: false, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0])); + + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + } + + const row = await (existing + ? db + .update(pluginCompanySettings) + .set({ + enabled: true, + settingsJson: input.settingsJson ?? existing.settingsJson, + lastError: input.lastError ?? existing.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]) + : db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0])); + + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + }, + + /** + * Ensure all companies have an explicit enabled row for this plugin. + * + * Company availability defaults to enabled when no row exists, but this + * helper persists explicit `enabled=true` rows so newly-installed plugins + * appear as enabled immediately and consistently in company-scoped views. + */ + seedEnabledForAllCompanies: async (pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); + + const companyRows = await db + .select({ id: companies.id }) + .from(companies); + + if (companyRows.length === 0) return 0; + + const now = new Date(); + await db + .insert(pluginCompanySettings) + .values( + companyRows.map((company) => ({ + companyId: company.id, + pluginId, + enabled: true, + settingsJson: {}, + lastError: null, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [pluginCompanySettings.companyId, pluginCompanySettings.pluginId], + }); + + return companyRows.length; + }, + + /** + * Record an error against a plugin's config (e.g. validation failure + * against the plugin's instanceConfigSchema). + */ + setConfigError: async (pluginId: string, lastError: string | null) => { + const rows = await db + .update(pluginConfig) + .set({ lastError, updatedAt: new Date() }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning(); + + if (rows.length === 0) throw notFound("Plugin config not found"); + return rows[0]; + }, + + /** Delete a plugin's config row. */ + deleteConfig: async (pluginId: string) => { + const rows = await db + .delete(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning(); + + return rows[0] ?? null; + }, + + // ----- Entities ------------------------------------------------------- + + /** + * List persistent entity mappings owned by a specific plugin, with filtering and pagination. + * + * @param pluginId - The UUID of the plugin. + * @param query - Optional filters (type, externalId) and pagination (limit, offset). + * @returns A list of matching `PluginEntityRecord` objects. + */ + listEntities: (pluginId: string, query?: PluginEntityQuery) => { + const conditions = [eq(pluginEntities.pluginId, pluginId)]; + if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType)); + if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId)); + + return db + .select() + .from(pluginEntities) + .where(and(...conditions)) + .orderBy(asc(pluginEntities.createdAt)) + .limit(query?.limit ?? 100) + .offset(query?.offset ?? 0); + }, + + /** + * Look up a plugin-owned entity mapping by its external identifier. + * + * @param pluginId - The UUID of the plugin. + * @param entityType - The type of entity (e.g., 'project', 'issue'). + * @param externalId - The identifier in the external system. + * @returns The matching `PluginEntityRecord` or null. + */ + getEntityByExternalId: ( + pluginId: string, + entityType: string, + externalId: string, + ) => + db + .select() + .from(pluginEntities) + .where( + and( + eq(pluginEntities.pluginId, pluginId), + eq(pluginEntities.entityType, entityType), + eq(pluginEntities.externalId, externalId), + ), + ) + .then((rows) => rows[0] ?? null), + + /** + * Create or update a persistent mapping between a Paperclip object and an + * external entity. + * + * @param pluginId - The UUID of the plugin. + * @param input - The entity data to persist. + * @returns The newly created or updated `PluginEntityRecord`. + */ + upsertEntity: async ( + pluginId: string, + input: Omit, + ) => { + // Drizzle doesn't support pg-specific onConflictDoUpdate easily in the insert() call + // with complex where clauses, so we do it manually. + const existing = await db + .select() + .from(pluginEntities) + .where( + and( + eq(pluginEntities.pluginId, pluginId), + eq(pluginEntities.entityType, input.entityType), + eq(pluginEntities.externalId, input.externalId ?? ""), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginEntities) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(pluginEntities.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginEntities) + .values({ + ...input, + pluginId, + } as any) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Delete a specific plugin-owned entity mapping by its internal UUID. + * + * @param id - The UUID of the entity record. + * @returns The deleted record, or null if not found. + */ + deleteEntity: async (id: string) => { + const rows = await db + .delete(pluginEntities) + .where(eq(pluginEntities.id, id)) + .returning(); + return rows[0] ?? null; + }, + + // ----- Jobs ----------------------------------------------------------- + + /** + * List all scheduled jobs registered for a specific plugin. + * + * @param pluginId - The UUID of the plugin. + * @returns A list of `PluginJobRecord` objects. + */ + listJobs: (pluginId: string) => + db + .select() + .from(pluginJobs) + .where(eq(pluginJobs.pluginId, pluginId)) + .orderBy(asc(pluginJobs.jobKey)), + + /** + * Look up a plugin job by its unique job key. + * + * @param pluginId - The UUID of the plugin. + * @param jobKey - The key defined in the plugin manifest. + * @returns The matching `PluginJobRecord` or null. + */ + getJobByKey: (pluginId: string, jobKey: string) => + db + .select() + .from(pluginJobs) + .where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey))) + .then((rows) => rows[0] ?? null), + + /** + * Register or update a scheduled job for a plugin. + * + * @param pluginId - The UUID of the plugin. + * @param jobKey - The unique key for the job. + * @param input - The schedule (cron) and optional status. + * @returns The updated or created `PluginJobRecord`. + */ + upsertJob: async ( + pluginId: string, + jobKey: string, + input: { schedule: string; status?: PluginJobStatus }, + ) => { + const existing = await db + .select() + .from(pluginJobs) + .where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey))) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginJobs) + .set({ + schedule: input.schedule, + status: input.status ?? existing.status, + updatedAt: new Date(), + }) + .where(eq(pluginJobs.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginJobs) + .values({ + pluginId, + jobKey, + schedule: input.schedule, + status: input.status ?? "active", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Record the start of a specific job execution. + * + * @param pluginId - The UUID of the plugin. + * @param jobId - The UUID of the parent job record. + * @param trigger - What triggered this run (e.g., 'schedule', 'manual'). + * @returns The newly created `PluginJobRunRecord` in 'pending' status. + */ + createJobRun: async ( + pluginId: string, + jobId: string, + trigger: PluginJobRunTrigger, + ) => { + return db + .insert(pluginJobRuns) + .values({ + pluginId, + jobId, + trigger, + status: "pending", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Update the status, duration, and logs of a job execution record. + * + * @param runId - The UUID of the job run. + * @param input - The update fields (status, error, duration, etc.). + * @returns The updated `PluginJobRunRecord`. + */ + updateJobRun: async ( + runId: string, + input: { + status: PluginJobRunStatus; + durationMs?: number; + error?: string; + logs?: string[]; + startedAt?: Date; + finishedAt?: Date; + }, + ) => { + return db + .update(pluginJobRuns) + .set(input) + .where(eq(pluginJobRuns.id, runId)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Webhooks ------------------------------------------------------- + + /** + * Create a record for an incoming webhook delivery. + * + * @param pluginId - The UUID of the receiving plugin. + * @param webhookKey - The endpoint key defined in the manifest. + * @param input - The payload, headers, and optional external ID. + * @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status. + */ + createWebhookDelivery: async ( + pluginId: string, + webhookKey: string, + input: { + externalId?: string; + payload: Record; + headers?: Record; + }, + ) => { + return db + .insert(pluginWebhookDeliveries) + .values({ + pluginId, + webhookKey, + externalId: input.externalId, + payload: input.payload, + headers: input.headers ?? {}, + status: "pending", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Update the status and processing metrics of a webhook delivery. + * + * @param deliveryId - The UUID of the delivery record. + * @param input - The update fields (status, error, duration, etc.). + * @returns The updated `PluginWebhookDeliveryRecord`. + */ + updateWebhookDelivery: async ( + deliveryId: string, + input: { + status: PluginWebhookDeliveryStatus; + durationMs?: number; + error?: string; + startedAt?: Date; + finishedAt?: Date; + }, + ) => { + return db + .update(pluginWebhookDeliveries) + .set(input) + .where(eq(pluginWebhookDeliveries.id, deliveryId)) + .returning() + .then((rows) => rows[0] ?? null); + }, + }; +} diff --git a/server/src/services/plugin-runtime-sandbox.ts b/server/src/services/plugin-runtime-sandbox.ts new file mode 100644 index 00000000..0ffe4a3f --- /dev/null +++ b/server/src/services/plugin-runtime-sandbox.ts @@ -0,0 +1,221 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import type { PluginCapabilityValidator } from "./plugin-capability-validator.js"; + +export class PluginSandboxError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginSandboxError"; + } +} + +/** + * Sandbox runtime options used when loading a plugin worker module. + * + * `allowedModuleSpecifiers` controls which bare module specifiers are permitted. + * `allowedModules` provides concrete host-provided bindings for those specifiers. + */ +export interface PluginSandboxOptions { + entrypointPath: string; + allowedModuleSpecifiers?: ReadonlySet; + allowedModules?: Readonly>>; + allowedGlobals?: Record; + timeoutMs?: number; +} + +/** + * Operation-level runtime gate for plugin host API calls. + * Every host operation must be checked against manifest capabilities before execution. + */ +export interface CapabilityScopedInvoker { + invoke(operation: string, fn: () => Promise | T): Promise; +} + +interface LoadedModule { + namespace: Record; +} + +const DEFAULT_TIMEOUT_MS = 2_000; +const MODULE_PATH_SUFFIXES = ["", ".js", ".mjs", ".cjs", "/index.js", "/index.mjs", "/index.cjs"]; +const DEFAULT_GLOBALS: Record = { + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + AbortController, + AbortSignal, +}; + +export function createCapabilityScopedInvoker( + manifest: PaperclipPluginManifestV1, + validator: PluginCapabilityValidator, +): CapabilityScopedInvoker { + return { + async invoke(operation: string, fn: () => Promise | T): Promise { + validator.assertOperation(manifest, operation); + return await fn(); + }, + }; +} + +/** + * Load a CommonJS plugin module in a VM context with explicit module import allow-listing. + * + * Security properties: + * - no implicit access to host globals like `process` + * - no unrestricted built-in module imports + * - relative imports are resolved only inside the plugin root directory + */ +export async function loadPluginModuleInSandbox( + options: PluginSandboxOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const allowedSpecifiers = options.allowedModuleSpecifiers ?? new Set(); + const entrypointPath = path.resolve(options.entrypointPath); + const pluginRoot = path.dirname(entrypointPath); + + const context = vm.createContext({ + ...DEFAULT_GLOBALS, + ...options.allowedGlobals, + }); + + const moduleCache = new Map>(); + const allowedModules = options.allowedModules ?? {}; + + const realPluginRoot = realpathSync(pluginRoot); + + const loadModuleSync = (modulePath: string): Record => { + const resolvedPath = resolveModulePathSync(path.resolve(modulePath)); + const realPath = realpathSync(resolvedPath); + + if (!isWithinRoot(realPath, realPluginRoot)) { + throw new PluginSandboxError( + `Import '${modulePath}' escapes plugin root and is not allowed`, + ); + } + + const cached = moduleCache.get(realPath); + if (cached) return cached; + + const code = readModuleSourceSync(realPath); + + if (looksLikeEsm(code)) { + throw new PluginSandboxError( + "Sandbox loader only supports CommonJS modules. Build plugin worker entrypoints as CJS for sandboxed loading.", + ); + } + + const module = { exports: {} as Record }; + // Cache the module before execution to preserve CommonJS cycle semantics. + moduleCache.set(realPath, module.exports); + + const requireInSandbox = (specifier: string): Record => { + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + if (!allowedSpecifiers.has(specifier)) { + throw new PluginSandboxError( + `Import denied for module '${specifier}'. Add an explicit sandbox allow-list entry.`, + ); + } + + const binding = allowedModules[specifier]; + if (!binding) { + throw new PluginSandboxError( + `Bare module '${specifier}' is allow-listed but no host binding is registered.`, + ); + } + + return binding; + } + + const candidatePath = path.resolve(path.dirname(realPath), specifier); + return loadModuleSync(candidatePath); + }; + + // Inject the CJS module arguments into the context so the script can call + // the wrapper immediately. This is critical: the timeout in runInContext + // only applies during script evaluation. By including the self-invocation + // `(fn)(exports, module, ...)` in the script text, the timeout also covers + // the actual module body execution — preventing infinite loops from hanging. + const sandboxArgs = { + __paperclip_exports: module.exports, + __paperclip_module: module, + __paperclip_require: requireInSandbox, + __paperclip_filename: realPath, + __paperclip_dirname: path.dirname(realPath), + }; + // Temporarily inject args into the context, run, then remove to avoid pollution. + Object.assign(context, sandboxArgs); + const wrapped = `(function (exports, module, require, __filename, __dirname) {\n${code}\n})(__paperclip_exports, __paperclip_module, __paperclip_require, __paperclip_filename, __paperclip_dirname)`; + const script = new vm.Script(wrapped, { filename: realPath }); + try { + script.runInContext(context, { timeout: timeoutMs }); + } finally { + for (const key of Object.keys(sandboxArgs)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (context as Record)[key]; + } + } + + const normalizedExports = normalizeModuleExports(module.exports); + moduleCache.set(realPath, normalizedExports); + return normalizedExports; + }; + + const entryExports = loadModuleSync(entrypointPath); + + return { + namespace: { ...entryExports }, + }; +} + +function resolveModulePathSync(candidatePath: string): string { + for (const suffix of MODULE_PATH_SUFFIXES) { + const fullPath = `${candidatePath}${suffix}`; + if (existsSync(fullPath)) { + return fullPath; + } + } + + throw new PluginSandboxError(`Unable to resolve module import at path '${candidatePath}'`); +} + +/** + * True when `targetPath` is inside `rootPath` (or equals rootPath), false otherwise. + * Uses `path.relative` so sibling-prefix paths (e.g. `/root-a` vs `/root`) cannot bypass checks. + */ +function isWithinRoot(targetPath: string, rootPath: string): boolean { + const relative = path.relative(rootPath, targetPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function readModuleSourceSync(modulePath: string): string { + try { + return readFileSync(modulePath, "utf8"); + } catch (error) { + throw new PluginSandboxError( + `Failed to read sandbox module '${modulePath}': ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function normalizeModuleExports(exportsValue: unknown): Record { + if (typeof exportsValue === "object" && exportsValue !== null) { + return exportsValue as Record; + } + + return { default: exportsValue }; +} + +/** + * Lightweight guard to reject ESM syntax in the VM CommonJS loader. + */ +function looksLikeEsm(code: string): boolean { + return /(^|\n)\s*import\s+/m.test(code) || /(^|\n)\s*export\s+/m.test(code); +} diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts new file mode 100644 index 00000000..a2156b38 --- /dev/null +++ b/server/src/services/plugin-secrets-handler.ts @@ -0,0 +1,367 @@ +/** + * Plugin secrets host-side handler — resolves secret references through the + * Paperclip secret provider system. + * + * When a plugin worker calls `ctx.secrets.resolve(secretRef)`, the JSON-RPC + * request arrives at the host with `{ secretRef }`. This module provides the + * concrete `HostServices.secrets` adapter that: + * + * 1. Parses the `secretRef` string to identify the secret. + * 2. Looks up the secret record and its latest version in the database. + * 3. Delegates to the configured `SecretProviderModule` to decrypt / + * resolve the raw value. + * 4. Returns the resolved plaintext value to the worker. + * + * ## Secret Reference Format + * + * A `secretRef` is a **secret UUID** — the primary key (`id`) of a row in + * the `company_secrets` table. Operators place these UUIDs into plugin + * config values; plugin workers resolve them at execution time via + * `ctx.secrets.resolve(secretId)`. + * + * ## Security Invariants + * + * - Resolved values are **never** logged, persisted, or included in error + * messages (per PLUGIN_SPEC.md §22). + * - The handler is capability-gated: only plugins with `secrets.read-ref` + * declared in their manifest may call it (enforced by `host-client-factory`). + * - The host handler itself does not cache resolved values. Each call goes + * through the secret provider to honour rotation. + * + * @see PLUGIN_SPEC.md §22 — Secrets + * @see host-client-factory.ts — capability gating + * @see services/secrets.ts — secretService used by agent env bindings + */ + +import { eq, and, desc } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db"; +import type { SecretProvider } from "@paperclipai/shared"; +import { getSecretProvider } from "../secrets/provider-registry.js"; +import { pluginRegistryService } from "./plugin-registry.js"; + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +/** + * Create a sanitised error that never leaks secret material. + * Only the ref identifier is included; never the resolved value. + */ +function secretNotFound(secretRef: string): Error { + const err = new Error(`Secret not found: ${secretRef}`); + err.name = "SecretNotFoundError"; + return err; +} + +function secretVersionNotFound(secretRef: string): Error { + const err = new Error(`No version found for secret: ${secretRef}`); + err.name = "SecretVersionNotFoundError"; + return err; +} + +function invalidSecretRef(secretRef: string): Error { + const err = new Error(`Invalid secret reference: ${secretRef}`); + err.name = "InvalidSecretRefError"; + return err; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** UUID v4 regex for validating secretRef format. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Check whether a secretRef looks like a valid UUID. + */ +function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** + * Collect the property paths (dot-separated keys) whose schema node declares + * `format: "secret-ref"`. Only top-level and nested `properties` are walked — + * this mirrors the flat/nested object shapes that `JsonSchemaForm` renders. + */ +function collectSecretRefPaths( + schema: Record | null | undefined, +): Set { + const paths = new Set(); + if (!schema || typeof schema !== "object") return paths; + + function walk(node: Record, prefix: string): void { + const props = node.properties as Record> | undefined; + if (!props || typeof props !== "object") return; + for (const [key, propSchema] of Object.entries(props)) { + if (!propSchema || typeof propSchema !== "object") continue; + const path = prefix ? `${prefix}.${key}` : key; + if (propSchema.format === "secret-ref") { + paths.add(path); + } + // Recurse into nested object schemas + if (propSchema.type === "object") { + walk(propSchema, path); + } + } + } + + walk(schema, ""); + return paths; +} + +/** + * Extract secret reference UUIDs from a plugin's configJson, scoped to only + * the fields annotated with `format: "secret-ref"` in the schema. + * + * When no schema is provided, falls back to collecting all UUID-shaped strings + * (backwards-compatible for plugins without a declared instanceConfigSchema). + */ +export function extractSecretRefsFromConfig( + configJson: unknown, + schema?: Record | null, +): Set { + const refs = new Set(); + if (configJson == null || typeof configJson !== "object") return refs; + + const secretPaths = collectSecretRefPaths(schema); + + // If schema declares secret-ref paths, extract only those values. + if (secretPaths.size > 0) { + for (const dotPath of secretPaths) { + const keys = dotPath.split("."); + let current: unknown = configJson; + for (const k of keys) { + if (current == null || typeof current !== "object") { current = undefined; break; } + current = (current as Record)[k]; + } + if (typeof current === "string" && isUuid(current)) { + refs.add(current); + } + } + return refs; + } + + // Fallback: no schema or no secret-ref annotations — collect all UUIDs. + // This preserves backwards compatibility for plugins that omit + // instanceConfigSchema. + function walkAll(value: unknown): void { + if (typeof value === "string") { + if (isUuid(value)) refs.add(value); + } else if (Array.isArray(value)) { + for (const item of value) walkAll(item); + } else if (value !== null && typeof value === "object") { + for (const v of Object.values(value as Record)) walkAll(v); + } + } + + walkAll(configJson); + return refs; +} + +// --------------------------------------------------------------------------- +// Handler factory +// --------------------------------------------------------------------------- + +/** + * Input shape for the `secrets.resolve` handler. + * + * Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`. + */ +export interface PluginSecretsResolveParams { + /** The secret reference string (a secret UUID). */ + secretRef: string; +} + +/** + * Options for creating the plugin secrets handler. + */ +export interface PluginSecretsHandlerOptions { + /** Database connection. */ + db: Db; + /** + * The plugin ID using this handler. + * Used for logging context only; never included in error payloads + * that reach the plugin worker. + */ + pluginId: string; +} + +/** + * The `HostServices.secrets` adapter for the plugin host-client factory. + */ +export interface PluginSecretsService { + /** + * Resolve a secret reference to its current plaintext value. + * + * @param params - Contains the `secretRef` (UUID of the secret) + * @returns The resolved secret value + * @throws {Error} If the secret is not found, has no versions, or + * the provider fails to resolve + */ + resolve(params: PluginSecretsResolveParams): Promise; +} + +/** + * Create a `HostServices.secrets` adapter for a specific plugin. + * + * The returned service looks up secrets by UUID, fetches the latest version + * material, and delegates to the appropriate `SecretProviderModule` for + * decryption. + * + * @example + * ```ts + * const secretsHandler = createPluginSecretsHandler({ db, pluginId }); + * const handlers = createHostClientHandlers({ + * pluginId, + * capabilities: manifest.capabilities, + * services: { + * secrets: secretsHandler, + * // ... + * }, + * }); + * ``` + * + * @param options - Database connection and plugin identity + * @returns A `PluginSecretsService` suitable for `HostServices.secrets` + */ +/** Simple sliding-window rate limiter for secret resolution attempts. */ +function createRateLimiter(maxAttempts: number, windowMs: number) { + const attempts = new Map(); + + return { + check(key: string): boolean { + const now = Date.now(); + const windowStart = now - windowMs; + const existing = (attempts.get(key) ?? []).filter((ts) => ts > windowStart); + if (existing.length >= maxAttempts) return false; + existing.push(now); + attempts.set(key, existing); + return true; + }, + }; +} + +export function createPluginSecretsHandler( + options: PluginSecretsHandlerOptions, +): PluginSecretsService { + const { db, pluginId } = options; + const registry = pluginRegistryService(db); + + // Rate limit: max 30 resolution attempts per plugin per minute + const rateLimiter = createRateLimiter(30, 60_000); + + let cachedAllowedRefs: Set | null = null; + let cachedAllowedRefsExpiry = 0; + const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL + + return { + async resolve(params: PluginSecretsResolveParams): Promise { + const { secretRef } = params; + + // --------------------------------------------------------------- + // 0. Rate limiting — prevent brute-force UUID enumeration + // --------------------------------------------------------------- + if (!rateLimiter.check(pluginId)) { + const err = new Error("Rate limit exceeded for secret resolution"); + err.name = "RateLimitExceededError"; + throw err; + } + + // --------------------------------------------------------------- + // 1. Validate the ref format + // --------------------------------------------------------------- + if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) { + throw invalidSecretRef(secretRef ?? ""); + } + + const trimmedRef = secretRef.trim(); + + if (!isUuid(trimmedRef)) { + throw invalidSecretRef(trimmedRef); + } + + // --------------------------------------------------------------- + // 1b. Scope check — only allow secrets referenced in this plugin's config + // --------------------------------------------------------------- + const now = Date.now(); + if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) { + const [configRow, plugin] = await Promise.all([ + db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null), + registry.getById(pluginId), + ]); + + const schema = (plugin?.manifestJson as unknown as Record | null) + ?.instanceConfigSchema as Record | undefined; + cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema); + cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS; + } + + if (!cachedAllowedRefs.has(trimmedRef)) { + // Return "not found" to avoid leaking whether the secret exists + throw secretNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 2. Look up the secret record by UUID + // --------------------------------------------------------------- + const secret = await db + .select() + .from(companySecrets) + .where(eq(companySecrets.id, trimmedRef)) + .then((rows) => rows[0] ?? null); + + if (!secret) { + throw secretNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 2b. Verify the plugin is available for the secret's company. + // This prevents cross-company secret access via UUID guessing. + // --------------------------------------------------------------- + const companyId = (secret as { companyId?: string }).companyId; + if (companyId) { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + if (!availability || !availability.available) { + // Return the same error as "not found" to avoid leaking existence + throw secretNotFound(trimmedRef); + } + } + + // --------------------------------------------------------------- + // 3. Fetch the latest version's material + // --------------------------------------------------------------- + const versionRow = await db + .select() + .from(companySecretVersions) + .where( + and( + eq(companySecretVersions.secretId, secret.id), + eq(companySecretVersions.version, secret.latestVersion), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!versionRow) { + throw secretVersionNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 4. Resolve through the appropriate secret provider + // --------------------------------------------------------------- + const provider = getSecretProvider(secret.provider as SecretProvider); + const resolved = await provider.resolveVersion({ + material: versionRow.material as Record, + externalRef: secret.externalRef, + }); + + return resolved; + }, + }; +} diff --git a/server/src/services/plugin-state-store.ts b/server/src/services/plugin-state-store.ts new file mode 100644 index 00000000..94377dc0 --- /dev/null +++ b/server/src/services/plugin-state-store.ts @@ -0,0 +1,237 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { plugins, pluginState } from "@paperclipai/db"; +import type { + PluginStateScopeKind, + SetPluginState, + ListPluginState, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Default namespace used when the plugin does not specify one. */ +const DEFAULT_NAMESPACE = "default"; + +/** + * Build the WHERE clause conditions for a scoped state lookup. + * + * The five-part composite key is: + * `(pluginId, scopeKind, scopeId, namespace, stateKey)` + * + * `scopeId` may be null (for `instance` scope) or a non-empty string. + */ +function scopeConditions( + pluginId: string, + scopeKind: PluginStateScopeKind, + scopeId: string | undefined | null, + namespace: string, + stateKey: string, +) { + const conditions = [ + eq(pluginState.pluginId, pluginId), + eq(pluginState.scopeKind, scopeKind), + eq(pluginState.namespace, namespace), + eq(pluginState.stateKey, stateKey), + ]; + + if (scopeId != null && scopeId !== "") { + conditions.push(eq(pluginState.scopeId, scopeId)); + } else { + conditions.push(isNull(pluginState.scopeId)); + } + + return and(...conditions); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * Plugin State Store — scoped key-value persistence for plugin workers. + * + * Provides `get`, `set`, `delete`, and `list` operations over the + * `plugin_state` table. Each plugin's data is strictly namespaced by + * `pluginId` so plugins cannot read or write each other's state. + * + * This service implements the server-side backing for the `ctx.state` SDK + * client exposed to plugin workers. The host is responsible for: + * - enforcing `plugin.state.read` capability before calling `get` / `list` + * - enforcing `plugin.state.write` capability before calling `set` / `delete` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface (`ctx.state`) + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Plugin State + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` table + */ +export function pluginStateStore(db: Db) { + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function assertPluginExists(pluginId: string): Promise { + 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 { + /** + * Read a state value. + * + * Returns the stored JSON value, or `null` if no entry exists for the + * given scope and key. + * + * Requires `plugin.state.read` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param scopeKind - Granularity of the scope + * @param scopeId - Identifier for the scoped entity (null for `instance` scope) + * @param stateKey - The key to read + * @param namespace - Sub-namespace (defaults to `"default"`) + */ + get: async ( + pluginId: string, + scopeKind: PluginStateScopeKind, + stateKey: string, + { + scopeId, + namespace = DEFAULT_NAMESPACE, + }: { scopeId?: string; namespace?: string } = {}, + ): Promise => { + const rows = await db + .select() + .from(pluginState) + .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); + + return rows[0]?.valueJson ?? null; + }, + + /** + * Write (create or replace) a state value. + * + * Uses an upsert so the caller does not need to check for prior existence. + * On conflict (same composite key) the existing row's `value_json` and + * `updated_at` are overwritten. + * + * Requires `plugin.state.write` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param input - Scope key and value to store + */ + set: async (pluginId: string, input: SetPluginState): Promise => { + await assertPluginExists(pluginId); + + const namespace = input.namespace ?? DEFAULT_NAMESPACE; + const scopeId = input.scopeId ?? null; + + await db + .insert(pluginState) + .values({ + pluginId, + scopeKind: input.scopeKind, + scopeId, + namespace, + stateKey: input.stateKey, + valueJson: input.value, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + pluginState.pluginId, + pluginState.scopeKind, + pluginState.scopeId, + pluginState.namespace, + pluginState.stateKey, + ], + set: { + valueJson: input.value, + updatedAt: new Date(), + }, + }); + }, + + /** + * Delete a state value. + * + * No-ops silently if the entry does not exist (idempotent by design). + * + * Requires `plugin.state.write` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param scopeKind - Granularity of the scope + * @param stateKey - The key to delete + * @param scopeId - Identifier for the scoped entity (null for `instance` scope) + * @param namespace - Sub-namespace (defaults to `"default"`) + */ + delete: async ( + pluginId: string, + scopeKind: PluginStateScopeKind, + stateKey: string, + { + scopeId, + namespace = DEFAULT_NAMESPACE, + }: { scopeId?: string; namespace?: string } = {}, + ): Promise => { + await db + .delete(pluginState) + .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); + }, + + /** + * List all state entries for a plugin, optionally filtered by scope. + * + * Returns all matching rows as `PluginStateRecord`-shaped objects. + * The `valueJson` field contains the stored value. + * + * Requires `plugin.state.read` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param filter - Optional scope filters (scopeKind, scopeId, namespace) + */ + list: async (pluginId: string, filter: ListPluginState = {}): Promise => { + const conditions = [eq(pluginState.pluginId, pluginId)]; + + if (filter.scopeKind !== undefined) { + conditions.push(eq(pluginState.scopeKind, filter.scopeKind)); + } + if (filter.scopeId !== undefined) { + conditions.push(eq(pluginState.scopeId, filter.scopeId)); + } + if (filter.namespace !== undefined) { + conditions.push(eq(pluginState.namespace, filter.namespace)); + } + + return db + .select() + .from(pluginState) + .where(and(...conditions)); + }, + + /** + * Delete all state entries owned by a plugin. + * + * Called during plugin uninstall when `removeData = true`. Also useful + * for resetting a plugin's state during testing. + * + * @param pluginId - UUID of the owning plugin + */ + deleteAll: async (pluginId: string): Promise => { + await db + .delete(pluginState) + .where(eq(pluginState.pluginId, pluginId)); + }, + }; +} + +export type PluginStateStore = ReturnType; diff --git a/server/src/services/plugin-stream-bus.ts b/server/src/services/plugin-stream-bus.ts new file mode 100644 index 00000000..e39aca5e --- /dev/null +++ b/server/src/services/plugin-stream-bus.ts @@ -0,0 +1,81 @@ +/** + * In-memory pub/sub bus for plugin SSE streams. + * + * Workers emit stream events via JSON-RPC notifications. The bus fans out + * each event to all connected SSE clients that match the (pluginId, channel, + * companyId) tuple. + * + * @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming + */ + +/** Valid SSE event types for plugin streams. */ +export type StreamEventType = "message" | "open" | "close" | "error"; + +export type StreamSubscriber = (event: unknown, eventType: StreamEventType) => void; + +/** + * Composite key for stream subscriptions: pluginId:channel:companyId + */ +function streamKey(pluginId: string, channel: string, companyId: string): string { + return `${pluginId}:${channel}:${companyId}`; +} + +export interface PluginStreamBus { + /** + * Subscribe to stream events for a specific (pluginId, channel, companyId). + * Returns an unsubscribe function. + */ + subscribe( + pluginId: string, + channel: string, + companyId: string, + listener: StreamSubscriber, + ): () => void; + + /** + * Publish an event to all subscribers of (pluginId, channel, companyId). + * Called by the worker manager when it receives a stream notification. + */ + publish( + pluginId: string, + channel: string, + companyId: string, + event: unknown, + eventType?: StreamEventType, + ): void; +} + +/** + * Create a new PluginStreamBus instance. + */ +export function createPluginStreamBus(): PluginStreamBus { + const subscribers = new Map>(); + + return { + subscribe(pluginId, channel, companyId, listener) { + const key = streamKey(pluginId, channel, companyId); + let set = subscribers.get(key); + if (!set) { + set = new Set(); + subscribers.set(key, set); + } + set.add(listener); + + return () => { + set!.delete(listener); + if (set!.size === 0) { + subscribers.delete(key); + } + }; + }, + + publish(pluginId, channel, companyId, event, eventType: StreamEventType = "message") { + const key = streamKey(pluginId, channel, companyId); + const set = subscribers.get(key); + if (!set) return; + for (const listener of set) { + listener(event, eventType); + } + }, + }; +} diff --git a/server/src/services/plugin-tool-dispatcher.ts b/server/src/services/plugin-tool-dispatcher.ts new file mode 100644 index 00000000..18ea075b --- /dev/null +++ b/server/src/services/plugin-tool-dispatcher.ts @@ -0,0 +1,448 @@ +/** + * PluginToolDispatcher — orchestrates plugin tool discovery, lifecycle + * integration, and execution routing for the agent service. + * + * This service sits between the agent service and the lower-level + * `PluginToolRegistry` + `PluginWorkerManager`, providing a clean API that: + * + * - Discovers tools from loaded plugin manifests and registers them + * in the tool registry. + * - Hooks into `PluginLifecycleManager` events to automatically register + * and unregister tools when plugins are enabled or disabled. + * - Exposes the tool list in an agent-friendly format (with namespaced + * names, descriptions, parameter schemas). + * - Routes `executeTool` calls to the correct plugin worker and returns + * structured results. + * - Validates tool parameters against declared schemas before dispatch. + * + * The dispatcher is created once at server startup and shared across + * the application. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ + +import type { Db } from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginRecord, +} from "@paperclipai/shared"; +import type { ToolRunContext, ToolResult } from "@paperclipai/plugin-sdk"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; +import { + createPluginToolRegistry, + type PluginToolRegistry, + type RegisteredTool, + type ToolListFilter, + type ToolExecutionResult, +} from "./plugin-tool-registry.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * An agent-facing tool descriptor — the shape returned when agents + * query for available tools. + * + * This is intentionally simpler than `RegisteredTool`, exposing only + * what agents need to decide whether and how to call a tool. + */ +export interface AgentToolDescriptor { + /** Fully namespaced tool name (e.g. `"acme.linear:search-issues"`). */ + name: string; + /** Human-readable display name. */ + displayName: string; + /** Description for the agent — explains when and how to use this tool. */ + description: string; + /** JSON Schema describing the tool's input parameters. */ + parametersSchema: Record; + /** The plugin that provides this tool. */ + pluginId: string; +} + +/** + * Options for creating the plugin tool dispatcher. + */ +export interface PluginToolDispatcherOptions { + /** The worker manager used to dispatch RPC calls to plugin workers. */ + workerManager?: PluginWorkerManager; + /** The lifecycle manager to listen for plugin state changes. */ + lifecycleManager?: PluginLifecycleManager; + /** Database connection for looking up plugin records. */ + db?: Db; +} + +// --------------------------------------------------------------------------- +// PluginToolDispatcher interface +// --------------------------------------------------------------------------- + +/** + * The plugin tool dispatcher — the primary integration point between the + * agent service and the plugin tool system. + * + * Agents use this service to: + * 1. List all available tools (for prompt construction / tool choice) + * 2. Execute a specific tool by its namespaced name + * + * The dispatcher handles lifecycle management internally — when a plugin + * is loaded or unloaded, its tools are automatically registered or removed. + */ +export interface PluginToolDispatcher { + /** + * Initialize the dispatcher — load tools from all currently-ready plugins + * and start listening for lifecycle events. + * + * Must be called once at server startup after the lifecycle manager + * and worker manager are ready. + */ + initialize(): Promise; + + /** + * Tear down the dispatcher — unregister lifecycle event listeners + * and clear all tool registrations. + * + * Called during server shutdown. + */ + teardown(): void; + + /** + * List all available tools for agents, optionally filtered. + * + * Returns tool descriptors in an agent-friendly format. + * + * @param filter - Optional filter criteria + * @returns Array of agent tool descriptors + */ + listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[]; + + /** + * Look up a tool by its namespaced name. + * + * @param namespacedName - e.g. `"acme.linear:search-issues"` + * @returns The registered tool, or `null` if not found + */ + getTool(namespacedName: string): RegisteredTool | null; + + /** + * Execute a tool by its namespaced name, routing to the correct + * plugin worker. + * + * @param namespacedName - Fully qualified tool name + * @param parameters - Input parameters matching the tool's schema + * @param runContext - Agent run context + * @returns The execution result with routing metadata + * @throws {Error} if the tool is not found, the worker is not running, + * or the tool execution fails + */ + executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise; + + /** + * Register all tools from a plugin manifest. + * + * This is called automatically when a plugin transitions to `ready`. + * Can also be called manually for testing or recovery scenarios. + * + * @param pluginId - The plugin's unique identifier + * @param manifest - The plugin manifest containing tool declarations + */ + registerPluginTools( + pluginId: string, + manifest: PaperclipPluginManifestV1, + ): void; + + /** + * Unregister all tools for a plugin. + * + * Called automatically when a plugin is disabled or unloaded. + * + * @param pluginId - The plugin to unregister + */ + unregisterPluginTools(pluginId: string): void; + + /** + * Get the total number of registered tools, optionally scoped to a plugin. + * + * @param pluginId - If provided, count only this plugin's tools + */ + toolCount(pluginId?: string): number; + + /** + * Access the underlying tool registry for advanced operations. + * + * This escape hatch exists for internal use (e.g. diagnostics). + * Prefer the dispatcher's own methods for normal operations. + */ + getRegistry(): PluginToolRegistry; +} + +// --------------------------------------------------------------------------- +// Factory: createPluginToolDispatcher +// --------------------------------------------------------------------------- + +/** + * Create a new `PluginToolDispatcher`. + * + * The dispatcher: + * 1. Creates and owns a `PluginToolRegistry` backed by the given worker manager. + * 2. Listens for lifecycle events (plugin.enabled, plugin.disabled, plugin.unloaded) + * to automatically register and unregister tools. + * 3. On `initialize()`, loads tools from all currently-ready plugins via the DB. + * + * @param options - Configuration options + * + * @example + * ```ts + * // At server startup + * const dispatcher = createPluginToolDispatcher({ + * workerManager, + * lifecycleManager, + * db, + * }); + * await dispatcher.initialize(); + * + * // In agent service — list tools for prompt construction + * const tools = dispatcher.listToolsForAgent(); + * + * // In agent service — execute a tool + * const result = await dispatcher.executeTool( + * "acme.linear:search-issues", + * { query: "auth bug" }, + * { agentId: "a-1", runId: "r-1", companyId: "c-1", projectId: "p-1" }, + * ); + * ``` + */ +export function createPluginToolDispatcher( + options: PluginToolDispatcherOptions = {}, +): PluginToolDispatcher { + const { workerManager, lifecycleManager, db } = options; + const log = logger.child({ service: "plugin-tool-dispatcher" }); + + // Create the underlying tool registry, backed by the worker manager + const registry = createPluginToolRegistry(workerManager); + + // Track lifecycle event listeners so we can remove them on teardown + let enabledListener: ((payload: { pluginId: string; pluginKey: string }) => void) | null = null; + let disabledListener: ((payload: { pluginId: string; pluginKey: string; reason?: string }) => void) | null = null; + let unloadedListener: ((payload: { pluginId: string; pluginKey: string; removeData: boolean }) => void) | null = null; + + let initialized = false; + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /** + * Attempt to register tools for a plugin by looking up its manifest + * from the DB. No-ops gracefully if the plugin or manifest is missing. + */ + async function registerFromDb(pluginId: string): Promise { + if (!db) { + log.warn( + { pluginId }, + "cannot register tools from DB — no database connection configured", + ); + return; + } + + const pluginRegistry = pluginRegistryService(db); + const plugin = await pluginRegistry.getById(pluginId) as PluginRecord | null; + + if (!plugin) { + log.warn({ pluginId }, "plugin not found in registry, cannot register tools"); + return; + } + + const manifest = plugin.manifestJson; + if (!manifest) { + log.warn({ pluginId }, "plugin has no manifest, cannot register tools"); + return; + } + + registry.registerPlugin(plugin.pluginKey, manifest, plugin.id); + } + + /** + * Convert a `RegisteredTool` to an `AgentToolDescriptor`. + */ + function toAgentDescriptor(tool: RegisteredTool): AgentToolDescriptor { + return { + name: tool.namespacedName, + displayName: tool.displayName, + description: tool.description, + parametersSchema: tool.parametersSchema, + pluginId: tool.pluginDbId, + }; + } + + // ----------------------------------------------------------------------- + // Lifecycle event handlers + // ----------------------------------------------------------------------- + + function handlePluginEnabled(payload: { pluginId: string; pluginKey: string }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin enabled — registering tools"); + // Async registration from DB — we fire-and-forget since the lifecycle + // event handler must be synchronous. Any errors are logged. + void registerFromDb(payload.pluginId).catch((err) => { + log.error( + { pluginId: payload.pluginId, err: err instanceof Error ? err.message : String(err) }, + "failed to register tools after plugin enabled", + ); + }); + } + + function handlePluginDisabled(payload: { pluginId: string; pluginKey: string; reason?: string }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin disabled — unregistering tools"); + registry.unregisterPlugin(payload.pluginKey); + } + + function handlePluginUnloaded(payload: { pluginId: string; pluginKey: string; removeData: boolean }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin unloaded — unregistering tools"); + registry.unregisterPlugin(payload.pluginKey); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + async initialize(): Promise { + if (initialized) { + log.warn("dispatcher already initialized, skipping"); + return; + } + + log.info("initializing plugin tool dispatcher"); + + // Step 1: Load tools from all currently-ready plugins + if (db) { + const pluginRegistry = pluginRegistryService(db); + const readyPlugins = await pluginRegistry.listByStatus("ready") as PluginRecord[]; + + let totalTools = 0; + for (const plugin of readyPlugins) { + const manifest = plugin.manifestJson; + if (manifest?.tools && manifest.tools.length > 0) { + registry.registerPlugin(plugin.pluginKey, manifest, plugin.id); + totalTools += manifest.tools.length; + } + } + + log.info( + { readyPlugins: readyPlugins.length, registeredTools: totalTools }, + "loaded tools from ready plugins", + ); + } + + // Step 2: Subscribe to lifecycle events for dynamic updates + if (lifecycleManager) { + enabledListener = handlePluginEnabled; + disabledListener = handlePluginDisabled; + unloadedListener = handlePluginUnloaded; + + lifecycleManager.on("plugin.enabled", enabledListener); + lifecycleManager.on("plugin.disabled", disabledListener); + lifecycleManager.on("plugin.unloaded", unloadedListener); + + log.debug("subscribed to lifecycle events"); + } else { + log.warn("no lifecycle manager provided — tools will not auto-update on plugin state changes"); + } + + initialized = true; + log.info( + { totalTools: registry.toolCount() }, + "plugin tool dispatcher initialized", + ); + }, + + teardown(): void { + if (!initialized) return; + + // Unsubscribe from lifecycle events + if (lifecycleManager) { + if (enabledListener) lifecycleManager.off("plugin.enabled", enabledListener); + if (disabledListener) lifecycleManager.off("plugin.disabled", disabledListener); + if (unloadedListener) lifecycleManager.off("plugin.unloaded", unloadedListener); + + enabledListener = null; + disabledListener = null; + unloadedListener = null; + } + + // Note: we do NOT clear the registry here because teardown may be + // called during graceful shutdown where in-flight tool calls should + // still be able to resolve their tool entries. + + initialized = false; + log.info("plugin tool dispatcher torn down"); + }, + + listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[] { + return registry.listTools(filter).map(toAgentDescriptor); + }, + + getTool(namespacedName: string): RegisteredTool | null { + return registry.getTool(namespacedName); + }, + + async executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise { + log.debug( + { + tool: namespacedName, + agentId: runContext.agentId, + runId: runContext.runId, + }, + "dispatching tool execution", + ); + + const result = await registry.executeTool( + namespacedName, + parameters, + runContext, + ); + + log.debug( + { + tool: namespacedName, + pluginId: result.pluginId, + hasContent: !!result.result.content, + hasError: !!result.result.error, + }, + "tool execution completed", + ); + + return result; + }, + + registerPluginTools( + pluginId: string, + manifest: PaperclipPluginManifestV1, + ): void { + registry.registerPlugin(pluginId, manifest); + }, + + unregisterPluginTools(pluginId: string): void { + registry.unregisterPlugin(pluginId); + }, + + toolCount(pluginId?: string): number { + return registry.toolCount(pluginId); + }, + + getRegistry(): PluginToolRegistry { + return registry; + }, + }; +} diff --git a/server/src/services/plugin-tool-registry.ts b/server/src/services/plugin-tool-registry.ts new file mode 100644 index 00000000..cde0cf27 --- /dev/null +++ b/server/src/services/plugin-tool-registry.ts @@ -0,0 +1,449 @@ +/** + * PluginToolRegistry — host-side registry for plugin-contributed agent tools. + * + * Responsibilities: + * - Store tool declarations (from plugin manifests) alongside routing metadata + * so the host can resolve namespaced tool names to the owning plugin worker. + * - Namespace tools automatically: a tool `"search-issues"` from plugin + * `"acme.linear"` is exposed to agents as `"acme.linear:search-issues"`. + * - Route `executeTool` calls to the correct plugin worker via the + * `PluginWorkerManager`. + * - Provide tool discovery queries so agents can list available tools. + * - Clean up tool registrations when a plugin is unloaded or its worker stops. + * + * The registry is an in-memory structure — tool declarations are derived from + * the plugin manifest at load time and do not need persistence. When a plugin + * worker restarts, the host re-registers its manifest tools. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ + +import type { + PaperclipPluginManifestV1, + PluginToolDeclaration, +} from "@paperclipai/shared"; +import type { ToolRunContext, ToolResult, ExecuteToolParams } from "@paperclipai/plugin-sdk"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Separator between plugin ID and tool name in the namespaced tool identifier. + * + * Example: `"acme.linear:search-issues"` + */ +export const TOOL_NAMESPACE_SEPARATOR = ":"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A registered tool entry stored in the registry. + * + * Combines the manifest-level declaration with routing metadata so the host + * can resolve a namespaced tool name → plugin worker in O(1). + */ +export interface RegisteredTool { + /** The plugin key used for namespacing (e.g. `"acme.linear"`). */ + pluginId: string; + /** + * The plugin's database UUID, used for worker routing and availability + * checks. Falls back to `pluginId` when not provided (e.g. in tests + * where `id === pluginKey`). + */ + pluginDbId: string; + /** The tool's bare name (without namespace prefix). */ + name: string; + /** Fully namespaced identifier: `":"`. */ + namespacedName: string; + /** Human-readable display name. */ + 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: Record; +} + +/** + * Filter criteria for listing available tools. + */ +export interface ToolListFilter { + /** Only return tools owned by this plugin. */ + pluginId?: string; +} + +/** + * Result of executing a tool, extending `ToolResult` with routing metadata. + */ +export interface ToolExecutionResult { + /** The plugin that handled the tool call. */ + pluginId: string; + /** The bare tool name that was executed. */ + toolName: string; + /** The result returned by the plugin's tool handler. */ + result: ToolResult; +} + +// --------------------------------------------------------------------------- +// PluginToolRegistry interface +// --------------------------------------------------------------------------- + +/** + * The host-side tool registry — held by the host process. + * + * Created once at server startup and shared across the application. Plugins + * register their tools when their worker starts, and unregister when the + * worker stops or the plugin is uninstalled. + */ +export interface PluginToolRegistry { + /** + * Register all tools declared in a plugin's manifest. + * + * Called when a plugin worker starts and its manifest is loaded. Any + * previously registered tools for the same plugin are replaced (idempotent). + * + * @param pluginId - The plugin's unique identifier (e.g. `"acme.linear"`) + * @param manifest - The plugin manifest containing the `tools` array + * @param pluginDbId - The plugin's database UUID, used for worker routing + * and availability checks. If omitted, `pluginId` is used (backwards-compat). + */ + registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void; + + /** + * Remove all tool registrations for a plugin. + * + * Called when a plugin worker stops, crashes, or is uninstalled. + * + * @param pluginId - The plugin to clear + */ + unregisterPlugin(pluginId: string): void; + + /** + * Look up a registered tool by its namespaced name. + * + * @param namespacedName - Fully qualified name, e.g. `"acme.linear:search-issues"` + * @returns The registered tool entry, or `null` if not found + */ + getTool(namespacedName: string): RegisteredTool | null; + + /** + * Look up a registered tool by plugin ID and bare tool name. + * + * @param pluginId - The owning plugin + * @param toolName - The bare tool name (without namespace prefix) + * @returns The registered tool entry, or `null` if not found + */ + getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null; + + /** + * List all registered tools, optionally filtered. + * + * @param filter - Optional filter criteria + * @returns Array of registered tool entries + */ + listTools(filter?: ToolListFilter): RegisteredTool[]; + + /** + * Parse a namespaced tool name into plugin ID and bare tool name. + * + * @param namespacedName - e.g. `"acme.linear:search-issues"` + * @returns `{ pluginId, toolName }` or `null` if the format is invalid + */ + parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null; + + /** + * Build a namespaced tool name from a plugin ID and bare tool name. + * + * @param pluginId - e.g. `"acme.linear"` + * @param toolName - e.g. `"search-issues"` + * @returns The namespaced name, e.g. `"acme.linear:search-issues"` + */ + buildNamespacedName(pluginId: string, toolName: string): string; + + /** + * Execute a tool by its namespaced name, routing to the correct plugin worker. + * + * Resolves the namespaced name to the owning plugin, validates the tool + * exists, and dispatches the `executeTool` RPC call to the worker. + * + * @param namespacedName - Fully qualified tool name (e.g. `"acme.linear:search-issues"`) + * @param parameters - The parsed parameters matching the tool's schema + * @param runContext - Agent run context + * @returns The execution result with routing metadata + * @throws {Error} if the tool is not found or the worker is not running + */ + executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise; + + /** + * Get the number of registered tools, optionally scoped to a plugin. + * + * @param pluginId - If provided, count only this plugin's tools + */ + toolCount(pluginId?: string): number; +} + +// --------------------------------------------------------------------------- +// Factory: createPluginToolRegistry +// --------------------------------------------------------------------------- + +/** + * Create a new `PluginToolRegistry`. + * + * The registry is backed by two in-memory maps: + * - `byNamespace`: namespaced name → `RegisteredTool` for O(1) lookups. + * - `byPlugin`: pluginId → Set of namespaced names for efficient per-plugin ops. + * + * @param workerManager - The worker manager used to dispatch `executeTool` RPC + * calls to plugin workers. If not provided, `executeTool` will throw. + * + * @example + * ```ts + * const toolRegistry = createPluginToolRegistry(workerManager); + * + * // Register tools from a plugin manifest + * toolRegistry.registerPlugin("acme.linear", linearManifest); + * + * // List all available tools for agents + * const tools = toolRegistry.listTools(); + * // → [{ namespacedName: "acme.linear:search-issues", ... }] + * + * // Execute a tool + * const result = await toolRegistry.executeTool( + * "acme.linear:search-issues", + * { query: "auth bug" }, + * { agentId: "agent-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" }, + * ); + * ``` + */ +export function createPluginToolRegistry( + workerManager?: PluginWorkerManager, +): PluginToolRegistry { + const log = logger.child({ service: "plugin-tool-registry" }); + + // Primary index: namespaced name → tool entry + const byNamespace = new Map(); + + // Secondary index: pluginId → set of namespaced names (for bulk operations) + const byPlugin = new Map>(); + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + function buildName(pluginId: string, toolName: string): string { + return `${pluginId}${TOOL_NAMESPACE_SEPARATOR}${toolName}`; + } + + function parseName(namespacedName: string): { pluginId: string; toolName: string } | null { + const sepIndex = namespacedName.lastIndexOf(TOOL_NAMESPACE_SEPARATOR); + if (sepIndex <= 0 || sepIndex >= namespacedName.length - 1) { + return null; + } + return { + pluginId: namespacedName.slice(0, sepIndex), + toolName: namespacedName.slice(sepIndex + 1), + }; + } + + function addTool(pluginId: string, decl: PluginToolDeclaration, pluginDbId: string): void { + const namespacedName = buildName(pluginId, decl.name); + + const entry: RegisteredTool = { + pluginId, + pluginDbId, + name: decl.name, + namespacedName, + displayName: decl.displayName, + description: decl.description, + parametersSchema: decl.parametersSchema, + }; + + byNamespace.set(namespacedName, entry); + + let pluginTools = byPlugin.get(pluginId); + if (!pluginTools) { + pluginTools = new Set(); + byPlugin.set(pluginId, pluginTools); + } + pluginTools.add(namespacedName); + } + + function removePluginTools(pluginId: string): number { + const pluginTools = byPlugin.get(pluginId); + if (!pluginTools) return 0; + + const count = pluginTools.size; + for (const name of pluginTools) { + byNamespace.delete(name); + } + byPlugin.delete(pluginId); + + return count; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void { + const dbId = pluginDbId ?? pluginId; + + // Remove any previously registered tools for this plugin (idempotent) + const previousCount = removePluginTools(pluginId); + if (previousCount > 0) { + log.debug( + { pluginId, previousCount }, + "cleared previous tool registrations before re-registering", + ); + } + + const tools = manifest.tools ?? []; + if (tools.length === 0) { + log.debug({ pluginId }, "plugin declares no tools"); + return; + } + + for (const decl of tools) { + addTool(pluginId, decl, dbId); + } + + log.info( + { + pluginId, + toolCount: tools.length, + tools: tools.map((t) => buildName(pluginId, t.name)), + }, + `registered ${tools.length} tool(s) for plugin`, + ); + }, + + unregisterPlugin(pluginId: string): void { + const removed = removePluginTools(pluginId); + if (removed > 0) { + log.info( + { pluginId, removedCount: removed }, + `unregistered ${removed} tool(s) for plugin`, + ); + } + }, + + getTool(namespacedName: string): RegisteredTool | null { + return byNamespace.get(namespacedName) ?? null; + }, + + getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null { + const namespacedName = buildName(pluginId, toolName); + return byNamespace.get(namespacedName) ?? null; + }, + + listTools(filter?: ToolListFilter): RegisteredTool[] { + if (filter?.pluginId) { + const pluginTools = byPlugin.get(filter.pluginId); + if (!pluginTools) return []; + const result: RegisteredTool[] = []; + for (const name of pluginTools) { + const tool = byNamespace.get(name); + if (tool) result.push(tool); + } + return result; + } + + return Array.from(byNamespace.values()); + }, + + parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null { + return parseName(namespacedName); + }, + + buildNamespacedName(pluginId: string, toolName: string): string { + return buildName(pluginId, toolName); + }, + + async executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise { + // 1. Resolve the namespaced name + const parsed = parseName(namespacedName); + if (!parsed) { + throw new Error( + `Invalid tool name "${namespacedName}". Expected format: "${TOOL_NAMESPACE_SEPARATOR}"`, + ); + } + + const { pluginId, toolName } = parsed; + + // 2. Verify the tool is registered + const tool = byNamespace.get(namespacedName); + if (!tool) { + throw new Error( + `Tool "${namespacedName}" is not registered. ` + + `The plugin may not be installed or its worker may not be running.`, + ); + } + + // 3. Verify the worker manager is available + if (!workerManager) { + throw new Error( + `Cannot execute tool "${namespacedName}" — no worker manager configured. ` + + `Tool execution requires a PluginWorkerManager.`, + ); + } + + // 4. Verify the plugin worker is running (use DB UUID for worker lookup) + const dbId = tool.pluginDbId; + if (!workerManager.isRunning(dbId)) { + throw new Error( + `Cannot execute tool "${namespacedName}" — ` + + `worker for plugin "${pluginId}" is not running.`, + ); + } + + // 5. Dispatch the executeTool RPC call to the worker + log.debug( + { pluginId, pluginDbId: dbId, toolName, namespacedName, agentId: runContext.agentId, runId: runContext.runId }, + "executing tool via plugin worker", + ); + + const rpcParams: ExecuteToolParams = { + toolName, + parameters, + runContext, + }; + + const result = await workerManager.call(dbId, "executeTool", rpcParams); + + log.debug( + { + pluginId, + toolName, + namespacedName, + hasContent: !!result.content, + hasData: result.data !== undefined, + hasError: !!result.error, + }, + "tool execution completed", + ); + + return { pluginId, toolName, result }; + }, + + toolCount(pluginId?: string): number { + if (pluginId !== undefined) { + return byPlugin.get(pluginId)?.size ?? 0; + } + return byNamespace.size; + }, + }; +} diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts new file mode 100644 index 00000000..b55ba1bc --- /dev/null +++ b/server/src/services/plugin-worker-manager.ts @@ -0,0 +1,1342 @@ +/** + * PluginWorkerManager — spawns and manages out-of-process plugin worker child + * processes, routes JSON-RPC 2.0 calls over stdio, and handles lifecycle + * management including crash recovery with exponential backoff. + * + * Each installed plugin gets one dedicated worker process. The host sends + * JSON-RPC requests over the child's stdin and reads responses from stdout. + * Worker stderr is captured and forwarded to the host logger. + * + * Process Model (from PLUGIN_SPEC.md §12): + * - One worker process per installed plugin + * - Failure isolation: plugin crashes do not affect the host + * - Graceful shutdown: 10-second drain, then SIGTERM, then SIGKILL + * - Automatic restart with exponential backoff on unexpected exits + * + * @see PLUGIN_SPEC.md §12 — Process Model + * @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + */ + +import { fork, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + JSONRPC_VERSION, + JSONRPC_ERROR_CODES, + PLUGIN_RPC_ERROR_CODES, + createRequest, + createErrorResponse, + parseMessage, + serializeMessage, + isJsonRpcResponse, + isJsonRpcRequest, + isJsonRpcNotification, + isJsonRpcSuccessResponse, + JsonRpcParseError, + JsonRpcCallError, +} from "@paperclipai/plugin-sdk"; +import type { + JsonRpcId, + JsonRpcResponse, + JsonRpcRequest, + JsonRpcNotification, + HostToWorkerMethodName, + HostToWorkerMethods, + WorkerToHostMethodName, + WorkerToHostMethods, + InitializeParams, +} from "@paperclipai/plugin-sdk"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default timeout for RPC calls in milliseconds. */ +const DEFAULT_RPC_TIMEOUT_MS = 30_000; + +/** Hard upper bound for any RPC timeout (5 minutes). Prevents unbounded waits. */ +const MAX_RPC_TIMEOUT_MS = 5 * 60 * 1_000; + +/** Timeout for the initialize RPC call. */ +const INITIALIZE_TIMEOUT_MS = 15_000; + +/** Timeout for the shutdown RPC call before escalating to SIGTERM. */ +const SHUTDOWN_DRAIN_MS = 10_000; + +/** Time to wait after SIGTERM before sending SIGKILL. */ +const SIGTERM_GRACE_MS = 5_000; + +/** Minimum backoff delay for crash recovery (1 second). */ +const MIN_BACKOFF_MS = 1_000; + +/** Maximum backoff delay for crash recovery (5 minutes). */ +const MAX_BACKOFF_MS = 5 * 60 * 1_000; + +/** Backoff multiplier on each consecutive crash. */ +const BACKOFF_MULTIPLIER = 2; + +/** Maximum number of consecutive crashes before giving up on auto-restart. */ +const MAX_CONSECUTIVE_CRASHES = 10; + +/** Time window in which crashes are considered consecutive (10 minutes). */ +const CRASH_WINDOW_MS = 10 * 60 * 1_000; + +/** Maximum number of stderr characters retained for worker failure context. */ +const MAX_STDERR_EXCERPT_CHARS = 8_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Status of a managed worker process. + */ +export type WorkerStatus = + | "stopped" + | "starting" + | "running" + | "stopping" + | "crashed" + | "backoff"; + +/** + * Worker-to-host method handler. The host registers these to service calls + * that the plugin worker makes back to the host (e.g. state.get, events.emit). + */ +export type WorkerToHostHandler = ( + params: WorkerToHostMethods[M][0], +) => Promise; + +/** + * A map of all worker-to-host method handlers provided by the host. + */ +export type WorkerToHostHandlers = { + [M in WorkerToHostMethodName]?: WorkerToHostHandler; +}; + +/** + * Events emitted by a PluginWorkerHandle. + */ +export interface WorkerHandleEvents { + /** Worker process started and is ready (initialize succeeded). */ + "ready": { pluginId: string }; + /** Worker process exited. */ + "exit": { pluginId: string; code: number | null; signal: NodeJS.Signals | null }; + /** Worker process crashed unexpectedly. */ + "crash": { pluginId: string; code: number | null; signal: NodeJS.Signals | null; willRestart: boolean }; + /** Worker process errored (e.g. spawn failure). */ + "error": { pluginId: string; error: Error }; + /** Worker status changed. */ + "status": { pluginId: string; status: WorkerStatus; previousStatus: WorkerStatus }; +} + +type WorkerHandleEventName = keyof WorkerHandleEvents; + +export function appendStderrExcerpt(current: string, chunk: string): string { + const next = current ? `${current}\n${chunk}` : chunk; + return next.length <= MAX_STDERR_EXCERPT_CHARS + ? next + : next.slice(-MAX_STDERR_EXCERPT_CHARS); +} + +export function formatWorkerFailureMessage(message: string, stderrExcerpt: string): string { + const excerpt = stderrExcerpt.trim(); + if (!excerpt) return message; + if (message.includes(excerpt)) return message; + return `${message}\n\nWorker stderr:\n${excerpt}`; +} + +/** + * Options for starting a worker process. + */ +export interface WorkerStartOptions { + /** Absolute path to the plugin worker entrypoint (CJS bundle). */ + entrypointPath: string; + /** Plugin manifest. */ + manifest: PaperclipPluginManifestV1; + /** Resolved plugin configuration. */ + config: Record; + /** Host instance information for the initialize call. */ + instanceInfo: { + instanceId: string; + hostVersion: string; + }; + /** Host API version. */ + apiVersion: number; + /** Handlers for worker→host RPC calls. */ + hostHandlers: WorkerToHostHandlers; + /** Default timeout for RPC calls (ms). Defaults to 30s. */ + rpcTimeoutMs?: number; + /** Whether to auto-restart on crash. Defaults to true. */ + autoRestart?: boolean; + /** Node.js execArgv passed to the child process. */ + execArgv?: string[]; + /** Environment variables passed to the child process. */ + env?: Record; + /** + * Callback for stream notifications from the worker (streams.open/emit/close). + * The host wires this to the PluginStreamBus to fan out events to SSE clients. + */ + onStreamNotification?: (method: string, params: Record) => void; +} + +/** + * A pending RPC call waiting for a response from the worker. + */ +interface PendingRequest { + /** The request ID. */ + id: JsonRpcId; + /** Method name (for logging). */ + method: string; + /** Resolve the promise with the response. */ + resolve: (response: JsonRpcResponse) => void; + /** Timeout timer handle. */ + timer: ReturnType; + /** Timestamp when the request was sent. */ + sentAt: number; +} + +// --------------------------------------------------------------------------- +// PluginWorkerHandle — manages a single worker process +// --------------------------------------------------------------------------- + +/** + * Handle for a single plugin worker process. + * + * Callers use `start()` to spawn the worker, `call()` to send RPC requests, + * and `stop()` to gracefully shut down. The handle manages crash recovery + * with exponential backoff automatically when `autoRestart` is enabled. + */ +export interface PluginWorkerHandle { + /** The plugin ID this worker serves. */ + readonly pluginId: string; + + /** Current worker status. */ + readonly status: WorkerStatus; + + /** Start the worker process. Resolves when initialize completes. */ + start(): Promise; + + /** + * Stop the worker process gracefully. + * + * Sends a `shutdown` RPC call, waits up to 10 seconds for the worker to + * exit, then escalates to SIGTERM, and finally SIGKILL if needed. + */ + stop(): Promise; + + /** + * Restart the worker process (stop + start). + */ + restart(): Promise; + + /** + * Send a typed host→worker RPC call. + * + * @param method - The RPC method name + * @param params - Method parameters + * @param timeoutMs - Optional per-call timeout override + * @returns The method result + * @throws {JsonRpcCallError} if the worker returns an error response + * @throws {Error} if the worker is not running or the call times out + */ + call( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise; + + /** + * Send a fire-and-forget notification to the worker (no response expected). + */ + notify(method: string, params: unknown): void; + + /** Subscribe to worker events. */ + on( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ): void; + + /** Unsubscribe from worker events. */ + off( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ): void; + + /** Optional methods the worker reported during initialization. */ + readonly supportedMethods: string[]; + + /** Get diagnostic info about the worker. */ + diagnostics(): WorkerDiagnostics; +} + +/** + * Diagnostic information about a worker process. + */ +export interface WorkerDiagnostics { + pluginId: string; + status: WorkerStatus; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; +} + +// --------------------------------------------------------------------------- +// PluginWorkerManager — manages all plugin workers +// --------------------------------------------------------------------------- + +/** + * The top-level manager that holds all plugin worker handles. + * + * Provides a registry of workers keyed by plugin ID, with convenience methods + * for starting/stopping all workers and routing RPC calls. + */ +export interface PluginWorkerManager { + /** + * Register and start a worker for a plugin. + * + * @returns The worker handle + * @throws if a worker is already registered for this plugin + */ + startWorker(pluginId: string, options: WorkerStartOptions): Promise; + + /** + * Stop and unregister a specific plugin worker. + */ + stopWorker(pluginId: string): Promise; + + /** + * Get the worker handle for a plugin. + */ + getWorker(pluginId: string): PluginWorkerHandle | undefined; + + /** + * Check if a worker is registered and running for a plugin. + */ + isRunning(pluginId: string): boolean; + + /** + * Stop all managed workers. Called during server shutdown. + */ + stopAll(): Promise; + + /** + * Get diagnostic info for all workers. + */ + diagnostics(): WorkerDiagnostics[]; + + /** + * Send an RPC call to a specific plugin worker. + * + * @throws if the worker is not running + */ + call( + pluginId: string, + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Implementation: createPluginWorkerHandle +// --------------------------------------------------------------------------- + +/** + * Create a handle for a single plugin worker process. + * + * @internal Exported for testing; consumers should use `createPluginWorkerManager`. + */ +export function createPluginWorkerHandle( + pluginId: string, + options: WorkerStartOptions, +): PluginWorkerHandle { + const log = logger.child({ service: "plugin-worker", pluginId }); + const emitter = new EventEmitter(); + /** + * Higher than default (10) to accommodate multiple subscribers to + * crash/ready/exit events during integration tests and runtime monitoring. + */ + emitter.setMaxListeners(50); + + // Worker process state + let childProcess: ChildProcess | null = null; + let readline: ReadlineInterface | null = null; + let stderrReadline: ReadlineInterface | null = null; + let status: WorkerStatus = "stopped"; + let startedAt: number | null = null; + let stderrExcerpt = ""; + + // Pending RPC requests awaiting a response + const pendingRequests = new Map(); + let nextRequestId = 1; + + // Optional methods reported by the worker during initialization + let supportedMethods: string[] = []; + + // Crash tracking for exponential backoff + let consecutiveCrashes = 0; + let totalCrashes = 0; + let lastCrashAt: number | null = null; + let backoffTimer: ReturnType | null = null; + let nextRestartAt: number | null = null; + + // Track open stream channels so we can emit synthetic close on crash. + // Maps channel → companyId. + const openStreamChannels = new Map(); + + // Shutdown coordination + let intentionalStop = false; + + const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + const autoRestart = options.autoRestart ?? true; + + // ----------------------------------------------------------------------- + // Status management + // ----------------------------------------------------------------------- + + function setStatus(newStatus: WorkerStatus): void { + const prev = status; + if (prev === newStatus) return; + status = newStatus; + log.debug({ from: prev, to: newStatus }, "worker status change"); + emitter.emit("status", { pluginId, status: newStatus, previousStatus: prev }); + } + + // ----------------------------------------------------------------------- + // JSON-RPC message sending + // ----------------------------------------------------------------------- + + function sendMessage(message: unknown): void { + if (!childProcess?.stdin?.writable) { + throw new Error(`Worker process for plugin "${pluginId}" is not writable`); + } + const serialized = serializeMessage(message as any); + childProcess.stdin.write(serialized); + } + + // ----------------------------------------------------------------------- + // Incoming message handling + // ----------------------------------------------------------------------- + + function handleLine(line: string): void { + if (!line.trim()) return; + + let message: unknown; + try { + message = parseMessage(line); + } catch (err) { + if (err instanceof JsonRpcParseError) { + log.warn({ rawLine: line.slice(0, 200) }, "unparseable message from worker"); + } else { + log.warn({ err }, "error parsing worker message"); + } + return; + } + + if (isJsonRpcResponse(message)) { + handleResponse(message); + } else if (isJsonRpcRequest(message)) { + handleWorkerRequest(message as JsonRpcRequest); + } else if (isJsonRpcNotification(message)) { + handleWorkerNotification(message as JsonRpcNotification); + } else { + log.warn("unknown message type from worker"); + } + } + + /** + * Handle a JSON-RPC response from the worker (matching a pending request). + */ + function handleResponse(response: JsonRpcResponse): void { + const id = response.id; + if (id === null || id === undefined) { + log.warn("received response with null/undefined id"); + return; + } + + const pending = pendingRequests.get(id); + if (!pending) { + log.warn({ id }, "received response for unknown request id"); + return; + } + + clearTimeout(pending.timer); + pendingRequests.delete(id); + pending.resolve(response); + } + + /** + * Handle a JSON-RPC request from the worker (worker→host call). + */ + async function handleWorkerRequest(request: JsonRpcRequest): Promise { + const method = request.method as WorkerToHostMethodName; + const handler = options.hostHandlers[method] as + | ((params: unknown) => Promise) + | undefined; + + if (!handler) { + log.warn({ method }, "worker called unregistered host method"); + try { + sendMessage( + createErrorResponse( + request.id, + JSONRPC_ERROR_CODES.METHOD_NOT_FOUND, + `Host does not handle method "${method}"`, + ), + ); + } catch { + // Worker may have exited, ignore send error + } + return; + } + + try { + const result = await handler(request.params); + sendMessage({ + jsonrpc: JSONRPC_VERSION, + id: request.id, + result: result ?? null, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error({ method, err: errorMessage }, "host handler error"); + try { + sendMessage( + createErrorResponse( + request.id, + JSONRPC_ERROR_CODES.INTERNAL_ERROR, + errorMessage, + ), + ); + } catch { + // Worker may have exited, ignore send error + } + } + } + + /** + * Handle a JSON-RPC notification from the worker (fire-and-forget). + * + * The `log` notification is the primary case — worker `ctx.logger` calls + * arrive here. We append structured plugin context (pluginId, timestamp, + * level) so that every log entry is queryable per the spec (§26.1). + */ + function handleWorkerNotification(notification: JsonRpcNotification): void { + if (notification.method === "log") { + const params = notification.params as { + level?: string; + message?: string; + meta?: Record; + } | null; + const level = params?.level ?? "info"; + const msg = params?.message ?? ""; + const meta = params?.meta; + + // Build a structured log object that includes the plugin context fields + // required by §26.1: pluginId, timestamp, level, message, and metadata. + // The child logger already carries `pluginId` in its bindings, but we + // add explicit `pluginLogLevel` and `pluginTimestamp` so downstream + // consumers (log storage, UI queries) can filter without parsing. + const logFields: Record = { + ...meta, + pluginLogLevel: level, + pluginTimestamp: new Date().toISOString(), + }; + + if (level === "error") { + log.error(logFields, `[plugin] ${msg}`); + } else if (level === "warn") { + log.warn(logFields, `[plugin] ${msg}`); + } else if (level === "debug") { + log.debug(logFields, `[plugin] ${msg}`); + } else { + log.info(logFields, `[plugin] ${msg}`); + } + return; + } + + // Stream notifications: forward to the stream bus via callback + if ( + notification.method === "streams.open" || + notification.method === "streams.emit" || + notification.method === "streams.close" + ) { + const params = (notification.params ?? {}) as Record; + + // Track open channels so we can emit synthetic close on crash + if (notification.method === "streams.open") { + const ch = String(params.channel ?? ""); + const co = String(params.companyId ?? ""); + if (ch) openStreamChannels.set(ch, co); + } else if (notification.method === "streams.close") { + openStreamChannels.delete(String(params.channel ?? "")); + } + + if (options.onStreamNotification) { + try { + options.onStreamNotification(notification.method, params); + } catch (err) { + log.error( + { + method: notification.method, + err: err instanceof Error ? err.message : String(err), + }, + "stream notification handler failed", + ); + } + } + return; + } + + log.debug({ method: notification.method }, "received notification from worker"); + } + + // ----------------------------------------------------------------------- + // Process lifecycle + // ----------------------------------------------------------------------- + + function spawnProcess(): ChildProcess { + // Security: Do NOT spread process.env into the worker. Plugins should only + // receive a minimal, controlled environment to prevent leaking host + // secrets (like DATABASE_URL, internal API keys, etc.). + const workerEnv: Record = { + ...options.env, + PATH: process.env.PATH ?? "", + NODE_PATH: process.env.NODE_PATH ?? "", + PAPERCLIP_PLUGIN_ID: pluginId, + NODE_ENV: process.env.NODE_ENV ?? "production", + TZ: process.env.TZ ?? "UTC", + }; + + const child = fork(options.entrypointPath, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + execArgv: options.execArgv ?? [], + env: workerEnv, + // Don't let the child keep the parent alive + detached: false, + }); + + return child; + } + + function attachStdioHandlers(child: ChildProcess): void { + // Read NDJSON from stdout + if (child.stdout) { + readline = createInterface({ input: child.stdout }); + readline.on("line", handleLine); + } + + // Capture stderr for logging + if (child.stderr) { + stderrReadline = createInterface({ input: child.stderr }); + stderrReadline.on("line", (line: string) => { + stderrExcerpt = appendStderrExcerpt(stderrExcerpt, line); + log.warn({ stream: "stderr" }, `[plugin stderr] ${line}`); + }); + } + + // Handle process exit + child.on("exit", (code, signal) => { + handleProcessExit(code, signal); + }); + + // Handle process errors (e.g. spawn failure) + child.on("error", (err) => { + log.error({ err: err.message }, "worker process error"); + emitter.emit("error", { pluginId, error: err }); + if (status === "starting") { + setStatus("crashed"); + rejectAllPending( + new Error(formatWorkerFailureMessage( + `Worker process failed to start: ${err.message}`, + stderrExcerpt, + )), + ); + } + }); + } + + function handleProcessExit( + code: number | null, + signal: NodeJS.Signals | null, + ): void { + const wasIntentional = intentionalStop; + + // Clean up readline interfaces + if (readline) { + readline.close(); + readline = null; + } + if (stderrReadline) { + stderrReadline.close(); + stderrReadline = null; + } + childProcess = null; + startedAt = null; + + // Reject all pending requests + rejectAllPending( + new Error(formatWorkerFailureMessage( + `Worker process exited (code=${code}, signal=${signal})`, + stderrExcerpt, + )), + ); + + // Emit synthetic close for any orphaned stream channels so SSE clients + // are notified instead of hanging indefinitely. + if (openStreamChannels.size > 0 && options.onStreamNotification) { + for (const [channel, companyId] of openStreamChannels) { + try { + options.onStreamNotification("streams.close", { channel, companyId }); + } catch { + // Best-effort cleanup — don't let it interfere with exit handling + } + } + openStreamChannels.clear(); + } + + emitter.emit("exit", { pluginId, code, signal }); + + if (wasIntentional) { + // Graceful stop — status is already "stopping" or will be set to "stopped" + setStatus("stopped"); + log.info({ code, signal }, "worker process stopped"); + return; + } + + // Unexpected exit — crash recovery + totalCrashes++; + const now = Date.now(); + + // Reset consecutive crash counter if enough time passed + if (lastCrashAt !== null && now - lastCrashAt > CRASH_WINDOW_MS) { + consecutiveCrashes = 0; + } + consecutiveCrashes++; + lastCrashAt = now; + + log.error( + { code, signal, consecutiveCrashes, totalCrashes }, + "worker process crashed", + ); + + const willRestart = + autoRestart && consecutiveCrashes <= MAX_CONSECUTIVE_CRASHES; + + setStatus("crashed"); + emitter.emit("crash", { pluginId, code, signal, willRestart }); + + if (willRestart) { + scheduleRestart(); + } else { + log.error( + { consecutiveCrashes, maxCrashes: MAX_CONSECUTIVE_CRASHES }, + "max consecutive crashes reached, not restarting", + ); + } + } + + function rejectAllPending(error: Error): void { + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.resolve( + createErrorResponse( + pending.id, + PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, + error.message, + ) as JsonRpcResponse, + ); + } + pendingRequests.clear(); + } + + // ----------------------------------------------------------------------- + // Crash recovery with exponential backoff + // ----------------------------------------------------------------------- + + function computeBackoffMs(): number { + // Exponential backoff: MIN_BACKOFF * MULTIPLIER^(consecutiveCrashes - 1) + const delay = + MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, consecutiveCrashes - 1); + // Add jitter: ±25% + const jitter = delay * 0.25 * (Math.random() * 2 - 1); + return Math.min(Math.round(delay + jitter), MAX_BACKOFF_MS); + } + + function scheduleRestart(): void { + const delay = computeBackoffMs(); + nextRestartAt = Date.now() + delay; + + setStatus("backoff"); + + log.info( + { delayMs: delay, consecutiveCrashes }, + "scheduling restart with backoff", + ); + + backoffTimer = setTimeout(async () => { + backoffTimer = null; + nextRestartAt = null; + try { + await startInternal(); + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + "restart after backoff failed", + ); + } + }, delay); + } + + function cancelPendingRestart(): void { + if (backoffTimer !== null) { + clearTimeout(backoffTimer); + backoffTimer = null; + nextRestartAt = null; + } + } + + // ----------------------------------------------------------------------- + // Start / Stop + // ----------------------------------------------------------------------- + + async function startInternal(): Promise { + if (status === "running" || status === "starting") { + throw new Error(`Worker for plugin "${pluginId}" is already ${status}`); + } + + intentionalStop = false; + setStatus("starting"); + stderrExcerpt = ""; + + const child = spawnProcess(); + childProcess = child; + attachStdioHandlers(child); + startedAt = Date.now(); + + // Send the initialize RPC call + const initParams: InitializeParams = { + manifest: options.manifest, + config: options.config, + instanceInfo: options.instanceInfo, + apiVersion: options.apiVersion, + }; + + try { + const result = await callInternal( + "initialize", + initParams, + INITIALIZE_TIMEOUT_MS, + ) as { ok?: boolean; supportedMethods?: string[] } | undefined; + if (!result || !result.ok) { + throw new Error("Worker initialize returned ok=false"); + } + supportedMethods = result.supportedMethods ?? []; + } catch (err) { + // Initialize failed — kill the process and propagate + const msg = err instanceof Error ? err.message : String(err); + log.error({ err: msg }, "worker initialize failed"); + await killProcess(); + setStatus("crashed"); + throw new Error(`Worker initialize failed for "${pluginId}": ${msg}`); + } + + // Reset crash counter on successful start + consecutiveCrashes = 0; + setStatus("running"); + emitter.emit("ready", { pluginId }); + log.info({ pid: child.pid }, "worker process started and initialized"); + } + + async function stopInternal(): Promise { + cancelPendingRestart(); + + if (status === "stopped" || status === "stopping") { + return; + } + + intentionalStop = true; + setStatus("stopping"); + + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 1: Send shutdown RPC and wait for the worker to exit gracefully. + // We race the shutdown call against a timeout. The worker should process + // the shutdown and exit on its own within the drain period. + try { + await Promise.race([ + callInternal("shutdown", {} as Record, SHUTDOWN_DRAIN_MS), + waitForExit(SHUTDOWN_DRAIN_MS), + ]); + } catch { + // Shutdown call failed or timed out — proceed to kill + log.warn("shutdown RPC failed or timed out, escalating to SIGTERM"); + } + + // Give the process a brief moment to exit after the shutdown response + if (childProcess) { + await waitForExit(500); + } + + // Check if process already exited + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 2: Send SIGTERM and wait + log.info("worker did not exit after shutdown RPC, sending SIGTERM"); + await killWithSignal("SIGTERM", SIGTERM_GRACE_MS); + + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 3: Forcefully kill with SIGKILL + log.warn("worker did not exit after SIGTERM, sending SIGKILL"); + await killWithSignal("SIGKILL", 2_000); + + if (childProcess) { + log.error("worker process still alive after SIGKILL — this should not happen"); + } + + setStatus("stopped"); + } + + /** + * Wait for the child process to exit, up to `timeoutMs`. + * Resolves immediately if the process is already gone. + */ + function waitForExit(timeoutMs: number): Promise { + return new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(); + }, timeoutMs); + + childProcess.once("exit", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }); + }); + } + + function killWithSignal( + signal: NodeJS.Signals, + waitMs: number, + ): Promise { + return new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + + const timer = setTimeout(() => { + resolve(); + }, waitMs); + + childProcess.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + + try { + childProcess.kill(signal); + } catch { + clearTimeout(timer); + resolve(); + } + }); + } + + async function killProcess(): Promise { + if (!childProcess) return; + intentionalStop = true; + try { + childProcess.kill("SIGKILL"); + } catch { + // Process may already be dead + } + // Wait briefly for exit event + await new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + const timer = setTimeout(() => { + resolve(); + }, 1_000); + childProcess.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + // ----------------------------------------------------------------------- + // RPC call implementation + // ----------------------------------------------------------------------- + + function callInternal( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (!childProcess?.stdin?.writable) { + reject( + new Error( + `Cannot call "${method}" — worker for "${pluginId}" is not running`, + ), + ); + return; + } + + const id = nextRequestId++; + const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); + + // Guard against double-settlement. When a process exits all pending + // requests are rejected via rejectAllPending(), but the timeout timer + // may still be running. Without this guard the timer's reject fires on + // an already-settled promise, producing an unhandled rejection. + let settled = false; + + const settle = (fn: (value: T) => void, value: T): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + pendingRequests.delete(id); + fn(value); + }; + + const timer = setTimeout(() => { + settle( + reject, + new JsonRpcCallError({ + code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, + message: `RPC call "${method}" timed out after ${timeout}ms`, + }), + ); + }, timeout); + + const pending: PendingRequest = { + id, + method, + resolve: (response: JsonRpcResponse) => { + if (isJsonRpcSuccessResponse(response)) { + settle(resolve, response.result as HostToWorkerMethods[M][1]); + } else if ("error" in response && response.error) { + settle(reject, new JsonRpcCallError(response.error)); + } else { + settle(reject, new Error(`Unexpected response format for "${method}"`)); + } + }, + timer, + sentAt: Date.now(), + }; + + pendingRequests.set(id, pending); + + try { + const request = createRequest(method, params, id); + sendMessage(request); + } catch (err) { + clearTimeout(timer); + pendingRequests.delete(id); + reject( + new Error( + `Failed to send "${method}" to worker: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + }); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + const handle: PluginWorkerHandle = { + get pluginId() { + return pluginId; + }, + + get status() { + return status; + }, + + get supportedMethods() { + return supportedMethods; + }, + + async start() { + await startInternal(); + }, + + async stop() { + await stopInternal(); + }, + + async restart() { + await stopInternal(); + await startInternal(); + }, + + call( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + if (status !== "running" && status !== "starting") { + return Promise.reject( + new Error( + `Cannot call "${method}" — worker for "${pluginId}" is ${status}`, + ), + ); + } + return callInternal(method, params, timeoutMs); + }, + + notify(method: string, params: unknown) { + if (status !== "running") return; + try { + sendMessage({ + jsonrpc: JSONRPC_VERSION, + method, + params, + }); + } catch { + log.warn({ method }, "failed to send notification to worker"); + } + }, + + on( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ) { + emitter.on(event, listener); + }, + + off( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ) { + emitter.off(event, listener); + }, + + diagnostics(): WorkerDiagnostics { + return { + pluginId, + status, + pid: childProcess?.pid ?? null, + uptime: + startedAt !== null && status === "running" + ? Date.now() - startedAt + : null, + consecutiveCrashes, + totalCrashes, + pendingRequests: pendingRequests.size, + lastCrashAt, + nextRestartAt, + }; + }, + }; + + return handle; +} + +// --------------------------------------------------------------------------- +// Implementation: createPluginWorkerManager +// --------------------------------------------------------------------------- + +/** + * Options for creating a PluginWorkerManager. + */ +export interface PluginWorkerManagerOptions { + /** + * Optional callback invoked when a worker emits a lifecycle event + * (crash, restart). Used by the server to publish global live events. + */ + onWorkerEvent?: (event: { + type: "plugin.worker.crashed" | "plugin.worker.restarted"; + pluginId: string; + code?: number | null; + signal?: string | null; + willRestart?: boolean; + }) => void; +} + +/** + * Create a new PluginWorkerManager. + * + * The manager holds all plugin worker handles and provides a unified API for + * starting, stopping, and communicating with plugin workers. + * + * @example + * ```ts + * const manager = createPluginWorkerManager(); + * + * const handle = await manager.startWorker("acme.linear", { + * entrypointPath: "/path/to/worker.cjs", + * manifest, + * config: resolvedConfig, + * instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" }, + * apiVersion: 1, + * hostHandlers: { "config.get": async () => resolvedConfig, ... }, + * }); + * + * // Send RPC call to the worker + * const health = await manager.call("acme.linear", "health", {}); + * + * // Shutdown all workers on server exit + * await manager.stopAll(); + * ``` + */ +export function createPluginWorkerManager( + managerOptions?: PluginWorkerManagerOptions, +): PluginWorkerManager { + const log = logger.child({ service: "plugin-worker-manager" }); + const workers = new Map(); + /** Per-plugin startup locks to prevent concurrent spawn races. */ + const startupLocks = new Map>(); + + return { + async startWorker( + pluginId: string, + options: WorkerStartOptions, + ): Promise { + // Mutex: if a start is already in-flight for this plugin, wait for it + const inFlight = startupLocks.get(pluginId); + if (inFlight) { + log.warn({ pluginId }, "concurrent startWorker call — waiting for in-flight start"); + return inFlight; + } + + const existing = workers.get(pluginId); + if (existing && existing.status !== "stopped") { + throw new Error( + `Worker already registered for plugin "${pluginId}" (status: ${existing.status})`, + ); + } + + const handle = createPluginWorkerHandle(pluginId, options); + workers.set(pluginId, handle); + + // Subscribe to crash/ready events for live event forwarding + if (managerOptions?.onWorkerEvent) { + const notify = managerOptions.onWorkerEvent; + handle.on("crash", (payload) => { + notify({ + type: "plugin.worker.crashed", + pluginId: payload.pluginId, + code: payload.code, + signal: payload.signal, + willRestart: payload.willRestart, + }); + }); + handle.on("ready", (payload) => { + // Only emit restarted if this was a crash recovery (totalCrashes > 0) + const diag = handle.diagnostics(); + if (diag.totalCrashes > 0) { + notify({ + type: "plugin.worker.restarted", + pluginId: payload.pluginId, + }); + } + }); + } + + log.info({ pluginId }, "starting plugin worker"); + + // Set the lock before awaiting start() to prevent concurrent spawns + const startPromise = handle.start().then(() => handle).finally(() => { + startupLocks.delete(pluginId); + }); + startupLocks.set(pluginId, startPromise); + + return startPromise; + }, + + async stopWorker(pluginId: string): Promise { + const handle = workers.get(pluginId); + if (!handle) { + log.warn({ pluginId }, "no worker registered for plugin, nothing to stop"); + return; + } + + log.info({ pluginId }, "stopping plugin worker"); + await handle.stop(); + workers.delete(pluginId); + }, + + getWorker(pluginId: string): PluginWorkerHandle | undefined { + return workers.get(pluginId); + }, + + isRunning(pluginId: string): boolean { + const handle = workers.get(pluginId); + return handle?.status === "running"; + }, + + async stopAll(): Promise { + log.info({ count: workers.size }, "stopping all plugin workers"); + const promises = Array.from(workers.values()).map(async (handle) => { + try { + await handle.stop(); + } catch (err) { + log.error( + { + pluginId: handle.pluginId, + err: err instanceof Error ? err.message : String(err), + }, + "error stopping worker during shutdown", + ); + } + }); + await Promise.all(promises); + workers.clear(); + }, + + diagnostics(): WorkerDiagnostics[] { + return Array.from(workers.values()).map((h) => h.diagnostics()); + }, + + call( + pluginId: string, + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + const handle = workers.get(pluginId); + if (!handle) { + return Promise.reject( + new Error(`No worker registered for plugin "${pluginId}"`), + ); + } + return handle.call(method, params, timeoutMs); + }, + }; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1cfdd9df..a05bbcec 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,9 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceSettings } from "./pages/InstanceSettings"; +import { PluginManager } from "./pages/PluginManager"; +import { PluginSettings } from "./pages/PluginSettings"; +import { PluginPage } from "./pages/PluginPage"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -113,6 +116,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -162,7 +166,7 @@ function InboxRootRedirect() { function LegacySettingsRedirect() { const location = useLocation(); - return ; + return ; } function OnboardingRoutePage() { @@ -295,9 +299,12 @@ export function App() { }> } /> } /> - } /> + } /> }> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index b1b4f648..1071ba8f 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -41,6 +41,8 @@ export const api = { request(path, { method: "POST", body: JSON.stringify(body) }), postForm: (path: string, body: FormData) => request(path, { method: "POST", body }), + put: (path: string, body: unknown) => + request(path, { method: "PUT", body: JSON.stringify(body) }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts new file mode 100644 index 00000000..f2b7d31b --- /dev/null +++ b/ui/src/api/plugins.ts @@ -0,0 +1,469 @@ +/** + * @fileoverview Frontend API client for the Paperclip plugin system. + * + * All functions in `pluginsApi` map 1:1 to REST endpoints on + * `server/src/routes/plugins.ts`. Call sites should consume these functions + * through React Query hooks (`useQuery` / `useMutation`) and reference cache + * keys from `queryKeys.plugins.*`. + * + * @see ui/src/lib/queryKeys.ts for cache key definitions. + * @see server/src/routes/plugins.ts for endpoint implementation details. + */ + +import type { + PluginLauncherDeclaration, + PluginLauncherRenderContextSnapshot, + PluginUiSlotDeclaration, + PluginRecord, + PluginConfig, + PluginStatus, + CompanyPluginAvailability, +} from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Normalized UI contribution record returned by `GET /api/plugins/ui-contributions`. + * + * Only populated for plugins in `ready` state that declare at least one UI slot + * or launcher. The `slots` array is sourced from `manifest.ui.slots`. The + * `launchers` array aggregates both legacy `manifest.launchers` and + * `manifest.ui.launchers`. + */ +export type PluginUiContribution = { + pluginId: string; + pluginKey: string; + displayName: string; + version: string; + updatedAt?: string; + /** + * Relative filename of the UI entry module within the plugin's UI directory. + * The host constructs the full import URL as + * `/_plugins/${pluginId}/ui/${uiEntryFile}`. + */ + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +}; + +/** + * Health check result returned by `GET /api/plugins/:pluginId/health`. + * + * The `healthy` flag summarises whether all checks passed. Individual check + * results are available in `checks` for detailed diagnostics display. + */ +export interface PluginHealthCheckResult { + pluginId: string; + /** The plugin's current lifecycle status at time of check. */ + status: string; + /** True if all health checks passed. */ + healthy: boolean; + /** Individual diagnostic check results. */ + checks: Array<{ + name: string; + passed: boolean; + /** Human-readable description of a failure, if any. */ + message?: string; + }>; + /** The most recent error message if the plugin is in `error` state. */ + lastError?: string; +} + +/** + * Worker diagnostics returned as part of the dashboard response. + */ +export interface PluginWorkerDiagnostics { + status: string; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; +} + +/** + * A recent job run entry returned in the dashboard response. + */ +export interface PluginDashboardJobRun { + id: string; + jobId: string; + jobKey?: string; + trigger: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; +} + +/** + * A recent webhook delivery entry returned in the dashboard response. + */ +export interface PluginDashboardWebhookDelivery { + id: string; + webhookKey: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; +} + +/** + * Aggregated health dashboard data returned by `GET /api/plugins/:pluginId/dashboard`. + * + * Contains worker diagnostics, recent job runs, recent webhook deliveries, + * and the current health check result — all in a single response. + */ +export interface PluginDashboardData { + pluginId: string; + /** Worker process diagnostics, or null if no worker is registered. */ + worker: PluginWorkerDiagnostics | null; + /** Recent job execution history (newest first, max 10). */ + recentJobRuns: PluginDashboardJobRun[]; + /** Recent inbound webhook deliveries (newest first, max 10). */ + recentWebhookDeliveries: PluginDashboardWebhookDelivery[]; + /** Current health check results. */ + health: PluginHealthCheckResult; + /** ISO 8601 timestamp when the dashboard data was generated. */ + checkedAt: string; +} + +export interface AvailablePluginExample { + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: "example"; +} + +/** + * Plugin management API client. + * + * All methods are thin wrappers around the `api` base client. They return + * promises that resolve to typed JSON responses or throw on HTTP errors. + * + * @example + * ```tsx + * // In a component: + * const { data: plugins } = useQuery({ + * queryKey: queryKeys.plugins.all, + * queryFn: () => pluginsApi.list(), + * }); + * ``` + */ +export const pluginsApi = { + /** + * List all installed plugins, optionally filtered by lifecycle status. + * + * @param status - Optional filter; must be a valid `PluginStatus` value. + * Invalid values are rejected by the server with HTTP 400. + */ + list: (status?: PluginStatus) => + api.get(`/plugins${status ? `?status=${status}` : ""}`), + + /** + * List bundled example plugins available from the current repo checkout. + */ + listExamples: () => + api.get("/plugins/examples"), + + /** + * Fetch a single plugin record by its UUID or plugin key. + * + * @param pluginId - The plugin's UUID (from `PluginRecord.id`) or plugin key. + */ + get: (pluginId: string) => + api.get(`/plugins/${pluginId}`), + + /** + * Install a plugin from npm or a local path. + * + * On success, the plugin is registered in the database and transitioned to + * `ready` state. The response is the newly created `PluginRecord`. + * + * @param params.packageName - npm package name (e.g. `@paperclip/plugin-linear`) + * or a filesystem path when `isLocalPath` is `true`. + * @param params.version - Target npm version tag/range (optional; defaults to latest). + * @param params.isLocalPath - Set to `true` when `packageName` is a local path. + */ + install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => + api.post("/plugins/install", params), + + /** + * Uninstall a plugin. + * + * @param pluginId - UUID of the plugin to remove. + * @param purge - If `true`, permanently delete all plugin data (hard delete). + * Otherwise the plugin is soft-deleted with a 30-day data retention window. + */ + uninstall: (pluginId: string, purge?: boolean) => + api.delete<{ ok: boolean }>(`/plugins/${pluginId}${purge ? "?purge=true" : ""}`), + + /** + * Transition a plugin from `error` state back to `ready`. + * No-ops if the plugin is already enabled. + * + * @param pluginId - UUID of the plugin to enable. + */ + enable: (pluginId: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/enable`, {}), + + /** + * Disable a plugin (transition to `error` state with an operator sentinel). + * The plugin's worker is stopped; it will not process events until re-enabled. + * + * @param pluginId - UUID of the plugin to disable. + * @param reason - Optional human-readable reason stored in `lastError`. + */ + disable: (pluginId: string, reason?: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/disable`, reason ? { reason } : {}), + + /** + * Run health diagnostics for a plugin. + * + * Only meaningful for plugins in `ready` state. Returns the result of all + * registered health checks. Called on a 30-second polling interval by + * {@link PluginSettings}. + * + * @param pluginId - UUID of the plugin to health-check. + */ + health: (pluginId: string) => + api.get(`/plugins/${pluginId}/health`), + + /** + * Fetch aggregated health dashboard data for a plugin. + * + * Returns worker diagnostics, recent job runs, recent webhook deliveries, + * and the current health check result in a single request. Used by the + * {@link PluginSettings} page to render the runtime dashboard section. + * + * @param pluginId - UUID of the plugin. + */ + dashboard: (pluginId: string) => + api.get(`/plugins/${pluginId}/dashboard`), + + /** + * Fetch recent log entries for a plugin. + * + * @param pluginId - UUID of the plugin. + * @param options - Optional filters: limit, level, since. + */ + logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + if (options?.level) params.set("level", options.level); + if (options?.since) params.set("since", options.since); + const qs = params.toString(); + return api.get | null; createdAt: string }>>( + `/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`, + ); + }, + + /** + * Upgrade a plugin to a newer version. + * + * If the new version declares additional capabilities, the plugin is + * transitioned to `upgrade_pending` state awaiting operator approval. + * + * @param pluginId - UUID of the plugin to upgrade. + * @param version - Target version (optional; defaults to latest published). + */ + upgrade: (pluginId: string, version?: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/upgrade`, version ? { version } : {}), + + /** + * Returns normalized UI contribution declarations for ready plugins. + * Used by the slot host runtime and launcher discovery surfaces. + * + * When `companyId` is provided, the server filters out plugins that are + * disabled for that company before returning contributions. + * + * Response shape: + * - `slots`: concrete React mount declarations from `manifest.ui.slots` + * - `launchers`: host-owned entry points from `manifest.ui.launchers` plus + * the legacy top-level `manifest.launchers` + * + * @example + * ```ts + * const rows = await pluginsApi.listUiContributions(companyId); + * const toolbarLaunchers = rows.flatMap((row) => + * row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"), + * ); + * ``` + */ + listUiContributions: (companyId?: string) => + api.get( + `/plugins/ui-contributions${companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""}`, + ), + + /** + * List plugin availability/settings for a specific company. + * + * @param companyId - UUID of the company. + * @param available - Optional availability filter. + */ + listForCompany: (companyId: string, available?: boolean) => + api.get( + `/companies/${companyId}/plugins${available === undefined ? "" : `?available=${available}`}`, + ), + + /** + * Fetch a single company-scoped plugin availability/settings record. + * + * @param companyId - UUID of the company. + * @param pluginId - Plugin UUID or plugin key. + */ + getForCompany: (companyId: string, pluginId: string) => + api.get(`/companies/${companyId}/plugins/${pluginId}`), + + /** + * Create, update, or clear company-scoped plugin settings. + * + * Company availability is enabled by default. This endpoint stores explicit + * overrides in `plugin_company_settings` so the selected company can be + * disabled without affecting the global plugin installation. + */ + saveForCompany: ( + companyId: string, + pluginId: string, + params: { + available: boolean; + settingsJson?: Record; + lastError?: string | null; + }, + ) => + api.put(`/companies/${companyId}/plugins/${pluginId}`, params), + + // =========================================================================== + // Plugin configuration endpoints + // =========================================================================== + + /** + * Fetch the current configuration for a plugin. + * + * Returns the `PluginConfig` record if one exists, or `null` if the plugin + * has not yet been configured. + * + * @param pluginId - UUID of the plugin. + */ + getConfig: (pluginId: string) => + api.get(`/plugins/${pluginId}/config`), + + /** + * Save (create or update) the configuration for a plugin. + * + * The server validates `configJson` against the plugin's `instanceConfigSchema` + * and returns the persisted `PluginConfig` record on success. + * + * @param pluginId - UUID of the plugin. + * @param configJson - Configuration values matching the plugin's `instanceConfigSchema`. + */ + saveConfig: (pluginId: string, configJson: Record) => + api.post(`/plugins/${pluginId}/config`, { configJson }), + + /** + * Call the plugin's `validateConfig` RPC method to test the configuration + * without persisting it. + * + * Returns `{ valid: true }` on success, or `{ valid: false, message: string }` + * when the plugin reports a validation failure. + * + * Only available when the plugin declares a `validateConfig` RPC handler. + * + * @param pluginId - UUID of the plugin. + * @param configJson - Configuration values to validate. + */ + testConfig: (pluginId: string, configJson: Record) => + api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }), + + // =========================================================================== + // Bridge proxy endpoints — used by the plugin UI bridge runtime + // =========================================================================== + + /** + * Proxy a `getData` call from a plugin UI component to its worker backend. + * + * This is the HTTP transport for `usePluginData(key, params)`. The bridge + * runtime calls this method and maps the response into `PluginDataResult`. + * + * On success, the response is `{ data: T }`. + * On failure, the response body is a `PluginBridgeError`-shaped object + * with `code`, `message`, and optional `details`. + * + * @param pluginId - UUID of the plugin whose worker should handle the request + * @param key - Plugin-defined data key (e.g. `"sync-health"`) + * @param params - Optional query parameters forwarded to the worker handler + * @param companyId - Optional company scope. When present, the server rejects + * the call with HTTP 403 if the plugin is disabled for that company. + * @param renderEnvironment - Optional launcher/page snapshot forwarded for + * launcher-backed UI so workers can distinguish modal, drawer, popover, and + * page execution. + * + * Error responses: + * - `401`/`403` when auth or company access checks fail + * - `404` when the plugin or handler key does not exist + * - `409` when the plugin is not in a callable runtime state + * - `5xx` with a `PluginBridgeError`-shaped body when the worker throws + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + bridgeGetData: ( + pluginId: string, + key: string, + params?: Record, + companyId?: string | null, + renderEnvironment?: PluginLauncherRenderContextSnapshot | null, + ) => + api.post<{ data: unknown }>(`/plugins/${pluginId}/data/${encodeURIComponent(key)}`, { + companyId: companyId ?? undefined, + params, + renderEnvironment: renderEnvironment ?? undefined, + }), + + /** + * Proxy a `performAction` call from a plugin UI component to its worker backend. + * + * This is the HTTP transport for `usePluginAction(key)`. The bridge runtime + * calls this method when the action function is invoked. + * + * On success, the response is `{ data: T }`. + * On failure, the response body is a `PluginBridgeError`-shaped object + * with `code`, `message`, and optional `details`. + * + * @param pluginId - UUID of the plugin whose worker should handle the request + * @param key - Plugin-defined action key (e.g. `"resync"`) + * @param params - Optional parameters forwarded to the worker handler + * @param companyId - Optional company scope. When present, the server rejects + * the call with HTTP 403 if the plugin is disabled for that company. + * @param renderEnvironment - Optional launcher/page snapshot forwarded for + * launcher-backed UI so workers can distinguish modal, drawer, popover, and + * page execution. + * + * Error responses: + * - `401`/`403` when auth or company access checks fail + * - `404` when the plugin or handler key does not exist + * - `409` when the plugin is not in a callable runtime state + * - `5xx` with a `PluginBridgeError`-shaped body when the worker throws + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + bridgePerformAction: ( + pluginId: string, + key: string, + params?: Record, + companyId?: string | null, + renderEnvironment?: PluginLauncherRenderContextSnapshot | null, + ) => + api.post<{ data: unknown }>(`/plugins/${pluginId}/actions/${encodeURIComponent(key)}`, { + companyId: companyId ?? undefined, + params, + renderEnvironment: renderEnvironment ?? undefined, + }), +}; diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index ac933aa1..0b396ef9 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -1,4 +1,4 @@ -import { Clock3, Settings } from "lucide-react"; +import { Clock3, Puzzle, Settings } from "lucide-react"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -13,7 +13,8 @@ export function InstanceSidebar() { diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx new file mode 100644 index 00000000..e185bf4b --- /dev/null +++ b/ui/src/components/JsonSchemaForm.tsx @@ -0,0 +1,1048 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Plus, + Trash2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Threshold for string length above which a Textarea is used instead of a standard Input. + */ +const TEXTAREA_THRESHOLD = 200; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Subset of JSON Schema properties we understand for form rendering. + * We intentionally keep this loose (`Record`) at the top + * level to match the `JsonSchema` type in shared, but narrow internally. + */ +export interface JsonSchemaNode { + type?: string | string[]; + title?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + const?: unknown; + format?: string; + + // String constraints + minLength?: number; + maxLength?: number; + pattern?: string; + + // Number constraints + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + + // Object + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JsonSchemaNode; + + // Array + items?: JsonSchemaNode; + minItems?: number; + maxItems?: number; + + // Metadata + readOnly?: boolean; + writeOnly?: boolean; + + // Allow extra keys + [key: string]: unknown; +} + +export interface JsonSchemaFormProps { + /** The JSON Schema to render. */ + schema: JsonSchemaNode; + /** Current form values. */ + values: Record; + /** Called whenever any field value changes. */ + onChange: (values: Record) => void; + /** Validation errors keyed by JSON pointer path (e.g. "/apiKey"). */ + errors?: Record; + /** If true, all fields are disabled. */ + disabled?: boolean; + /** Additional CSS class for the root container. */ + className?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve the primary type string from a schema node. */ +export function resolveType(schema: JsonSchemaNode): string { + if (schema.enum) return "enum"; + if (schema.const !== undefined) return "const"; + if (schema.format === "secret-ref") return "secret-ref"; + if (Array.isArray(schema.type)) { + // Use the first non-null type + return schema.type.find((t) => t !== "null") ?? "string"; + } + return schema.type ?? "string"; +} + +/** Human-readable label from schema title or property key. */ +export function labelFromKey(key: string, schema: JsonSchemaNode): string { + if (schema.title) return schema.title; + // Convert camelCase / snake_case to Title Case + return key + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Produce a sensible default value for a schema node. */ +export function getDefaultForSchema(schema: JsonSchemaNode): unknown { + if (schema.default !== undefined) return schema.default; + + const type = resolveType(schema); + switch (type) { + case "string": + case "secret-ref": + return ""; + case "number": + case "integer": + return schema.minimum ?? 0; + case "boolean": + return false; + case "enum": + return schema.enum?.[0] ?? ""; + case "array": + return []; + case "object": { + if (!schema.properties) return {}; + const obj: Record = {}; + for (const [key, propSchema] of Object.entries(schema.properties)) { + obj[key] = getDefaultForSchema(propSchema); + } + return obj; + } + default: + return ""; + } +} + +/** Validate a single field value against schema constraints. Returns error string or null. */ +export function validateField( + value: unknown, + schema: JsonSchemaNode, + isRequired: boolean, +): string | null { + const type = resolveType(schema); + + // Required check + if (isRequired && (value === undefined || value === null || value === "")) { + return "This field is required"; + } + + // Skip further validation if empty and not required + if (value === undefined || value === null || value === "") return null; + + if (type === "string" || type === "secret-ref") { + const str = String(value); + if (schema.minLength != null && str.length < schema.minLength) { + return `Must be at least ${schema.minLength} characters`; + } + if (schema.maxLength != null && str.length > schema.maxLength) { + return `Must be at most ${schema.maxLength} characters`; + } + if (schema.pattern) { + // Guard against ReDoS: reject overly complex patterns from plugin JSON Schemas. + // Limit pattern length and run the regex with a defensive try/catch. + const MAX_PATTERN_LENGTH = 512; + if (schema.pattern.length <= MAX_PATTERN_LENGTH) { + try { + const re = new RegExp(schema.pattern); + if (!re.test(str)) { + return `Must match pattern: ${schema.pattern}`; + } + } catch { + // Invalid regex in schema — skip + } + } + } + } + + if (type === "number" || type === "integer") { + const num = Number(value); + if (isNaN(num)) return "Must be a valid number"; + if (schema.minimum != null && num < schema.minimum) { + return `Must be at least ${schema.minimum}`; + } + if (schema.maximum != null && num > schema.maximum) { + return `Must be at most ${schema.maximum}`; + } + if (schema.exclusiveMinimum != null && num <= schema.exclusiveMinimum) { + return `Must be greater than ${schema.exclusiveMinimum}`; + } + if (schema.exclusiveMaximum != null && num >= schema.exclusiveMaximum) { + return `Must be less than ${schema.exclusiveMaximum}`; + } + if (type === "integer" && !Number.isInteger(num)) { + return "Must be a whole number"; + } + if (schema.multipleOf != null && num % schema.multipleOf !== 0) { + return `Must be a multiple of ${schema.multipleOf}`; + } + } + + if (type === "array") { + const arr = value as unknown[]; + if (schema.minItems != null && arr.length < schema.minItems) { + return `Must have at least ${schema.minItems} items`; + } + if (schema.maxItems != null && arr.length > schema.maxItems) { + return `Must have at most ${schema.maxItems} items`; + } + } + + return null; +} + +/** Public API for validation */ +export function validateJsonSchemaForm( + schema: JsonSchemaNode, + values: Record, + path: string[] = [], +): Record { + const errors: Record = {}; + const properties = schema.properties ?? {}; + const requiredFields = new Set(schema.required ?? []); + + for (const [key, propSchema] of Object.entries(properties)) { + const fieldPath = [...path, key]; + const errorKey = `/${fieldPath.join("/")}`; + const value = values[key]; + const isRequired = requiredFields.has(key); + const type = resolveType(propSchema); + + // Per-field validation + const fieldErr = validateField(value, propSchema, isRequired); + if (fieldErr) { + errors[errorKey] = fieldErr; + } + + // Recurse into objects + if (type === "object" && propSchema.properties && typeof value === "object" && value !== null) { + Object.assign( + errors, + validateJsonSchemaForm(propSchema, value as Record, fieldPath), + ); + } + + // Recurse into arrays + if (type === "array" && propSchema.items && Array.isArray(value)) { + const itemSchema = propSchema.items as JsonSchemaNode; + const isObjectItem = resolveType(itemSchema) === "object"; + + value.forEach((item, index) => { + const itemPath = [...fieldPath, String(index)]; + const itemErrorKey = `/${itemPath.join("/")}`; + + if (isObjectItem) { + Object.assign( + errors, + validateJsonSchemaForm( + itemSchema, + item as Record, + itemPath, + ), + ); + } else { + const itemErr = validateField(item, itemSchema, false); + if (itemErr) { + errors[itemErrorKey] = itemErr; + } + } + }); + } + } + + return errors; +} + +/** Public API for default values */ +export function getDefaultValues(schema: JsonSchemaNode): Record { + const result: Record = {}; + const properties = schema.properties ?? {}; + + for (const [key, propSchema] of Object.entries(properties)) { + const def = getDefaultForSchema(propSchema); + if (def !== undefined) { + result[key] = def; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Internal Components +// --------------------------------------------------------------------------- + +interface FieldWrapperProps { + label: string; + description?: string; + required?: boolean; + error?: string; + disabled?: boolean; + children: React.ReactNode; +} + +/** + * Common wrapper for form fields that handles labels, descriptions, and error messages. + */ +const FieldWrapper = React.memo(({ + label, + description, + required, + error, + disabled, + children, +}: FieldWrapperProps) => { + return ( +
    +
    + {label && ( + + )} +
    + {children} + {description && ( +

    + {description} +

    + )} + {error && ( +

    {error}

    + )} +
    + ); +}); + +FieldWrapper.displayName = "FieldWrapper"; + +interface FormFieldProps { + propSchema: JsonSchemaNode; + value: unknown; + onChange: (val: unknown) => void; + error?: string; + disabled?: boolean; + label: string; + isRequired?: boolean; + errors: Record; // needed for recursion + path: string; // needed for recursion error filtering +} + +/** + * Specialized field for boolean (checkbox) values. + */ +const BooleanField = React.memo(({ + id, + value, + onChange, + disabled, + label, + isRequired, + description, + error, +}: { + id: string; + value: unknown; + onChange: (val: unknown) => void; + disabled: boolean; + label: string; + isRequired?: boolean; + description?: string; + error?: string; +}) => ( +
    + +
    + {label && ( + + )} + {description && ( +

    {description}

    + )} + {error && ( +

    {error}

    + )} +
    +
    +)); + +BooleanField.displayName = "BooleanField"; + +/** + * Specialized field for enum (select) values. + */ +const EnumField = React.memo(({ + value, + onChange, + disabled, + label, + isRequired, + description, + error, + options, +}: { + value: unknown; + onChange: (val: unknown) => void; + disabled: boolean; + label: string; + isRequired?: boolean; + description?: string; + error?: string; + options: unknown[]; +}) => ( + + + +)); + +EnumField.displayName = "EnumField"; + +/** + * Specialized field for secret-ref values, providing a toggleable password input. + */ +const SecretField = React.memo(({ + value, + onChange, + disabled, + label, + isRequired, + description, + error, + defaultValue, +}: { + value: unknown; + onChange: (val: unknown) => void; + disabled: boolean; + label: string; + isRequired?: boolean; + description?: string; + error?: string; + defaultValue?: unknown; +}) => { + const [isVisible, setIsVisible] = useState(false); + return ( + +
    + onChange(e.target.value)} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + className="pr-10" + aria-invalid={!!error} + /> + +
    +
    + ); +}); + +SecretField.displayName = "SecretField"; + +/** + * Specialized field for numeric (number/integer) values. + */ +const NumberField = React.memo(({ + value, + onChange, + disabled, + label, + isRequired, + description, + error, + defaultValue, + type, +}: { + value: unknown; + onChange: (val: unknown) => void; + disabled: boolean; + label: string; + isRequired?: boolean; + description?: string; + error?: string; + defaultValue?: unknown; + type: "number" | "integer"; +}) => ( + + { + const val = e.target.value; + onChange(val === "" ? undefined : Number(val)); + }} + placeholder={String(defaultValue ?? "")} + disabled={disabled} + aria-invalid={!!error} + /> + +)); + +NumberField.displayName = "NumberField"; + +/** + * Specialized field for string values, rendering either an Input or Textarea based on length or format. + */ +const StringField = React.memo(({ + value, + onChange, + disabled, + label, + isRequired, + description, + error, + defaultValue, + format, + maxLength, +}: { + value: unknown; + onChange: (val: unknown) => void; + disabled: boolean; + label: string; + isRequired?: boolean; + description?: string; + error?: string; + defaultValue?: unknown; + format?: string; + maxLength?: number; +}) => { + const isTextArea = format === "textarea" || (maxLength && maxLength > TEXTAREA_THRESHOLD); + return ( + + {isTextArea ? ( +