Add plugin framework and settings UI
This commit is contained in:
@@ -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).
|
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.
|
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
|
## 1. Scope
|
||||||
|
|
||||||
This spec covers:
|
This spec covers:
|
||||||
@@ -212,6 +232,8 @@ Suggested layout:
|
|||||||
|
|
||||||
The package install directory and the plugin data directory are separate.
|
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
|
## 8.2 Operator Commands
|
||||||
|
|
||||||
Paperclip should add CLI commands:
|
Paperclip should add CLI commands:
|
||||||
@@ -237,6 +259,8 @@ The install process is:
|
|||||||
7. Start plugin worker and run health/validation.
|
7. Start plugin worker and run health/validation.
|
||||||
8. Mark plugin `ready` or `error`.
|
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
|
## 9. Load Order And Precedence
|
||||||
|
|
||||||
Load order must be deterministic.
|
Load order must be deterministic.
|
||||||
|
|||||||
177
packages/db/src/migrations/0028_plugin_tables.sql
Normal file
177
packages/db/src/migrations/0028_plugin_tables.sql
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
-- Rollback:
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugins_status_idx";
|
||||||
|
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_logs";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_company_settings";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_job_runs";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_jobs";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_entities";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_state";
|
||||||
|
-- DROP TABLE IF EXISTS "plugin_config";
|
||||||
|
-- DROP TABLE IF EXISTS "plugins";
|
||||||
|
|
||||||
|
CREATE TABLE "plugins" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_key" text NOT NULL,
|
||||||
|
"package_name" text NOT NULL,
|
||||||
|
"package_path" text,
|
||||||
|
"version" text NOT NULL,
|
||||||
|
"api_version" integer DEFAULT 1 NOT NULL,
|
||||||
|
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"manifest_json" jsonb NOT NULL,
|
||||||
|
"status" text DEFAULT 'installed' NOT NULL,
|
||||||
|
"install_order" integer,
|
||||||
|
"last_error" text,
|
||||||
|
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_config" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_state" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"scope_kind" text NOT NULL,
|
||||||
|
"scope_id" text,
|
||||||
|
"namespace" text DEFAULT 'default' NOT NULL,
|
||||||
|
"state_key" text NOT NULL,
|
||||||
|
"value_json" jsonb NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_entities" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"scope_kind" text NOT NULL,
|
||||||
|
"scope_id" text,
|
||||||
|
"external_id" text,
|
||||||
|
"title" text,
|
||||||
|
"status" text,
|
||||||
|
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_jobs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"job_key" text NOT NULL,
|
||||||
|
"schedule" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"last_run_at" timestamp with time zone,
|
||||||
|
"next_run_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_job_runs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"job_id" uuid NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"trigger" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"error" text,
|
||||||
|
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_webhook_deliveries" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"webhook_key" text NOT NULL,
|
||||||
|
"external_id" text,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"duration_ms" integer,
|
||||||
|
"error" text,
|
||||||
|
"payload" jsonb NOT NULL,
|
||||||
|
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"finished_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_company_settings" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"last_error" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "plugin_logs" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"level" text NOT NULL DEFAULT 'info',
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"meta" jsonb,
|
||||||
|
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
|
||||||
@@ -197,6 +197,13 @@
|
|||||||
"when": 1773150731736,
|
"when": 1773150731736,
|
||||||
"tag": "0027_tranquil_tenebrous",
|
"tag": "0027_tranquil_tenebrous",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773417600000,
|
||||||
|
"tag": "0028_plugin_tables",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -32,3 +32,11 @@ export { approvalComments } from "./approval_comments.js";
|
|||||||
export { activityLog } from "./activity_log.js";
|
export { activityLog } from "./activity_log.js";
|
||||||
export { companySecrets } from "./company_secrets.js";
|
export { companySecrets } from "./company_secrets.js";
|
||||||
export { companySecretVersions } from "./company_secret_versions.js";
|
export { companySecretVersions } from "./company_secret_versions.js";
|
||||||
|
export { plugins } from "./plugins.js";
|
||||||
|
export { pluginConfig } from "./plugin_config.js";
|
||||||
|
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||||
|
export { pluginState } from "./plugin_state.js";
|
||||||
|
export { pluginEntities } from "./plugin_entities.js";
|
||||||
|
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||||
|
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||||
|
export { pluginLogs } from "./plugin_logs.js";
|
||||||
|
|||||||
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||||
|
* scoped to a specific company.
|
||||||
|
*
|
||||||
|
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||||
|
* configuration. Each company can have at most one settings row per plugin.
|
||||||
|
*
|
||||||
|
* Rows represent explicit overrides from the default company behavior:
|
||||||
|
* - no row => plugin is enabled for the company by default
|
||||||
|
* - row with `enabled = false` => plugin is disabled for that company
|
||||||
|
* - row with `enabled = true` => plugin remains enabled and stores company settings
|
||||||
|
*/
|
||||||
|
export const pluginCompanySettings = pgTable(
|
||||||
|
"plugin_company_settings",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: "cascade" }),
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
|
||||||
|
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
|
||||||
|
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.pluginId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
30
packages/db/src/schema/plugin_config.ts
Normal file
30
packages/db/src/schema/plugin_config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_config` table — stores operator-provided instance configuration
|
||||||
|
* for each plugin (one row per plugin, enforced by a unique index on
|
||||||
|
* `plugin_id`).
|
||||||
|
*
|
||||||
|
* The `config_json` column holds the values that the operator enters in the
|
||||||
|
* plugin settings UI. These values are validated at runtime against the
|
||||||
|
* plugin's `instanceConfigSchema` from the manifest.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3
|
||||||
|
*/
|
||||||
|
export const pluginConfig = pgTable(
|
||||||
|
"plugin_config",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
54
packages/db/src/schema/plugin_entities.ts
Normal file
54
packages/db/src/schema/plugin_entities.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_entities` table — persistent high-level mapping between Paperclip
|
||||||
|
* objects and external plugin-defined entities.
|
||||||
|
*
|
||||||
|
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
|
||||||
|
* to their respective external IDs for projects, issues, etc. and to store
|
||||||
|
* their custom data.
|
||||||
|
*
|
||||||
|
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
|
||||||
|
* is intended for structured object mappings that the host can understand
|
||||||
|
* and query for cross-plugin UI integration.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3
|
||||||
|
*/
|
||||||
|
export const pluginEntities = pgTable(
|
||||||
|
"plugin_entities",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||||
|
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||||
|
externalId: text("external_id"), // ID in the external system
|
||||||
|
title: text("title"),
|
||||||
|
status: text("status"),
|
||||||
|
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||||
|
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||||
|
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||||
|
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.entityType,
|
||||||
|
table.externalId,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
102
packages/db/src/schema/plugin_jobs.ts
Normal file
102
packages/db/src/schema/plugin_jobs.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_jobs` table — registration and runtime configuration for
|
||||||
|
* scheduled jobs declared by plugins in their manifests.
|
||||||
|
*
|
||||||
|
* Each row represents one scheduled job entry for a plugin. The
|
||||||
|
* `job_key` matches the key declared in the manifest's `jobs` array.
|
||||||
|
* The `schedule` column stores the cron expression or interval string
|
||||||
|
* used by the job scheduler to decide when to fire the job.
|
||||||
|
*
|
||||||
|
* Status values:
|
||||||
|
* - `active` — job is enabled and will run on schedule
|
||||||
|
* - `paused` — job is temporarily disabled by the operator
|
||||||
|
* - `error` — job has been disabled due to repeated failures
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
|
||||||
|
*/
|
||||||
|
export const pluginJobs = pgTable(
|
||||||
|
"plugin_jobs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
/** FK to the owning plugin. Cascades on delete. */
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** Identifier matching the key in the plugin manifest's `jobs` array. */
|
||||||
|
jobKey: text("job_key").notNull(),
|
||||||
|
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
|
||||||
|
schedule: text("schedule").notNull(),
|
||||||
|
/** Current scheduling state. */
|
||||||
|
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
|
||||||
|
/** Timestamp of the most recent successful execution. */
|
||||||
|
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
|
||||||
|
/** Pre-computed timestamp of the next scheduled execution. */
|
||||||
|
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
|
||||||
|
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
|
||||||
|
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
|
||||||
|
*
|
||||||
|
* Each row is created when a job run begins and updated when it completes.
|
||||||
|
* Rows are never modified after `status` reaches a terminal value
|
||||||
|
* (`succeeded` | `failed` | `cancelled`).
|
||||||
|
*
|
||||||
|
* Trigger values:
|
||||||
|
* - `scheduled` — fired automatically by the cron/interval scheduler
|
||||||
|
* - `manual` — triggered by an operator via the admin UI or API
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
|
||||||
|
*/
|
||||||
|
export const pluginJobRuns = pgTable(
|
||||||
|
"plugin_job_runs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
/** FK to the parent job definition. Cascades on delete. */
|
||||||
|
jobId: uuid("job_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => pluginJobs.id, { onDelete: "cascade" }),
|
||||||
|
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||||
|
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||||
|
/** Current lifecycle state of this run. */
|
||||||
|
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||||
|
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
/** Error message if `status === "failed"`. */
|
||||||
|
error: text("error"),
|
||||||
|
/** Ordered list of log lines emitted during this run. */
|
||||||
|
logs: jsonb("logs").$type<string[]>().notNull().default([]),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||||
|
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||||
|
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
43
packages/db/src/schema/plugin_logs.ts
Normal file
43
packages/db/src/schema/plugin_logs.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_logs` table — structured log storage for plugin workers.
|
||||||
|
*
|
||||||
|
* Each row stores a single log entry emitted by a plugin worker via
|
||||||
|
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
|
||||||
|
* time range to support the operator logs panel and debugging workflows.
|
||||||
|
*
|
||||||
|
* Rows are inserted by the host when handling `log` notifications from
|
||||||
|
* the worker process. A capped retention policy can be applied via
|
||||||
|
* periodic cleanup (e.g. delete rows older than 7 days).
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §26 — Observability
|
||||||
|
*/
|
||||||
|
export const pluginLogs = pgTable(
|
||||||
|
"plugin_logs",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
level: text("level").notNull().default("info"),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||||
|
}),
|
||||||
|
);
|
||||||
90
packages/db/src/schema/plugin_state.ts
Normal file
90
packages/db/src/schema/plugin_state.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
unique,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_state` table — scoped key-value storage for plugin workers.
|
||||||
|
*
|
||||||
|
* Each row stores a single JSON value identified by
|
||||||
|
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
|
||||||
|
* this table through `ctx.state.get()`, `ctx.state.set()`, and
|
||||||
|
* `ctx.state.delete()` in the SDK.
|
||||||
|
*
|
||||||
|
* Scope kinds determine the granularity of isolation:
|
||||||
|
* - `instance` — one value shared across the whole Paperclip instance
|
||||||
|
* - `company` — one value per company
|
||||||
|
* - `project` — one value per project
|
||||||
|
* - `project_workspace` — one value per project workspace
|
||||||
|
* - `agent` — one value per agent
|
||||||
|
* - `issue` — one value per issue
|
||||||
|
* - `goal` — one value per goal
|
||||||
|
* - `run` — one value per agent run
|
||||||
|
*
|
||||||
|
* The `namespace` column defaults to `"default"` and can be used to
|
||||||
|
* logically group keys without polluting the root namespace.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||||
|
*/
|
||||||
|
export const pluginState = pgTable(
|
||||||
|
"plugin_state",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
/** FK to the owning plugin. Cascades on delete. */
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
|
||||||
|
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||||
|
/**
|
||||||
|
* UUID or text identifier for the scoped object.
|
||||||
|
* Null for `instance` scope (which has no associated entity).
|
||||||
|
*/
|
||||||
|
scopeId: text("scope_id"),
|
||||||
|
/**
|
||||||
|
* Sub-namespace to avoid key collisions within a scope.
|
||||||
|
* Defaults to `"default"` if the plugin does not specify one.
|
||||||
|
*/
|
||||||
|
namespace: text("namespace").notNull().default("default"),
|
||||||
|
/** The key identifying this state entry within the namespace. */
|
||||||
|
stateKey: text("state_key").notNull(),
|
||||||
|
/** JSON-serializable value stored by the plugin. */
|
||||||
|
valueJson: jsonb("value_json").notNull(),
|
||||||
|
/** Timestamp of the most recent write. */
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
/**
|
||||||
|
* Unique constraint enforces that there is at most one value per
|
||||||
|
* (plugin, scope kind, scope id, namespace, key) tuple.
|
||||||
|
*
|
||||||
|
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
|
||||||
|
* (used by `instance` scope) are treated as equal by PostgreSQL rather
|
||||||
|
* than as distinct nulls — otherwise the upsert target in `set()` would
|
||||||
|
* fail to match existing rows and create duplicates.
|
||||||
|
*
|
||||||
|
* Requires PostgreSQL 15+.
|
||||||
|
*/
|
||||||
|
uniqueEntry: unique("plugin_state_unique_entry_idx")
|
||||||
|
.on(
|
||||||
|
table.pluginId,
|
||||||
|
table.scopeKind,
|
||||||
|
table.scopeId,
|
||||||
|
table.namespace,
|
||||||
|
table.stateKey,
|
||||||
|
)
|
||||||
|
.nullsNotDistinct(),
|
||||||
|
/** Speed up lookups by plugin + scope kind (most common access pattern). */
|
||||||
|
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
|
||||||
|
table.pluginId,
|
||||||
|
table.scopeKind,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
|
||||||
|
*
|
||||||
|
* When an external system sends an HTTP POST to a plugin's registered webhook
|
||||||
|
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
|
||||||
|
* creates a row in this table before dispatching the payload to the plugin
|
||||||
|
* worker. This provides an auditable log of every delivery attempt.
|
||||||
|
*
|
||||||
|
* The `webhook_key` matches the key declared in the plugin manifest's
|
||||||
|
* `webhooks` array. `external_id` is an optional identifier supplied by the
|
||||||
|
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
|
||||||
|
* and reject duplicate deliveries.
|
||||||
|
*
|
||||||
|
* Status values:
|
||||||
|
* - `pending` — received but not yet dispatched to the worker
|
||||||
|
* - `processing` — currently being handled by the plugin worker
|
||||||
|
* - `succeeded` — worker processed the payload successfully
|
||||||
|
* - `failed` — worker returned an error or timed out
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
|
||||||
|
*/
|
||||||
|
export const pluginWebhookDeliveries = pgTable(
|
||||||
|
"plugin_webhook_deliveries",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
/** FK to the owning plugin. Cascades on delete. */
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||||
|
webhookKey: text("webhook_key").notNull(),
|
||||||
|
/** Optional de-duplication ID provided by the external system. */
|
||||||
|
externalId: text("external_id"),
|
||||||
|
/** Current delivery state. */
|
||||||
|
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
|
||||||
|
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
/** Error message if `status === "failed"`. */
|
||||||
|
error: text("error"),
|
||||||
|
/** Raw JSON body of the inbound HTTP request. */
|
||||||
|
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||||
|
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
|
||||||
|
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
|
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||||
|
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||||
|
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||||
|
}),
|
||||||
|
);
|
||||||
45
packages/db/src/schema/plugins.ts
Normal file
45
packages/db/src/schema/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugins` table — stores one row per installed plugin.
|
||||||
|
*
|
||||||
|
* Each plugin is uniquely identified by `plugin_key` (derived from
|
||||||
|
* the manifest `id`). The full manifest is persisted as JSONB in
|
||||||
|
* `manifest_json` so the host can reconstruct capability and UI
|
||||||
|
* slot information without loading the plugin package.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3
|
||||||
|
*/
|
||||||
|
export const plugins = pgTable(
|
||||||
|
"plugins",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
pluginKey: text("plugin_key").notNull(),
|
||||||
|
packageName: text("package_name").notNull(),
|
||||||
|
version: text("version").notNull(),
|
||||||
|
apiVersion: integer("api_version").notNull().default(1),
|
||||||
|
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
|
||||||
|
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
|
||||||
|
status: text("status").$type<PluginStatus>().notNull().default("installed"),
|
||||||
|
installOrder: integer("install_order"),
|
||||||
|
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||||
|
packagePath: text("package_path"),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
|
||||||
|
statusIdx: index("plugins_status_idx").on(table.status),
|
||||||
|
}),
|
||||||
|
);
|
||||||
38
packages/plugins/create-paperclip-plugin/README.md
Normal file
38
packages/plugins/create-paperclip-plugin/README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/create-paperclip-plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"create-paperclip-plugin": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"bin": {
|
||||||
|
"create-paperclip-plugin": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/plugin-sdk": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
398
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
398
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
@@ -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<HealthData>("health");
|
||||||
|
const ping = usePluginAction("ping");
|
||||||
|
|
||||||
|
if (loading) return <div>Loading plugin health...</div>;
|
||||||
|
if (error) return <StatusBadge label={error.message} status="error" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||||
|
<MetricCard label="Health" value={data?.status ?? "unknown"} />
|
||||||
|
<button onClick={() => void ping()}>Ping Worker</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFile(
|
||||||
|
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||||
|
`import { describe, expect, it } from "vitest";
|
||||||
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||||
|
import manifest from "../src/manifest.js";
|
||||||
|
import plugin from "../src/worker.js";
|
||||||
|
|
||||||
|
describe("plugin scaffold", () => {
|
||||||
|
it("registers data + actions and handles events", async () => {
|
||||||
|
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||||
|
await plugin.definition.setup(harness.ctx);
|
||||||
|
|
||||||
|
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||||
|
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||||
|
|
||||||
|
const data = await harness.getData<{ status: string }>("health");
|
||||||
|
expect(data.status).toBe("ok");
|
||||||
|
|
||||||
|
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||||
|
expect(action.pong).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFile(
|
||||||
|
path.join(outputDir, "README.md"),
|
||||||
|
`# ${displayName}
|
||||||
|
|
||||||
|
${description}
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # watch builds
|
||||||
|
pnpm dev:ui # local dev server with hot-reload events
|
||||||
|
pnpm test
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 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 <name> [--template default|connector|workspace] [--output <dir>]");
|
||||||
|
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();
|
||||||
|
}
|
||||||
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# File Browser Example Plugin
|
||||||
|
|
||||||
|
Example Paperclip plugin that demonstrates:
|
||||||
|
|
||||||
|
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
|
||||||
|
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
|
||||||
|
|
||||||
|
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
|
||||||
|
|
||||||
|
## Slots
|
||||||
|
|
||||||
|
| Slot | Type | Description |
|
||||||
|
|---------------------|---------------------|--------------------------------------------------|
|
||||||
|
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
|
||||||
|
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
|
||||||
|
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
- `ui.sidebar.register` — project sidebar item
|
||||||
|
- `ui.detailTab.register` — project detail tab
|
||||||
|
- `projects.read` — resolve project
|
||||||
|
- `project.workspaces.read` — list workspaces and read paths for file access
|
||||||
|
|
||||||
|
## Worker
|
||||||
|
|
||||||
|
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
|
||||||
|
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
|
||||||
|
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
|
||||||
|
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
|
||||||
|
|
||||||
|
## Local Install (Dev)
|
||||||
|
|
||||||
|
From the repo root, build the plugin and install it by local path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||||
|
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||||
|
```
|
||||||
|
|
||||||
|
To uninstall:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local development notes:**
|
||||||
|
|
||||||
|
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||||
|
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
|
||||||
|
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
|
||||||
|
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
|
||||||
|
- `src/worker.ts` — data handlers for workspaces, file list, file content.
|
||||||
|
- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/plugin-file-browser-example",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"paperclipPlugin": {
|
||||||
|
"manifest": "./dist/manifest.js",
|
||||||
|
"worker": "./dist/worker.js",
|
||||||
|
"ui": "./dist/ui/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||||
|
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
|
"@codemirror/language": "^6.11.0",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/view": "^6.28.0",
|
||||||
|
"@lezer/highlight": "^1.2.1",
|
||||||
|
"@paperclipai/plugin-sdk": "workspace:*",
|
||||||
|
"codemirror": "^6.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"esbuild": "^0.27.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import esbuild from "esbuild";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const packageRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||||
|
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: ["es2022"],
|
||||||
|
sourcemap: true,
|
||||||
|
external: [
|
||||||
|
"react",
|
||||||
|
"react-dom",
|
||||||
|
"react/jsx-runtime",
|
||||||
|
"@paperclipai/plugin-sdk/ui",
|
||||||
|
],
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as manifest } from "./manifest.js";
|
||||||
|
export { default as worker } from "./worker.js";
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
const PLUGIN_ID = "paperclip-file-browser-example";
|
||||||
|
const FILES_SIDEBAR_SLOT_ID = "files-link";
|
||||||
|
const FILES_TAB_SLOT_ID = "files-tab";
|
||||||
|
const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
|
||||||
|
const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
|
||||||
|
|
||||||
|
const manifest: PaperclipPluginManifestV1 = {
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
apiVersion: 1,
|
||||||
|
version: "0.2.0",
|
||||||
|
displayName: "File Browser (Example)",
|
||||||
|
description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["workspace", "ui"],
|
||||||
|
capabilities: [
|
||||||
|
"ui.sidebar.register",
|
||||||
|
"ui.detailTab.register",
|
||||||
|
"ui.commentAnnotation.register",
|
||||||
|
"ui.action.register",
|
||||||
|
"projects.read",
|
||||||
|
"project.workspaces.read",
|
||||||
|
"issue.comments.read",
|
||||||
|
"plugin.state.read",
|
||||||
|
],
|
||||||
|
instanceConfigSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
showFilesInSidebar: {
|
||||||
|
type: "boolean",
|
||||||
|
title: "Show Files in Sidebar",
|
||||||
|
default: false,
|
||||||
|
description: "Adds the Files link under each project in the sidebar.",
|
||||||
|
},
|
||||||
|
commentAnnotationMode: {
|
||||||
|
type: "string",
|
||||||
|
title: "Comment File Links",
|
||||||
|
enum: ["annotation", "contextMenu", "both", "none"],
|
||||||
|
default: "both",
|
||||||
|
description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entrypoints: {
|
||||||
|
worker: "./dist/worker.js",
|
||||||
|
ui: "./dist/ui",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "projectSidebarItem",
|
||||||
|
id: FILES_SIDEBAR_SLOT_ID,
|
||||||
|
displayName: "Files",
|
||||||
|
exportName: "FilesLink",
|
||||||
|
entityTypes: ["project"],
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "detailTab",
|
||||||
|
id: FILES_TAB_SLOT_ID,
|
||||||
|
displayName: "Files",
|
||||||
|
exportName: "FilesTab",
|
||||||
|
entityTypes: ["project"],
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "commentAnnotation",
|
||||||
|
id: COMMENT_FILE_LINKS_SLOT_ID,
|
||||||
|
displayName: "File Links",
|
||||||
|
exportName: "CommentFileLinks",
|
||||||
|
entityTypes: ["comment"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "commentContextMenuItem",
|
||||||
|
id: COMMENT_OPEN_FILES_SLOT_ID,
|
||||||
|
displayName: "Open in Files",
|
||||||
|
exportName: "CommentOpenFiles",
|
||||||
|
entityTypes: ["comment"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
import type {
|
||||||
|
PluginProjectSidebarItemProps,
|
||||||
|
PluginDetailTabProps,
|
||||||
|
PluginCommentAnnotationProps,
|
||||||
|
PluginCommentContextMenuItemProps,
|
||||||
|
} from "@paperclipai/plugin-sdk/ui";
|
||||||
|
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { basicSetup } from "codemirror";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
|
||||||
|
const PLUGIN_KEY = "paperclip-file-browser-example";
|
||||||
|
const FILES_TAB_SLOT_ID = "files-tab";
|
||||||
|
|
||||||
|
const editorBaseTheme = {
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
padding: "12px 14px 18px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorDarkTheme = EditorView.theme({
|
||||||
|
...editorBaseTheme,
|
||||||
|
"&": {
|
||||||
|
...editorBaseTheme["&"],
|
||||||
|
backgroundColor: "oklch(0.23 0.02 255)",
|
||||||
|
color: "oklch(0.93 0.01 255)",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "oklch(0.25 0.015 255)",
|
||||||
|
color: "oklch(0.74 0.015 255)",
|
||||||
|
borderRight: "1px solid oklch(0.34 0.01 255)",
|
||||||
|
},
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": {
|
||||||
|
backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, .cm-content ::selection": {
|
||||||
|
backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
|
||||||
|
},
|
||||||
|
"&.cm-focused .cm-selectionBackground": {
|
||||||
|
backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
|
||||||
|
},
|
||||||
|
".cm-cursor, .cm-dropCursor": {
|
||||||
|
borderLeftColor: "oklch(0.93 0.01 255)",
|
||||||
|
},
|
||||||
|
".cm-matchingBracket": {
|
||||||
|
backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
|
||||||
|
color: "oklch(0.95 0.01 255)",
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
".cm-nonmatchingBracket": {
|
||||||
|
color: "oklch(0.70 0.08 24)",
|
||||||
|
},
|
||||||
|
}, { dark: true });
|
||||||
|
|
||||||
|
const editorLightTheme = EditorView.theme({
|
||||||
|
...editorBaseTheme,
|
||||||
|
"&": {
|
||||||
|
...editorBaseTheme["&"],
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
...editorBaseTheme[".cm-content"],
|
||||||
|
caretColor: "var(--foreground)",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
},
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": {
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, .cm-content ::selection": {
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
|
||||||
|
},
|
||||||
|
"&.cm-focused .cm-selectionBackground": {
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
|
||||||
|
},
|
||||||
|
".cm-cursor, .cm-dropCursor": {
|
||||||
|
borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
|
||||||
|
},
|
||||||
|
".cm-matchingBracket": {
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
".cm-nonmatchingBracket": {
|
||||||
|
color: "var(--destructive)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorDarkHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
|
||||||
|
{ tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
|
||||||
|
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
|
||||||
|
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
|
||||||
|
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
|
||||||
|
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
|
||||||
|
{ tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
|
||||||
|
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
|
||||||
|
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const editorLightHighlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
|
||||||
|
{ tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
|
||||||
|
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
|
||||||
|
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
|
||||||
|
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
|
||||||
|
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
|
||||||
|
{ tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
|
||||||
|
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
|
||||||
|
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||||
|
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||||
|
type FileTreeNodeProps = {
|
||||||
|
entry: FileEntry;
|
||||||
|
companyId: string | null;
|
||||||
|
projectId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
selectedPath: string | null;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PathLikePattern = /[\\/]/;
|
||||||
|
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||||
|
const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function isLikelyPath(pathValue: string): boolean {
|
||||||
|
const trimmed = pathValue.trim();
|
||||||
|
return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceLabel(workspace: Workspace): string {
|
||||||
|
const pathLabel = workspace.path.trim();
|
||||||
|
const nameLabel = workspace.name.trim();
|
||||||
|
const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
|
||||||
|
const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
|
||||||
|
const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
|
||||||
|
if (!baseLabel) {
|
||||||
|
return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIsMobile(breakpointPx = 768): boolean {
|
||||||
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
|
||||||
|
const update = () => setIsMobile(mediaQuery.matches);
|
||||||
|
update();
|
||||||
|
mediaQuery.addEventListener("change", update);
|
||||||
|
return () => mediaQuery.removeEventListener("change", update);
|
||||||
|
}, [breakpointPx]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIsDarkMode(): boolean {
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||||
|
typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const update = () => setIsDarkMode(root.classList.contains("dark"));
|
||||||
|
update();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(update);
|
||||||
|
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isDarkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAvailableHeight(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
options?: { bottomPadding?: number; minHeight?: number },
|
||||||
|
): number | null {
|
||||||
|
const bottomPadding = options?.bottomPadding ?? 24;
|
||||||
|
const minHeight = options?.minHeight ?? 384;
|
||||||
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
|
||||||
|
setHeight(nextHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
window.addEventListener("orientationchange", update);
|
||||||
|
|
||||||
|
const observer = typeof ResizeObserver !== "undefined"
|
||||||
|
? new ResizeObserver(() => update())
|
||||||
|
: null;
|
||||||
|
if (observer && ref.current) observer.observe(ref.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", update);
|
||||||
|
window.removeEventListener("orientationchange", update);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}, [bottomPadding, minHeight, ref]);
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileTreeNode({
|
||||||
|
entry,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
workspaceId,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
depth = 0,
|
||||||
|
}: FileTreeNodeProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const isSelected = selectedPath === entry.path;
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||||
|
onClick={() => setIsExpanded((value) => !value)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
|
||||||
|
<span className="truncate font-medium">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ExpandedDirectoryChildren
|
||||||
|
directoryPath={entry.path}
|
||||||
|
companyId={companyId}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={depth}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
|
||||||
|
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 14 + 23}px` }}
|
||||||
|
onClick={() => onSelect(entry.path)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandedDirectoryChildren({
|
||||||
|
directoryPath,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
workspaceId,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
depth,
|
||||||
|
}: {
|
||||||
|
directoryPath: string;
|
||||||
|
companyId: string | null;
|
||||||
|
projectId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
selectedPath: string | null;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
depth: number;
|
||||||
|
}) {
|
||||||
|
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
workspaceId,
|
||||||
|
directoryPath,
|
||||||
|
});
|
||||||
|
const children = childData?.entries ?? [];
|
||||||
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{children.map((child) => (
|
||||||
|
<FileTreeNode
|
||||||
|
key={child.path}
|
||||||
|
entry={child}
|
||||||
|
companyId={companyId}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||||
|
*/
|
||||||
|
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||||
|
const { data: config, loading: configLoading } = usePluginData<PluginConfig>("plugin-config", {});
|
||||||
|
const showFilesInSidebar = config?.showFilesInSidebar ?? false;
|
||||||
|
|
||||||
|
if (configLoading || !showFilesInSidebar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = context.entityId;
|
||||||
|
const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
|
||||||
|
.projectRef
|
||||||
|
?? projectId;
|
||||||
|
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||||
|
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||||
|
const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
|
||||||
|
const isActive = typeof window !== "undefined" && (() => {
|
||||||
|
const pathname = window.location.pathname.replace(/\/+$/, "");
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const projectsIndex = segments.indexOf("projects");
|
||||||
|
const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
|
||||||
|
const activeTab = new URLSearchParams(window.location.search).get("tab");
|
||||||
|
if (activeTab !== tabValue) return false;
|
||||||
|
if (!activeProjectRef) return false;
|
||||||
|
return activeProjectRef === projectId || activeProjectRef === projectRef;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented
|
||||||
|
|| event.button !== 0
|
||||||
|
|| event.metaKey
|
||||||
|
|| event.ctrlKey
|
||||||
|
|| event.altKey
|
||||||
|
|| event.shiftKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
window.history.pushState({}, "", href);
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={`block px-3 py-1 text-[12px] truncate transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project detail tab: workspace selector, file tree, and CodeMirror editor.
|
||||||
|
*/
|
||||||
|
export function FilesTab({ context }: PluginDetailTabProps) {
|
||||||
|
const companyId = context.companyId;
|
||||||
|
const projectId = context.entityId;
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
const panesRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const availableHeight = useAvailableHeight(panesRef, {
|
||||||
|
bottomPadding: isMobile ? 16 : 24,
|
||||||
|
minHeight: isMobile ? 320 : 420,
|
||||||
|
});
|
||||||
|
const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", {
|
||||||
|
projectId,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
|
const workspaces = workspacesData ?? [];
|
||||||
|
const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
|
||||||
|
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||||
|
const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
|
||||||
|
const selectedWorkspace = useMemo(
|
||||||
|
() => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
|
||||||
|
[workspaces, resolvedWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileListParams = useMemo(
|
||||||
|
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||||
|
[companyId, projectId, selectedWorkspace],
|
||||||
|
);
|
||||||
|
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
|
||||||
|
"fileList",
|
||||||
|
fileListParams,
|
||||||
|
);
|
||||||
|
const entries = fileListData?.entries ?? [];
|
||||||
|
|
||||||
|
// Track the `?file=` query parameter across navigations (popstate).
|
||||||
|
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return new URLSearchParams(window.location.search).get("file") || null;
|
||||||
|
});
|
||||||
|
const lastConsumedFileRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onNav = () => {
|
||||||
|
const next = new URLSearchParams(window.location.search).get("file") || null;
|
||||||
|
setUrlFilePath(next);
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onNav);
|
||||||
|
return () => window.removeEventListener("popstate", onNav);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPath(null);
|
||||||
|
setMobileView("browser");
|
||||||
|
lastConsumedFileRef.current = null;
|
||||||
|
}, [selectedWorkspace?.id]);
|
||||||
|
|
||||||
|
// When a file path appears (or changes) in the URL and workspace is ready, select it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlFilePath || !selectedWorkspace) return;
|
||||||
|
if (lastConsumedFileRef.current === urlFilePath) return;
|
||||||
|
lastConsumedFileRef.current = urlFilePath;
|
||||||
|
setSelectedPath(urlFilePath);
|
||||||
|
setMobileView("editor");
|
||||||
|
}, [urlFilePath, selectedWorkspace]);
|
||||||
|
|
||||||
|
const fileContentParams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedPath && selectedWorkspace
|
||||||
|
? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
|
||||||
|
: null,
|
||||||
|
[companyId, projectId, selectedWorkspace, selectedPath],
|
||||||
|
);
|
||||||
|
const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
|
||||||
|
"fileContent",
|
||||||
|
fileContentParams ?? {},
|
||||||
|
);
|
||||||
|
const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
|
||||||
|
const writeFile = usePluginAction("writeFile");
|
||||||
|
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const loadedContentRef = useRef("");
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const content = fileContentData?.content ?? "";
|
||||||
|
loadedContentRef.current = content;
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaveMessage(null);
|
||||||
|
setSaveError(null);
|
||||||
|
if (viewRef.current) {
|
||||||
|
viewRef.current.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
}
|
||||||
|
const view = new EditorView({
|
||||||
|
doc: content,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
javascript(),
|
||||||
|
isDarkMode ? editorDarkTheme : editorLightTheme,
|
||||||
|
syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
const nextValue = update.state.doc.toString();
|
||||||
|
setIsDirty(nextValue !== loadedContentRef.current);
|
||||||
|
setSaveMessage(null);
|
||||||
|
setSaveError(null);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
parent: editorRef.current,
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, [fileContentData?.content, selectedPath, isDarkMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeydown);
|
||||||
|
}, [selectedWorkspace, selectedPath, isDirty, isSaving]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!selectedWorkspace || !selectedPath || !viewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = viewRef.current.state.doc.toString();
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
setSaveMessage(null);
|
||||||
|
try {
|
||||||
|
await writeFile({
|
||||||
|
projectId,
|
||||||
|
companyId,
|
||||||
|
workspaceId: selectedWorkspace.id,
|
||||||
|
filePath: selectedPath,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
loadedContentRef.current = content;
|
||||||
|
setIsDirty(false);
|
||||||
|
setSaveMessage("Saved");
|
||||||
|
refreshFileContent();
|
||||||
|
} catch (error) {
|
||||||
|
setSaveError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-border bg-card p-4">
|
||||||
|
<label className="text-sm font-medium text-muted-foreground">Workspace</label>
|
||||||
|
<select
|
||||||
|
key={workspaceSelectKey}
|
||||||
|
className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
value={resolvedWorkspaceId ?? ""}
|
||||||
|
onChange={(e) => setWorkspaceId(e.target.value || null)}
|
||||||
|
>
|
||||||
|
{workspaces.map((w) => {
|
||||||
|
const label = workspaceLabel(w);
|
||||||
|
return (
|
||||||
|
<option key={`${w.id}:${label}`} value={w.id} label={label} title={label}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={panesRef}
|
||||||
|
className="min-h-0"
|
||||||
|
style={{
|
||||||
|
display: isMobile ? "block" : "grid",
|
||||||
|
gap: "1rem",
|
||||||
|
gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)",
|
||||||
|
height: availableHeight ? `${availableHeight}px` : undefined,
|
||||||
|
minHeight: isMobile ? "20rem" : "26rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||||
|
style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
File Tree
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||||
|
{selectedWorkspace ? (
|
||||||
|
fileListLoading ? (
|
||||||
|
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
|
||||||
|
) : entries.length > 0 ? (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<FileTreeNode
|
||||||
|
key={entry.path}
|
||||||
|
entry={entry}
|
||||||
|
companyId={companyId}
|
||||||
|
projectId={projectId}
|
||||||
|
workspaceId={selectedWorkspace.id}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={(path) => {
|
||||||
|
setSelectedPath(path);
|
||||||
|
setMobileView("editor");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||||
|
style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }}
|
||||||
|
>
|
||||||
|
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground"
|
||||||
|
style={{ display: isMobile ? "inline-flex" : "none" }}
|
||||||
|
onClick={() => setMobileView("browser")}
|
||||||
|
>
|
||||||
|
Back to files
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div>
|
||||||
|
<div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isDirty || saveMessage || saveError ? (
|
||||||
|
<div className="border-b border-border px-4 py-2 text-xs">
|
||||||
|
{saveError ? (
|
||||||
|
<span className="text-destructive">{saveError}</span>
|
||||||
|
) : saveMessage ? (
|
||||||
|
<span className="text-emerald-600">{saveMessage}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Unsaved changes</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
|
||||||
|
<div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div>
|
||||||
|
) : null}
|
||||||
|
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comment Annotation: renders detected file links below each comment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type PluginConfig = {
|
||||||
|
showFilesInSidebar?: boolean;
|
||||||
|
commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-comment annotation showing file-path-like links extracted from the
|
||||||
|
* comment body. Each link navigates to the project Files tab with the
|
||||||
|
* matching path pre-selected.
|
||||||
|
*
|
||||||
|
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||||
|
* is `"contextMenu"` or `"none"`.
|
||||||
|
*/
|
||||||
|
function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
|
||||||
|
if (!projectId) return "#";
|
||||||
|
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||||
|
return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToFileBrowser(href: string, event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
if (
|
||||||
|
event.defaultPrevented
|
||||||
|
|| event.button !== 0
|
||||||
|
|| event.metaKey
|
||||||
|
|| event.ctrlKey
|
||||||
|
|| event.altKey
|
||||||
|
|| event.shiftKey
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
window.history.pushState({}, "", href);
|
||||||
|
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
|
||||||
|
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||||
|
const mode = config?.commentAnnotationMode ?? "both";
|
||||||
|
|
||||||
|
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||||
|
commentId: context.entityId,
|
||||||
|
issueId: context.parentEntityId,
|
||||||
|
companyId: context.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === "contextMenu" || mode === "none") return null;
|
||||||
|
if (!data?.links?.length) return null;
|
||||||
|
|
||||||
|
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||||
|
const projectId = context.projectId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span>
|
||||||
|
{data.links.map((link) => {
|
||||||
|
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={link}
|
||||||
|
href={href}
|
||||||
|
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||||
|
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors"
|
||||||
|
title={`Open ${link} in file browser`}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comment Context Menu Item: "Open in Files" action per comment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-comment context menu item that appears in the comment "more" (⋮) menu.
|
||||||
|
* Extracts file paths from the comment body and, if any are found, renders
|
||||||
|
* a button to open the first file in the project Files tab.
|
||||||
|
*
|
||||||
|
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||||
|
* is `"annotation"` or `"none"`.
|
||||||
|
*/
|
||||||
|
export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
|
||||||
|
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||||
|
const mode = config?.commentAnnotationMode ?? "both";
|
||||||
|
|
||||||
|
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||||
|
commentId: context.entityId,
|
||||||
|
issueId: context.parentEntityId,
|
||||||
|
companyId: context.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === "annotation" || mode === "none") return null;
|
||||||
|
if (!data?.links?.length) return null;
|
||||||
|
|
||||||
|
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||||
|
const projectId = context.projectId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Files
|
||||||
|
</div>
|
||||||
|
{data.links.map((link) => {
|
||||||
|
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||||
|
const fileName = link.split("/").pop() ?? link;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={link}
|
||||||
|
href={href}
|
||||||
|
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors"
|
||||||
|
title={`Open ${link} in file browser`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-mono">{fileName}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
const PLUGIN_NAME = "file-browser-example";
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const PATH_LIKE_PATTERN = /[\\/]/;
|
||||||
|
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||||
|
|
||||||
|
function looksLikePath(value: string): boolean {
|
||||||
|
const normalized = value.trim();
|
||||||
|
return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
|
||||||
|
&& !UUID_PATTERN.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWorkspacePath(pathValue: string): string {
|
||||||
|
return looksLikePath(pathValue) ? pathValue.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
|
||||||
|
const root = path.resolve(workspacePath);
|
||||||
|
const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
|
||||||
|
const relative = path.relative(root, resolved);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex that matches file-path-like tokens in comment text.
|
||||||
|
* Captures tokens that either start with `.` `/` `~` or contain a `/`
|
||||||
|
* (directory separator), plus bare words that could be filenames with
|
||||||
|
* extensions (e.g. `README.md`). The file-extension check in
|
||||||
|
* `extractFilePaths` filters out non-file matches.
|
||||||
|
*/
|
||||||
|
const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
|
||||||
|
|
||||||
|
/** Common file extensions to recognise path-like tokens as actual file references. */
|
||||||
|
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that look like paths but are almost certainly URL route segments
|
||||||
|
* (e.g. `/projects/abc`, `/settings`, `/dashboard`).
|
||||||
|
*/
|
||||||
|
const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
|
||||||
|
|
||||||
|
function extractFilePaths(body: string): string[] {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const match of body.matchAll(FILE_PATH_REGEX)) {
|
||||||
|
const raw = match[1];
|
||||||
|
// Strip trailing punctuation that isn't part of a path
|
||||||
|
const cleaned = raw.replace(/[.:,;!?)]+$/, "");
|
||||||
|
if (cleaned.length <= 1) continue;
|
||||||
|
// Must have a file extension (e.g. .ts, .json, .md)
|
||||||
|
if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
|
||||||
|
// Skip things that look like URL routes
|
||||||
|
if (URL_ROUTE_PATTERN.test(cleaned)) continue;
|
||||||
|
paths.add(cleaned);
|
||||||
|
}
|
||||||
|
return [...paths];
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = definePlugin({
|
||||||
|
async setup(ctx) {
|
||||||
|
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||||
|
|
||||||
|
// Expose the current plugin config so UI components can read 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<string, unknown> | 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<string, unknown>) => {
|
||||||
|
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
||||||
|
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
||||||
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
|
if (!commentId || !issueId || !companyId) return { links: [] };
|
||||||
|
try {
|
||||||
|
const comments = await ctx.issues.listComments(issueId, companyId);
|
||||||
|
const comment = comments.find((c) => c.id === commentId);
|
||||||
|
if (!comment?.body) return { links: [] };
|
||||||
|
return { links: extractFilePaths(comment.body) };
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
|
||||||
|
return { links: [] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.data.register("workspaces", async (params: Record<string, unknown>) => {
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
|
if (!projectId || !companyId) return [];
|
||||||
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||||
|
return workspaces.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
projectId: w.projectId,
|
||||||
|
name: w.name,
|
||||||
|
path: sanitizeWorkspacePath(w.path),
|
||||||
|
isPrimary: w.isPrimary,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.data.register(
|
||||||
|
"fileList",
|
||||||
|
async (params: Record<string, unknown>) => {
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
|
const workspaceId = params.workspaceId as string;
|
||||||
|
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||||
|
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||||
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||||
|
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||||
|
if (!workspace) return { entries: [] };
|
||||||
|
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||||
|
if (!workspacePath) return { entries: [] };
|
||||||
|
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||||
|
if (!dirPath) {
|
||||||
|
return { entries: [] };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
return { entries: [] };
|
||||||
|
}
|
||||||
|
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||||
|
const entries = names.map((name) => {
|
||||||
|
const full = path.join(dirPath, name);
|
||||||
|
const stat = fs.lstatSync(full);
|
||||||
|
const relativePath = path.relative(workspacePath, full);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: relativePath,
|
||||||
|
isDirectory: stat.isDirectory(),
|
||||||
|
};
|
||||||
|
}).sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return { entries };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.data.register(
|
||||||
|
"fileContent",
|
||||||
|
async (params: Record<string, unknown>) => {
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
|
const workspaceId = params.workspaceId as string;
|
||||||
|
const filePath = params.filePath as string;
|
||||||
|
if (!projectId || !companyId || !workspaceId || !filePath) {
|
||||||
|
return { content: null, error: "Missing file context" };
|
||||||
|
}
|
||||||
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||||
|
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||||
|
if (!workspace) return { content: null, error: "Workspace not found" };
|
||||||
|
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||||
|
if (!workspacePath) return { content: null, error: "Workspace has no path" };
|
||||||
|
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||||
|
if (!fullPath) {
|
||||||
|
return { content: null, error: "Path outside workspace" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(fullPath, "utf-8");
|
||||||
|
return { content };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { content: null, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.actions.register(
|
||||||
|
"writeFile",
|
||||||
|
async (params: Record<string, unknown>) => {
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
|
const workspaceId = params.workspaceId as string;
|
||||||
|
const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
|
||||||
|
if (!filePath) {
|
||||||
|
throw new Error("filePath must be a non-empty string");
|
||||||
|
}
|
||||||
|
const content = typeof params.content === "string" ? params.content : null;
|
||||||
|
if (!projectId || !companyId || !workspaceId) {
|
||||||
|
throw new Error("Missing workspace context");
|
||||||
|
}
|
||||||
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||||
|
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error("Workspace not found");
|
||||||
|
}
|
||||||
|
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||||
|
if (!workspacePath) {
|
||||||
|
throw new Error("Workspace has no path");
|
||||||
|
}
|
||||||
|
if (content === null) {
|
||||||
|
throw new Error("Missing file content");
|
||||||
|
}
|
||||||
|
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||||
|
if (!fullPath) {
|
||||||
|
throw new Error("Path outside workspace");
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new Error("Selected path is not a file");
|
||||||
|
}
|
||||||
|
fs.writeFileSync(fullPath, content, "utf-8");
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: filePath,
|
||||||
|
bytes: Buffer.byteLength(content, "utf-8"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onHealth() {
|
||||||
|
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
runWorker(plugin, import.meta.url);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# @paperclipai/plugin-hello-world-example
|
||||||
|
|
||||||
|
First-party reference plugin showing the smallest possible UI extension.
|
||||||
|
|
||||||
|
## What It Demonstrates
|
||||||
|
|
||||||
|
- a manifest with a `dashboardWidget` UI slot
|
||||||
|
- `entrypoints.ui` wiring for plugin UI bundles
|
||||||
|
- a minimal React widget rendered in the Paperclip dashboard
|
||||||
|
- reading host context (`companyId`) from `PluginWidgetProps`
|
||||||
|
- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
|
||||||
|
|
||||||
|
## API Surface
|
||||||
|
|
||||||
|
- This example does not add custom HTTP endpoints.
|
||||||
|
- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
|
||||||
|
It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
|
||||||
|
|
||||||
|
## Local Install (Dev)
|
||||||
|
|
||||||
|
From the repo root, build the plugin and install it by local path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @paperclipai/plugin-hello-world-example build
|
||||||
|
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local development notes:**
|
||||||
|
|
||||||
|
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||||
|
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
|
||||||
|
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
|
||||||
|
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||||
|
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/plugin-hello-world-example",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "First-party reference plugin that adds a Hello World dashboard widget",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"paperclipPlugin": {
|
||||||
|
"manifest": "./dist/manifest.js",
|
||||||
|
"worker": "./dist/worker.js",
|
||||||
|
"ui": "./dist/ui/"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/plugin-sdk": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as manifest } from "./manifest.js";
|
||||||
|
export { default as worker } from "./worker.js";
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable plugin ID used by host registration and namespacing.
|
||||||
|
*/
|
||||||
|
const PLUGIN_ID = "paperclip.hello-world-example";
|
||||||
|
const PLUGIN_VERSION = "0.1.0";
|
||||||
|
const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
|
||||||
|
const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
|
||||||
|
*/
|
||||||
|
const manifest: PaperclipPluginManifestV1 = {
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
apiVersion: 1,
|
||||||
|
version: PLUGIN_VERSION,
|
||||||
|
displayName: "Hello World Widget (Example)",
|
||||||
|
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
|
||||||
|
author: "Paperclip",
|
||||||
|
categories: ["ui"],
|
||||||
|
capabilities: ["ui.dashboardWidget.register"],
|
||||||
|
entrypoints: {
|
||||||
|
worker: "./dist/worker.js",
|
||||||
|
ui: "./dist/ui",
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "dashboardWidget",
|
||||||
|
id: DASHBOARD_WIDGET_SLOT_ID,
|
||||||
|
displayName: "Hello World",
|
||||||
|
exportName: DASHBOARD_WIDGET_EXPORT_NAME,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
const WIDGET_LABEL = "Hello world plugin widget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example dashboard widget showing the smallest possible UI contribution.
|
||||||
|
*/
|
||||||
|
export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
|
||||||
|
return (
|
||||||
|
<section aria-label={WIDGET_LABEL}>
|
||||||
|
<strong>Hello world</strong>
|
||||||
|
<div>This widget was added by @paperclipai/plugin-hello-world-example.</div>
|
||||||
|
{/* Include host context so authors can see where scoped IDs come from. */}
|
||||||
|
<div>Company context: {context.companyId}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
const PLUGIN_NAME = "hello-world-example";
|
||||||
|
const HEALTH_MESSAGE = "Hello World example plugin ready";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker lifecycle hooks for the Hello World reference plugin.
|
||||||
|
* This stays intentionally small so new authors can copy the shape quickly.
|
||||||
|
*/
|
||||||
|
const plugin = definePlugin({
|
||||||
|
/**
|
||||||
|
* Called when the host starts the plugin worker.
|
||||||
|
*/
|
||||||
|
async setup(ctx) {
|
||||||
|
ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the host health probe endpoint.
|
||||||
|
*/
|
||||||
|
async onHealth() {
|
||||||
|
return { status: "ok", message: HEALTH_MESSAGE };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
runWorker(plugin, import.meta.url);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
959
packages/plugins/sdk/README.md
Normal file
959
packages/plugins/sdk/README.md
Normal file
@@ -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.<pluginId>.<eventName>` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically.
|
||||||
|
|
||||||
|
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
|
||||||
|
|
||||||
|
**Company-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:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
|
||||||
|
|
||||||
|
#### `toolbarButton`
|
||||||
|
|
||||||
|
A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||||
|
|
||||||
|
#### `contextMenuItem`
|
||||||
|
|
||||||
|
An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||||
|
|
||||||
|
#### `commentAnnotation`
|
||||||
|
|
||||||
|
A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.
|
||||||
|
|
||||||
|
#### `commentContextMenuItem`
|
||||||
|
|
||||||
|
A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.
|
||||||
|
|
||||||
|
### Launcher actions and render options
|
||||||
|
|
||||||
|
| Launcher action | Description |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| `navigate` | Navigate to a route (plugin or host). |
|
||||||
|
| `openModal` | Open a modal. |
|
||||||
|
| `openDrawer` | Open a drawer. |
|
||||||
|
| `openPopover` | Open a popover. |
|
||||||
|
| `performAction` | Run an action (e.g. call plugin). |
|
||||||
|
| `deepLink` | Deep link to plugin or external URL. |
|
||||||
|
|
||||||
|
| Render option | Values | Description |
|
||||||
|
|---------------|--------|-------------|
|
||||||
|
| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
|
||||||
|
| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
Declare in `manifest.capabilities`. Grouped by scope:
|
||||||
|
|
||||||
|
| Scope | Capability |
|
||||||
|
|-------|------------|
|
||||||
|
| **Company** | `companies.read` |
|
||||||
|
| | `projects.read` |
|
||||||
|
| | `project.workspaces.read` |
|
||||||
|
| | `issues.read` |
|
||||||
|
| | `issue.comments.read` |
|
||||||
|
| | `agents.read` |
|
||||||
|
| | `goals.read` |
|
||||||
|
| | `goals.create` |
|
||||||
|
| | `goals.update` |
|
||||||
|
| | `activity.read` |
|
||||||
|
| | `costs.read` |
|
||||||
|
| | `issues.create` |
|
||||||
|
| | `issues.update` |
|
||||||
|
| | `issue.comments.create` |
|
||||||
|
| | `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 (
|
||||||
|
<div>
|
||||||
|
<MetricCard label="Health" value={data?.status ?? "unknown"} />
|
||||||
|
<button onClick={() => void ping()}>Ping</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hooks reference
|
||||||
|
|
||||||
|
#### `usePluginData<T>(key, params?)`
|
||||||
|
|
||||||
|
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePluginData, 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<SyncStatus>("sync-status", {
|
||||||
|
companyId: context.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) return <Spinner />;
|
||||||
|
if (error) return <StatusBadge label={error.message} status="error" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StatusBadge label={data!.healthy ? "Healthy" : "Unhealthy"} status={data!.healthy ? "ok" : "error"} />
|
||||||
|
<p>Synced {data!.syncedCount} items</p>
|
||||||
|
<p>Last sync: {data!.lastSyncAt}</p>
|
||||||
|
<button onClick={refresh}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `usePluginAction(key)`
|
||||||
|
|
||||||
|
Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function ResyncButton({ context }: PluginWidgetProps) {
|
||||||
|
const resync = usePluginAction("resync");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await resync({ companyId: context.companyId });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as PluginBridgeError).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={handleClick} disabled={busy}>
|
||||||
|
{busy ? "Syncing..." : "Resync Now"}
|
||||||
|
</button>
|
||||||
|
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `useHostContext()`
|
||||||
|
|
||||||
|
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||||||
|
const { companyId, entityId, entityType } = context;
|
||||||
|
const { data } = usePluginData<{ url: string }>("linear-link", {
|
||||||
|
companyId,
|
||||||
|
issueId: entityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.url) return <p>No linked Linear issue.</p>;
|
||||||
|
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `usePluginStream<T>(channel, options?)`
|
||||||
|
|
||||||
|
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
interface ChatToken {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessages({ context }: PluginWidgetProps) {
|
||||||
|
const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
|
||||||
|
companyId: context.companyId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||||
|
{connected && <span className="pulse" />}
|
||||||
|
<button onClick={close}>Stop</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<MetricCard label="Issues Synced" value={142} unit="issues" trend={{ direction: "up", percentage: 12 }} />
|
||||||
|
<MetricCard label="API Latency" value="45ms" sparkline={[52, 48, 45, 47, 45]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `StatusBadge`
|
||||||
|
|
||||||
|
Inline status indicator with semantic color.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StatusBadge label="Connected" status="ok" />
|
||||||
|
<StatusBadge label="Rate Limited" status="warning" />
|
||||||
|
<StatusBadge label="Auth Failed" status="error" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DataTable`
|
||||||
|
|
||||||
|
Sortable, paginated table.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: "name", header: "Name", sortable: true },
|
||||||
|
{ key: "status", header: "Status", width: "100px" },
|
||||||
|
{ key: "updatedAt", header: "Updated", render: (v) => 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
|
||||||
|
<TimeseriesChart
|
||||||
|
title="Sync Frequency"
|
||||||
|
data={[
|
||||||
|
{ timestamp: "2026-03-01T00:00:00Z", value: 24 },
|
||||||
|
{ timestamp: "2026-03-02T00:00:00Z", value: 31 },
|
||||||
|
{ timestamp: "2026-03-03T00:00:00Z", value: 28 },
|
||||||
|
]}
|
||||||
|
type="bar"
|
||||||
|
yLabel="Syncs"
|
||||||
|
height={250}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ActionBar`
|
||||||
|
|
||||||
|
Row of action buttons wired to the plugin bridge.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ActionBar
|
||||||
|
actions={[
|
||||||
|
{ label: "Sync Now", actionKey: "sync", variant: "primary" },
|
||||||
|
{ label: "Clear Cache", actionKey: "clear-cache", confirm: true, confirmMessage: "Delete all cached data?" },
|
||||||
|
]}
|
||||||
|
onSuccess={(key) => data.refresh()}
|
||||||
|
onError={(key, err) => console.error(key, err)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `LogView`, `JsonTree`, `KeyValueList`, `MarkdownBlock`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<LogView entries={logEntries} maxHeight="300px" autoScroll />
|
||||||
|
<JsonTree data={debugPayload} defaultExpandDepth={3} />
|
||||||
|
<KeyValueList pairs={[{ label: "Plugin ID", value: pluginId }, { label: "Version", value: "1.2.0" }]} />
|
||||||
|
<MarkdownBlock content="**Bold** text and `code` blocks are supported." />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Spinner`, `ErrorBoundary`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Spinner size="lg" label="Loading plugin data..." />
|
||||||
|
|
||||||
|
<ErrorBoundary fallback={<p>Something went wrong.</p>} onError={(err) => console.error(err)}>
|
||||||
|
<MyPluginContent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<Record<string, string>>("agent-metrics", {
|
||||||
|
agentId: context.entityId,
|
||||||
|
companyId: context.companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) return <Spinner />;
|
||||||
|
if (!data) return <p>No metrics available.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyValueList
|
||||||
|
pairs={Object.entries(data).map(([label, value]) => ({ 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 (
|
||||||
|
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
|
||||||
|
Files
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.
|
||||||
|
|
||||||
|
## Toolbar launcher with a local modal
|
||||||
|
|
||||||
|
For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ui": {
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"type": "toolbarButton",
|
||||||
|
"id": "sync-toolbar-button",
|
||||||
|
"displayName": "Sync",
|
||||||
|
"exportName": "SyncToolbarButton"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capabilities": ["ui.action.register"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!context.projectId) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
try {
|
||||||
|
await syncProject({ projectId: context.projectId });
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : "Sync failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={() => !submitting && setOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold">Sync this project?</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Queue a sync for <code>{context.projectId}</code>.
|
||||||
|
</p>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="mt-2 text-sm text-destructive">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
||||||
|
{submitting ? <Spinner size="sm" /> : "Run sync"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
|
||||||
|
|
||||||
|
## Real-time streaming (`ctx.streams`)
|
||||||
|
|
||||||
|
Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
|
||||||
|
|
||||||
|
### Worker side
|
||||||
|
|
||||||
|
In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const plugin = definePlugin({
|
||||||
|
async setup(ctx) {
|
||||||
|
ctx.actions.register("chat", async (params) => {
|
||||||
|
const companyId = params.companyId as string;
|
||||||
|
ctx.streams.open("chat-stream", companyId);
|
||||||
|
|
||||||
|
for await (const token of streamFromLLM(params.prompt as string)) {
|
||||||
|
ctx.streams.emit("chat-stream", { text: token });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.streams.close("chat-stream");
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**API:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
|
||||||
|
| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
|
||||||
|
| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
|
||||||
|
|
||||||
|
Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
|
||||||
|
|
||||||
|
### UI side
|
||||||
|
|
||||||
|
Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
|
||||||
|
|
||||||
|
### Host-side architecture
|
||||||
|
|
||||||
|
The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
|
||||||
|
|
||||||
|
1. Worker emits `streams.emit` notification via stdout
|
||||||
|
2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
|
||||||
|
3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
|
||||||
|
|
||||||
|
The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
|
||||||
|
|
||||||
|
### Streaming agent responses to the UI
|
||||||
|
|
||||||
|
`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
|
||||||
|
|
||||||
|
```
|
||||||
|
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
|
||||||
|
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
|
||||||
|
|
||||||
|
**Worker:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.actions.register("ask-agent", async (params) => {
|
||||||
|
const { agentId, companyId, prompt } = params as {
|
||||||
|
agentId: string; companyId: string; prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const channel = `agent:${agentId}`;
|
||||||
|
ctx.streams.open(channel, companyId);
|
||||||
|
|
||||||
|
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||||
|
|
||||||
|
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||||
|
prompt,
|
||||||
|
onEvent: (event) => {
|
||||||
|
ctx.streams.emit(channel, {
|
||||||
|
type: event.eventType, // "chunk" | "done" | "error"
|
||||||
|
text: event.message ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.streams.close(channel);
|
||||||
|
return { sessionId: session.sessionId };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
interface AgentEvent {
|
||||||
|
type: "chunk" | "done" | "error";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
|
||||||
|
const askAgent = usePluginAction("ask-agent");
|
||||||
|
const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
setPrompt("");
|
||||||
|
await askAgent({ agentId, companyId, prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
|
||||||
|
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||||
|
<button onClick={send}>Send</button>
|
||||||
|
{connected && <button onClick={close}>Stop</button>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent sessions (two-way chat)
|
||||||
|
|
||||||
|
Plugins can hold multi-turn conversational sessions with agents:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Create a session
|
||||||
|
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||||
|
|
||||||
|
// Send a message and stream the response
|
||||||
|
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||||
|
prompt: "Help me triage this issue",
|
||||||
|
onEvent: (event) => {
|
||||||
|
if (event.eventType === "chunk") console.log(event.message);
|
||||||
|
if (event.eventType === "done") console.log("Stream complete");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// List active sessions
|
||||||
|
const sessions = await ctx.agents.sessions.list(agentId, companyId);
|
||||||
|
|
||||||
|
// Close when done
|
||||||
|
await ctx.agents.sessions.close(session.sessionId, companyId);
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
|
||||||
|
|
||||||
|
Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
|
||||||
|
|
||||||
|
## Testing utilities
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||||
|
import plugin from "../src/worker.js";
|
||||||
|
import manifest from "../src/manifest.js";
|
||||||
|
|
||||||
|
const harness = createTestHarness({ manifest });
|
||||||
|
await plugin.definition.setup(harness.ctx);
|
||||||
|
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundler presets
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||||
|
|
||||||
|
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||||
|
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
|
||||||
|
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local dev server (hot-reload events)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
|
||||||
|
```
|
||||||
|
|
||||||
|
Or programmatically:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
|
||||||
|
const server = await startPluginDevServer({ rootDir: process.cwd() });
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev server endpoints:
|
||||||
|
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
||||||
|
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
|
||||||
124
packages/plugins/sdk/package.json
Normal file
124
packages/plugins/sdk/package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
packages/plugins/sdk/src/bundlers.ts
Normal file
161
packages/plugins/sdk/src/bundlers.ts
Normal file
@@ -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 } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
|
||||||
|
*
|
||||||
|
* Plugin authors call `definePlugin()` and export the result as the default
|
||||||
|
* export from their worker entrypoint. The host imports the worker module,
|
||||||
|
* calls `setup()` with a `PluginContext`, and from that point the plugin
|
||||||
|
* responds to events, jobs, webhooks, and UI requests through the context.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // dist/worker.ts
|
||||||
|
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||||
|
*
|
||||||
|
* export default definePlugin({
|
||||||
|
* async setup(ctx) {
|
||||||
|
* ctx.logger.info("Linear sync plugin starting");
|
||||||
|
*
|
||||||
|
* // Subscribe to events
|
||||||
|
* ctx.events.on("issue.created", async (event) => {
|
||||||
|
* const config = await ctx.config.get();
|
||||||
|
* await ctx.http.fetch(`https://api.linear.app/...`, {
|
||||||
|
* method: "POST",
|
||||||
|
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
|
||||||
|
* body: JSON.stringify({ title: event.payload.title }),
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Register a job handler
|
||||||
|
* ctx.jobs.register("full-sync", async (job) => {
|
||||||
|
* ctx.logger.info("Running full-sync job", { runId: job.runId });
|
||||||
|
* // ... sync logic
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Register data for the UI
|
||||||
|
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||||
|
* const state = await ctx.state.get({
|
||||||
|
* scopeKind: "company",
|
||||||
|
* scopeId: String(companyId),
|
||||||
|
* stateKey: "last-sync",
|
||||||
|
* });
|
||||||
|
* return { lastSync: state };
|
||||||
|
* });
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginContext } from "./types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health check result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||||
|
*/
|
||||||
|
export interface PluginHealthDiagnostics {
|
||||||
|
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
|
||||||
|
status: "ok" | "degraded" | "error";
|
||||||
|
/** Human-readable description of the current health state. */
|
||||||
|
message?: string;
|
||||||
|
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config validation result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned from the `validateConfig()` RPC method.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||||
|
*/
|
||||||
|
export interface PluginConfigValidationResult {
|
||||||
|
/** Whether the config is valid. */
|
||||||
|
ok: boolean;
|
||||||
|
/** Non-fatal warnings about the config. */
|
||||||
|
warnings?: string[];
|
||||||
|
/** Validation errors (populated when `ok` is `false`). */
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Webhook handler input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input received by the plugin worker's `handleWebhook` handler.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||||
|
*/
|
||||||
|
export interface PluginWebhookInput {
|
||||||
|
/** Endpoint key matching the manifest declaration. */
|
||||||
|
endpointKey: string;
|
||||||
|
/** Inbound request headers. */
|
||||||
|
headers: Record<string, string | string[]>;
|
||||||
|
/** Raw request body as a UTF-8 string. */
|
||||||
|
rawBody: string;
|
||||||
|
/** Parsed JSON body (if applicable and parseable). */
|
||||||
|
parsedBody?: unknown;
|
||||||
|
/** Unique request identifier for idempotency checks. */
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin definition shape passed to `definePlugin()`.
|
||||||
|
*
|
||||||
|
* The only required field is `setup`, which receives the `PluginContext` and
|
||||||
|
* is where the plugin registers its handlers (events, jobs, data, actions,
|
||||||
|
* tools, etc.).
|
||||||
|
*
|
||||||
|
* All other lifecycle hooks are optional. If a hook is not implemented the
|
||||||
|
* host applies default behaviour (e.g. restarting the worker on config change
|
||||||
|
* instead of calling `onConfigChanged`).
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||||
|
*/
|
||||||
|
export interface PluginDefinition {
|
||||||
|
/**
|
||||||
|
* Called once when the plugin worker starts up, after `initialize` completes.
|
||||||
|
*
|
||||||
|
* This is where the plugin registers all its handlers: event subscriptions,
|
||||||
|
* job handlers, data/action handlers, and tool registrations. Registration
|
||||||
|
* must be synchronous after `setup` resolves — do not register handlers
|
||||||
|
* inside async callbacks that may resolve after `setup` returns.
|
||||||
|
*
|
||||||
|
* @param ctx - The full plugin context provided by the host
|
||||||
|
*/
|
||||||
|
setup(ctx: PluginContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the host wants to know if the plugin is healthy.
|
||||||
|
*
|
||||||
|
* The host polls this on a regular interval and surfaces the result in the
|
||||||
|
* plugin health dashboard. If not implemented, the host infers health from
|
||||||
|
* worker process liveness.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||||
|
*/
|
||||||
|
onHealth?(): Promise<PluginHealthDiagnostics>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the operator updates the plugin's instance configuration at
|
||||||
|
* runtime, without restarting the worker.
|
||||||
|
*
|
||||||
|
* If not implemented, the host restarts the worker to apply the new config.
|
||||||
|
*
|
||||||
|
* @param newConfig - The newly resolved configuration
|
||||||
|
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||||||
|
*/
|
||||||
|
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the host is about to shut down the plugin worker.
|
||||||
|
*
|
||||||
|
* The worker has at most 10 seconds (configurable via plugin config) to
|
||||||
|
* finish in-flight work and resolve this promise. After the deadline the
|
||||||
|
* host sends SIGTERM, then SIGKILL.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||||
|
*/
|
||||||
|
onShutdown?(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to validate the current plugin configuration.
|
||||||
|
*
|
||||||
|
* The host calls this:
|
||||||
|
* - after the plugin starts (to surface config errors immediately)
|
||||||
|
* - after the operator saves a new config (to validate before persisting)
|
||||||
|
* - via the "Test Connection" button in the settings UI
|
||||||
|
*
|
||||||
|
* @param config - The configuration to validate
|
||||||
|
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||||
|
*/
|
||||||
|
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to handle an inbound webhook delivery.
|
||||||
|
*
|
||||||
|
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
|
||||||
|
* this handler. The plugin is responsible for signature verification using
|
||||||
|
* a resolved secret ref.
|
||||||
|
*
|
||||||
|
* If not implemented but webhooks are declared in the manifest, the host
|
||||||
|
* returns HTTP 501 for webhook deliveries.
|
||||||
|
*
|
||||||
|
* @param input - Webhook delivery metadata and payload
|
||||||
|
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||||
|
*/
|
||||||
|
onWebhook?(input: PluginWebhookInput): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PaperclipPlugin — the sealed object returned by definePlugin()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sealed plugin object returned by `definePlugin()`.
|
||||||
|
*
|
||||||
|
* Plugin authors export this as the default export from their worker
|
||||||
|
* entrypoint. The host imports it and calls the lifecycle methods.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||||
|
*/
|
||||||
|
export interface PaperclipPlugin {
|
||||||
|
/** The original plugin definition passed to `definePlugin()`. */
|
||||||
|
readonly definition: PluginDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// definePlugin — top-level factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a Paperclip plugin.
|
||||||
|
*
|
||||||
|
* Call this function in your worker entrypoint and export the result as the
|
||||||
|
* default export. The host will import the module and call lifecycle methods
|
||||||
|
* on the returned object.
|
||||||
|
*
|
||||||
|
* @param definition - Plugin lifecycle handlers
|
||||||
|
* @returns A sealed `PaperclipPlugin` object for the host to consume
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||||
|
*
|
||||||
|
* export default definePlugin({
|
||||||
|
* async setup(ctx) {
|
||||||
|
* ctx.logger.info("Plugin started");
|
||||||
|
* ctx.events.on("issue.created", async (event) => {
|
||||||
|
* // handle event
|
||||||
|
* });
|
||||||
|
* },
|
||||||
|
*
|
||||||
|
* async onHealth() {
|
||||||
|
* return { status: "ok" };
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||||
|
*/
|
||||||
|
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
|
||||||
|
return Object.freeze({ definition });
|
||||||
|
}
|
||||||
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from "node:path";
|
||||||
|
import { startPluginDevServer } from "./dev-server.js";
|
||||||
|
|
||||||
|
function parseArg(flag: string): string | undefined {
|
||||||
|
const index = process.argv.indexOf(flag);
|
||||||
|
if (index < 0) return undefined;
|
||||||
|
return process.argv[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI entrypoint for the local plugin UI preview server.
|
||||||
|
*
|
||||||
|
* This is intentionally minimal and delegates all serving behavior to
|
||||||
|
* `startPluginDevServer` so tests and programmatic usage share one path.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const rootDir = parseArg("--root") ?? process.cwd();
|
||||||
|
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
|
||||||
|
const host = parseArg("--host") ?? "127.0.0.1";
|
||||||
|
const rawPort = parseArg("--port") ?? "4177";
|
||||||
|
const port = Number.parseInt(rawPort, 10);
|
||||||
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||||
|
throw new Error(`Invalid --port value: ${rawPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await startPluginDevServer({
|
||||||
|
rootDir: path.resolve(rootDir),
|
||||||
|
uiDir,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Paperclip plugin dev server listening at ${server.url}`);
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
await server.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void shutdown();
|
||||||
|
});
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
228
packages/plugins/sdk/src/dev-server.ts
Normal file
228
packages/plugins/sdk/src/dev-server.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { createReadStream, existsSync, statSync, watch } from "node:fs";
|
||||||
|
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export interface PluginDevServerOptions {
|
||||||
|
/** Plugin project root. Defaults to `process.cwd()`. */
|
||||||
|
rootDir?: string;
|
||||||
|
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
|
||||||
|
uiDir?: string;
|
||||||
|
/** Bind port for local preview server. Defaults to `4177`. */
|
||||||
|
port?: number;
|
||||||
|
/** Bind host. Defaults to `127.0.0.1`. */
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDevServer {
|
||||||
|
url: string;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Closeable {
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentType(filePath: string): string {
|
||||||
|
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||||
|
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
||||||
|
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||||
|
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
||||||
|
if (filePath.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilePath(baseDir: string, reqPath: string): string {
|
||||||
|
const pathname = reqPath.split("?")[0] || "/";
|
||||||
|
const resolved = pathname === "/" ? "/index.js" : pathname;
|
||||||
|
const absolute = path.resolve(baseDir, `.${resolved}`);
|
||||||
|
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
|
||||||
|
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
|
||||||
|
throw new Error("path traversal blocked");
|
||||||
|
}
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send404(res: ServerResponse) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res: ServerResponse, value: unknown) {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.end(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUiDir(uiDir: string): Promise<void> {
|
||||||
|
if (existsSync(uiDir)) return;
|
||||||
|
await mkdir(uiDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFilesRecursive(dir: string): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const abs = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
out.push(...await listFilesRecursive(abs));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
out.push(abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
|
||||||
|
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
|
||||||
|
try {
|
||||||
|
// macOS/Windows support recursive native watching.
|
||||||
|
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
|
||||||
|
if (!filename) return;
|
||||||
|
onReload(path.join(uiDir, filename));
|
||||||
|
});
|
||||||
|
return watcher;
|
||||||
|
} catch {
|
||||||
|
// Linux may reject recursive watch. Fall back to polling snapshots.
|
||||||
|
let previous = snapshotSignature(
|
||||||
|
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
|
||||||
|
file: row.file,
|
||||||
|
mtimeMs: row.mtimeMs,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
|
||||||
|
const next = snapshotSignature(nextRows);
|
||||||
|
if (next === previous) return;
|
||||||
|
previous = next;
|
||||||
|
onReload("__snapshot__");
|
||||||
|
} catch {
|
||||||
|
// Ignore transient read errors while bundlers are writing files.
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return {
|
||||||
|
close() {
|
||||||
|
clearInterval(timer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a local static server for plugin UI assets with SSE reload events.
|
||||||
|
*
|
||||||
|
* Endpoint summary:
|
||||||
|
* - `GET /__paperclip__/health` for diagnostics
|
||||||
|
* - `GET /__paperclip__/events` for hot-reload stream
|
||||||
|
* - Any other path serves files from the configured UI build directory
|
||||||
|
*/
|
||||||
|
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
|
||||||
|
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||||
|
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
|
||||||
|
const host = options.host ?? "127.0.0.1";
|
||||||
|
const port = options.port ?? 4177;
|
||||||
|
|
||||||
|
await ensureUiDir(uiDir);
|
||||||
|
|
||||||
|
const sseClients = new Set<ServerResponse>();
|
||||||
|
|
||||||
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const url = req.url ?? "/";
|
||||||
|
|
||||||
|
if (url === "/__paperclip__/health") {
|
||||||
|
sendJson(res, { ok: true, rootDir, uiDir });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/__paperclip__/events") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
});
|
||||||
|
res.write(`event: connected\ndata: {"ok":true}\n\n`);
|
||||||
|
sseClients.add(res);
|
||||||
|
req.on("close", () => {
|
||||||
|
sseClients.delete(res);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = normalizeFilePath(uiDir, url);
|
||||||
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||||
|
send404(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Content-Type", contentType(filePath));
|
||||||
|
createReadStream(filePath).pipe(res);
|
||||||
|
} catch {
|
||||||
|
send404(res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
void handleRequest(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifyReload = (filePath: string) => {
|
||||||
|
const rel = path.relative(uiDir, filePath);
|
||||||
|
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
|
||||||
|
for (const client of sseClients) {
|
||||||
|
client.write(`event: reload\ndata: ${payload}\n\n`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const watcher = await startUiWatcher(uiDir, notifyReload);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(port, host, () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `http://${host}:${actualPort}`,
|
||||||
|
async close() {
|
||||||
|
watcher.close();
|
||||||
|
for (const client of sseClients) {
|
||||||
|
client.end();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a stable file+mtime snapshot for a built plugin UI directory.
|
||||||
|
*
|
||||||
|
* Used by the polling watcher fallback and useful for tests that need to assert
|
||||||
|
* whether a UI build has changed between runs.
|
||||||
|
*/
|
||||||
|
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
|
||||||
|
const baseDir = path.resolve(rootDir, uiDir);
|
||||||
|
if (!existsSync(baseDir)) return [];
|
||||||
|
const files = await listFilesRecursive(baseDir);
|
||||||
|
const rows = await Promise.all(files.map(async (filePath) => {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
return {
|
||||||
|
file: path.relative(baseDir, filePath),
|
||||||
|
mtimeMs: fileStat.mtimeMs,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return rows.sort((a, b) => a.file.localeCompare(b.file));
|
||||||
|
}
|
||||||
563
packages/plugins/sdk/src/host-client-factory.ts
Normal file
563
packages/plugins/sdk/src/host-client-factory.ts
Normal file
@@ -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<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||||
|
state: {
|
||||||
|
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||||
|
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
|
||||||
|
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `entities.upsert`, `entities.list`. */
|
||||||
|
entities: {
|
||||||
|
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
|
||||||
|
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `events.emit`. */
|
||||||
|
events: {
|
||||||
|
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `http.fetch`. */
|
||||||
|
http: {
|
||||||
|
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `secrets.resolve`. */
|
||||||
|
secrets: {
|
||||||
|
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `assets.upload`, `assets.getUrl`. */
|
||||||
|
assets: {
|
||||||
|
upload(params: WorkerToHostMethods["assets.upload"][0]): Promise<WorkerToHostMethods["assets.upload"][1]>;
|
||||||
|
getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `activity.log`. */
|
||||||
|
activity: {
|
||||||
|
log(params: {
|
||||||
|
companyId: string;
|
||||||
|
message: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `metrics.write`. */
|
||||||
|
metrics: {
|
||||||
|
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `log`. */
|
||||||
|
logger: {
|
||||||
|
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `companies.list`, `companies.get`. */
|
||||||
|
companies: {
|
||||||
|
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
|
||||||
|
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
|
||||||
|
projects: {
|
||||||
|
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||||
|
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
|
||||||
|
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||||
|
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||||
|
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
|
||||||
|
issues: {
|
||||||
|
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
||||||
|
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
||||||
|
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
||||||
|
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
|
||||||
|
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||||
|
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
|
||||||
|
agents: {
|
||||||
|
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
|
||||||
|
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
|
||||||
|
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||||
|
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||||
|
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||||
|
agentSessions: {
|
||||||
|
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
|
||||||
|
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
|
||||||
|
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
|
||||||
|
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
|
||||||
|
goals: {
|
||||||
|
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
|
||||||
|
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
|
||||||
|
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
|
||||||
|
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory input
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for `createHostClientHandlers`.
|
||||||
|
*/
|
||||||
|
export interface HostClientFactoryOptions {
|
||||||
|
/** The plugin ID. Used for error messages and logging. */
|
||||||
|
pluginId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capabilities declared by the plugin in its manifest. The factory
|
||||||
|
* enforces these at runtime before delegating to the service adapter.
|
||||||
|
*/
|
||||||
|
capabilities: readonly PluginCapability[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete implementations of host platform services. Each handler in the
|
||||||
|
* returned map delegates to the corresponding service method.
|
||||||
|
*/
|
||||||
|
services: HostServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handler function for a specific worker→host method.
|
||||||
|
*/
|
||||||
|
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||||
|
params: WorkerToHostMethods[M][0],
|
||||||
|
) => Promise<WorkerToHostMethods[M][1]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A complete map of all worker→host method handlers.
|
||||||
|
*
|
||||||
|
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
|
||||||
|
* but makes every handler required (the factory always provides all handlers).
|
||||||
|
*/
|
||||||
|
export type HostClientHandlers = {
|
||||||
|
[M in WorkerToHostMethodName]: HostHandler<M>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Capability → method mapping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps each worker→host RPC method to the capability required to invoke it.
|
||||||
|
* Methods without a capability requirement (e.g. `config.get`, `log`) are
|
||||||
|
* mapped to `null`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||||
|
*/
|
||||||
|
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
|
||||||
|
// Config — always allowed
|
||||||
|
"config.get": null,
|
||||||
|
|
||||||
|
// State
|
||||||
|
"state.get": "plugin.state.read",
|
||||||
|
"state.set": "plugin.state.write",
|
||||||
|
"state.delete": "plugin.state.write",
|
||||||
|
|
||||||
|
// Entities — no specific capability required (plugin-scoped by design)
|
||||||
|
"entities.upsert": null,
|
||||||
|
"entities.list": null,
|
||||||
|
|
||||||
|
// Events
|
||||||
|
"events.emit": "events.emit",
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
"http.fetch": "http.outbound",
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
"secrets.resolve": "secrets.read-ref",
|
||||||
|
|
||||||
|
// 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<PluginCapability>(options.capabilities);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that the plugin has the required capability for a method.
|
||||||
|
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||||
|
*/
|
||||||
|
function requireCapability(
|
||||||
|
method: WorkerToHostMethodName,
|
||||||
|
): void {
|
||||||
|
const required = METHOD_CAPABILITY_MAP[method];
|
||||||
|
if (required === null) return; // No capability required
|
||||||
|
if (capabilitySet.has(required)) return;
|
||||||
|
throw new CapabilityDeniedError(pluginId, method, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a capability-gated proxy handler for a method.
|
||||||
|
*
|
||||||
|
* @param method - The RPC method name (used for capability lookup)
|
||||||
|
* @param handler - The actual handler implementation
|
||||||
|
* @returns A wrapper that checks capabilities before delegating
|
||||||
|
*/
|
||||||
|
function gated<M extends WorkerToHostMethodName>(
|
||||||
|
method: M,
|
||||||
|
handler: HostHandler<M>,
|
||||||
|
): HostHandler<M> {
|
||||||
|
return async (params: WorkerToHostMethods[M][0]) => {
|
||||||
|
requireCapability(method);
|
||||||
|
return handler(params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Build the complete handler map
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Config
|
||||||
|
"config.get": gated("config.get", async () => {
|
||||||
|
return services.config.get();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// State
|
||||||
|
"state.get": gated("state.get", async (params) => {
|
||||||
|
return services.state.get(params);
|
||||||
|
}),
|
||||||
|
"state.set": gated("state.set", async (params) => {
|
||||||
|
return services.state.set(params);
|
||||||
|
}),
|
||||||
|
"state.delete": gated("state.delete", async (params) => {
|
||||||
|
return services.state.delete(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
"entities.upsert": gated("entities.upsert", async (params) => {
|
||||||
|
return services.entities.upsert(params);
|
||||||
|
}),
|
||||||
|
"entities.list": gated("entities.list", async (params) => {
|
||||||
|
return services.entities.list(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Events
|
||||||
|
"events.emit": gated("events.emit", async (params) => {
|
||||||
|
return services.events.emit(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
"http.fetch": gated("http.fetch", async (params) => {
|
||||||
|
return services.http.fetch(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Secrets
|
||||||
|
"secrets.resolve": gated("secrets.resolve", async (params) => {
|
||||||
|
return services.secrets.resolve(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
287
packages/plugins/sdk/src/index.ts
Normal file
287
packages/plugins/sdk/src/index.ts
Normal file
@@ -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";
|
||||||
1038
packages/plugins/sdk/src/protocol.ts
Normal file
1038
packages/plugins/sdk/src/protocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
720
packages/plugins/sdk/src/testing.ts
Normal file
720
packages/plugins/sdk/src/testing.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestHarnessLogEntry {
|
||||||
|
level: "info" | "warn" | "error" | "debug";
|
||||||
|
message: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestHarness {
|
||||||
|
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||||
|
ctx: PluginContext;
|
||||||
|
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||||
|
seed(input: {
|
||||||
|
companies?: Company[];
|
||||||
|
projects?: Project[];
|
||||||
|
issues?: Issue[];
|
||||||
|
issueComments?: IssueComment[];
|
||||||
|
agents?: Agent[];
|
||||||
|
goals?: Goal[];
|
||||||
|
}): void;
|
||||||
|
setConfig(config: Record<string, unknown>): void;
|
||||||
|
/** Dispatch a host or plugin event to registered handlers. */
|
||||||
|
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
||||||
|
/** Execute a previously-registered scheduled job handler. */
|
||||||
|
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
||||||
|
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||||
|
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||||
|
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||||
|
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||||
|
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||||
|
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||||
|
/** Read raw in-memory state for assertions. */
|
||||||
|
getState(input: ScopeKey): unknown;
|
||||||
|
/** Simulate a streaming event arriving for an active session. */
|
||||||
|
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
||||||
|
logs: TestHarnessLogEntry[];
|
||||||
|
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||||
|
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventRegistration = {
|
||||||
|
name: PluginEventType | `plugin.${string}`;
|
||||||
|
filter?: EventFilter;
|
||||||
|
fn: (event: PluginEvent) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
||||||
|
return {
|
||||||
|
scopeKind: input.scopeKind,
|
||||||
|
scopeId: input.scopeId,
|
||||||
|
namespace: input.namespace ?? "default",
|
||||||
|
stateKey: input.stateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateMapKey(input: ScopeKey): string {
|
||||||
|
const normalized = normalizeScope(input);
|
||||||
|
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
||||||
|
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
||||||
|
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
||||||
|
if (allowed.has(capability)) return;
|
||||||
|
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCompanyId(companyId?: string): string {
|
||||||
|
if (!companyId) throw new Error("companyId is required for this operation");
|
||||||
|
return companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInCompany<T extends { companyId: string | null | undefined }>(
|
||||||
|
record: T | null | undefined,
|
||||||
|
companyId: string,
|
||||||
|
): record is T {
|
||||||
|
return Boolean(record && record.companyId === companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an in-memory host harness for plugin worker tests.
|
||||||
|
*
|
||||||
|
* The harness enforces declared capabilities and simulates host APIs, so tests
|
||||||
|
* can validate plugin behavior without spinning up the Paperclip server runtime.
|
||||||
|
*/
|
||||||
|
export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
|
const manifest = options.manifest;
|
||||||
|
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
||||||
|
let currentConfig = { ...(options.config ?? {}) };
|
||||||
|
|
||||||
|
const logs: TestHarnessLogEntry[] = [];
|
||||||
|
const activity: TestHarness["activity"] = [];
|
||||||
|
const metrics: TestHarness["metrics"] = [];
|
||||||
|
|
||||||
|
const state = new Map<string, unknown>();
|
||||||
|
const entities = new Map<string, PluginEntityRecord>();
|
||||||
|
const entityExternalIndex = new Map<string, string>();
|
||||||
|
const assets = new Map<string, { contentType: string; data: Uint8Array }>();
|
||||||
|
|
||||||
|
const companies = new Map<string, Company>();
|
||||||
|
const projects = new Map<string, Project>();
|
||||||
|
const issues = new Map<string, Issue>();
|
||||||
|
const issueComments = new Map<string, IssueComment[]>();
|
||||||
|
const agents = new Map<string, Agent>();
|
||||||
|
const goals = new Map<string, Goal>();
|
||||||
|
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||||
|
|
||||||
|
const sessions = new Map<string, AgentSession>();
|
||||||
|
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||||
|
|
||||||
|
const events: EventRegistration[] = [];
|
||||||
|
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||||
|
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||||
|
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||||
|
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||||
|
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||||
|
|
||||||
|
const ctx: PluginContext = {
|
||||||
|
manifest,
|
||||||
|
config: {
|
||||||
|
async get() {
|
||||||
|
return { ...currentConfig };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||||
|
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||||
|
let registration: EventRegistration;
|
||||||
|
if (typeof filterOrFn === "function") {
|
||||||
|
registration = { name, fn: filterOrFn };
|
||||||
|
} else {
|
||||||
|
if (!maybeFn) throw new Error("event handler is required");
|
||||||
|
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||||
|
}
|
||||||
|
events.push(registration);
|
||||||
|
return () => {
|
||||||
|
const idx = events.indexOf(registration);
|
||||||
|
if (idx !== -1) events.splice(idx, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async emit(name, companyId, payload) {
|
||||||
|
requireCapability(manifest, capabilitySet, "events.emit");
|
||||||
|
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
register(key, fn) {
|
||||||
|
requireCapability(manifest, capabilitySet, "jobs.schedule");
|
||||||
|
jobs.set(key, fn);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
launchers: {
|
||||||
|
register(launcher) {
|
||||||
|
launchers.set(launcher.id, launcher);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
async fetch(url, init) {
|
||||||
|
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||||
|
return fetch(url, init);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secrets: {
|
||||||
|
async resolve(secretRef) {
|
||||||
|
requireCapability(manifest, capabilitySet, "secrets.read-ref");
|
||||||
|
return `resolved:${secretRef}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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<string, unknown>)?.projectId as string | undefined;
|
||||||
|
if (!projectId) return null;
|
||||||
|
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||||
|
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||||
|
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
companies: {
|
||||||
|
async list(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "companies.read");
|
||||||
|
let out = [...companies.values()];
|
||||||
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
|
if (input?.limit) out = out.slice(0, input.limit);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async get(companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "companies.read");
|
||||||
|
return companies.get(companyId) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issues: {
|
||||||
|
async list(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issues.read");
|
||||||
|
const companyId = requireCompanyId(input?.companyId);
|
||||||
|
let out = [...issues.values()];
|
||||||
|
out = out.filter((issue) => issue.companyId === companyId);
|
||||||
|
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||||
|
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
||||||
|
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||||
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
|
if (input?.limit) out = out.slice(0, input.limit);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async get(issueId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issues.read");
|
||||||
|
const issue = issues.get(issueId);
|
||||||
|
return isInCompany(issue, companyId) ? issue : null;
|
||||||
|
},
|
||||||
|
async create(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issues.create");
|
||||||
|
const now = new Date();
|
||||||
|
const record: Issue = {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: input.companyId,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
goalId: input.goalId ?? null,
|
||||||
|
parentId: input.parentId ?? null,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
status: "todo",
|
||||||
|
priority: input.priority ?? "medium",
|
||||||
|
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: null,
|
||||||
|
identifier: null,
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
issues.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
async update(issueId, patch, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issues.update");
|
||||||
|
const record = issues.get(issueId);
|
||||||
|
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
||||||
|
const updated: Issue = {
|
||||||
|
...record,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
issues.set(issueId, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
async listComments(issueId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||||
|
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||||
|
return issueComments.get(issueId) ?? [];
|
||||||
|
},
|
||||||
|
async createComment(issueId, body, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||||
|
const parentIssue = issues.get(issueId);
|
||||||
|
if (!isInCompany(parentIssue, companyId)) {
|
||||||
|
throw new Error(`Issue not found: ${issueId}`);
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const comment: IssueComment = {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: parentIssue.companyId,
|
||||||
|
issueId,
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: null,
|
||||||
|
body,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const current = issueComments.get(issueId) ?? [];
|
||||||
|
current.push(comment);
|
||||||
|
issueComments.set(issueId, current);
|
||||||
|
return comment;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
async list(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.read");
|
||||||
|
const companyId = requireCompanyId(input?.companyId);
|
||||||
|
let out = [...agents.values()];
|
||||||
|
out = out.filter((agent) => agent.companyId === companyId);
|
||||||
|
if (input?.status) out = out.filter((agent) => agent.status === input.status);
|
||||||
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
|
if (input?.limit) out = out.slice(0, input.limit);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async get(agentId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.read");
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
return isInCompany(agent, companyId) ? agent : null;
|
||||||
|
},
|
||||||
|
async pause(agentId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.pause");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
|
||||||
|
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
|
||||||
|
agents.set(agentId, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
async resume(agentId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.resume");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
|
||||||
|
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
|
||||||
|
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
|
||||||
|
agents.set(agentId, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
async invoke(agentId, companyId, opts) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.invoke");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
if (
|
||||||
|
agent!.status === "paused" ||
|
||||||
|
agent!.status === "terminated" ||
|
||||||
|
agent!.status === "pending_approval"
|
||||||
|
) {
|
||||||
|
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
|
||||||
|
}
|
||||||
|
return { runId: randomUUID() };
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
async create(agentId, companyId, opts) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const agent = agents.get(agentId);
|
||||||
|
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||||
|
const session: AgentSession = {
|
||||||
|
sessionId: randomUUID(),
|
||||||
|
agentId,
|
||||||
|
companyId: cid,
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
sessions.set(session.sessionId, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
async list(agentId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agent.sessions.list");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
return [...sessions.values()].filter(
|
||||||
|
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async sendMessage(sessionId, companyId, opts) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agent.sessions.send");
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
|
||||||
|
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
if (opts.onEvent) {
|
||||||
|
sessionEventCallbacks.set(sessionId, opts.onEvent);
|
||||||
|
}
|
||||||
|
return { runId: randomUUID() };
|
||||||
|
},
|
||||||
|
async close(sessionId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agent.sessions.close");
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||||
|
session.status = "closed";
|
||||||
|
sessionEventCallbacks.delete(sessionId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
goals: {
|
||||||
|
async list(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "goals.read");
|
||||||
|
const companyId = requireCompanyId(input?.companyId);
|
||||||
|
let out = [...goals.values()];
|
||||||
|
out = out.filter((goal) => goal.companyId === companyId);
|
||||||
|
if (input?.level) out = out.filter((goal) => goal.level === input.level);
|
||||||
|
if (input?.status) out = out.filter((goal) => goal.status === input.status);
|
||||||
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
|
if (input?.limit) out = out.slice(0, input.limit);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
async get(goalId, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "goals.read");
|
||||||
|
const goal = goals.get(goalId);
|
||||||
|
return isInCompany(goal, companyId) ? goal : null;
|
||||||
|
},
|
||||||
|
async create(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "goals.create");
|
||||||
|
const now = new Date();
|
||||||
|
const record: Goal = {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: input.companyId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
level: input.level ?? "task",
|
||||||
|
status: input.status ?? "planned",
|
||||||
|
parentId: input.parentId ?? null,
|
||||||
|
ownerAgentId: input.ownerAgentId ?? null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
goals.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
async update(goalId, patch, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "goals.update");
|
||||||
|
const record = goals.get(goalId);
|
||||||
|
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
|
||||||
|
const updated: Goal = {
|
||||||
|
...record,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
goals.set(goalId, updated);
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
register(key, handler) {
|
||||||
|
dataHandlers.set(key, handler);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
register(key, handler) {
|
||||||
|
actionHandlers.set(key, handler);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
streams: (() => {
|
||||||
|
const channelCompanyMap = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
open(channel: string, companyId: string) {
|
||||||
|
channelCompanyMap.set(channel, companyId);
|
||||||
|
},
|
||||||
|
emit(_channel: string, _event: unknown) {
|
||||||
|
// No-op in test harness — events are not forwarded
|
||||||
|
},
|
||||||
|
close(channel: string) {
|
||||||
|
channelCompanyMap.delete(channel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
tools: {
|
||||||
|
register(name, _decl, fn) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||||
|
toolHandlers.set(name, fn);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
async write(name, value, tags) {
|
||||||
|
requireCapability(manifest, capabilitySet, "metrics.write");
|
||||||
|
metrics.push({ name, value, tags });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: {
|
||||||
|
info(message, meta) {
|
||||||
|
logs.push({ level: "info", message, meta });
|
||||||
|
},
|
||||||
|
warn(message, meta) {
|
||||||
|
logs.push({ level: "warn", message, meta });
|
||||||
|
},
|
||||||
|
error(message, meta) {
|
||||||
|
logs.push({ level: "error", message, meta });
|
||||||
|
},
|
||||||
|
debug(message, meta) {
|
||||||
|
logs.push({ level: "debug", message, meta });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const harness: TestHarness = {
|
||||||
|
ctx,
|
||||||
|
seed(input) {
|
||||||
|
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||||
|
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||||
|
for (const row of input.issues ?? []) issues.set(row.id, row);
|
||||||
|
for (const row of input.issueComments ?? []) {
|
||||||
|
const list = issueComments.get(row.issueId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
issueComments.set(row.issueId, list);
|
||||||
|
}
|
||||||
|
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||||
|
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||||
|
},
|
||||||
|
setConfig(config) {
|
||||||
|
currentConfig = { ...config };
|
||||||
|
},
|
||||||
|
async emit(eventType, payload, base) {
|
||||||
|
const event: PluginEvent = {
|
||||||
|
eventId: base?.eventId ?? randomUUID(),
|
||||||
|
eventType,
|
||||||
|
companyId: base?.companyId ?? "test-company",
|
||||||
|
occurredAt: base?.occurredAt ?? new Date().toISOString(),
|
||||||
|
actorId: base?.actorId,
|
||||||
|
actorType: base?.actorType,
|
||||||
|
entityId: base?.entityId,
|
||||||
|
entityType: base?.entityType,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const handler of events) {
|
||||||
|
const exactMatch = handler.name === event.eventType;
|
||||||
|
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
|
||||||
|
const wildcardPluginOne = String(handler.name).endsWith(".*")
|
||||||
|
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
|
||||||
|
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
|
||||||
|
if (!allowsEvent(handler.filter, event)) continue;
|
||||||
|
await handler.fn(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async runJob(jobKey, partial = {}) {
|
||||||
|
const handler = jobs.get(jobKey);
|
||||||
|
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
|
||||||
|
await handler({
|
||||||
|
jobKey,
|
||||||
|
runId: partial.runId ?? randomUUID(),
|
||||||
|
trigger: partial.trigger ?? "manual",
|
||||||
|
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||||
|
const handler = dataHandlers.get(key);
|
||||||
|
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||||
|
return await handler(params) as T;
|
||||||
|
},
|
||||||
|
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||||
|
const handler = actionHandlers.get(key);
|
||||||
|
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||||
|
return await handler(params) as T;
|
||||||
|
},
|
||||||
|
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||||
|
const handler = toolHandlers.get(name);
|
||||||
|
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
||||||
|
const ctxToPass: ToolRunContext = {
|
||||||
|
agentId: runCtx.agentId ?? "agent-test",
|
||||||
|
runId: runCtx.runId ?? randomUUID(),
|
||||||
|
companyId: runCtx.companyId ?? "company-test",
|
||||||
|
projectId: runCtx.projectId ?? "project-test",
|
||||||
|
};
|
||||||
|
return await handler(params, ctxToPass) as T;
|
||||||
|
},
|
||||||
|
getState(input) {
|
||||||
|
return state.get(stateMapKey(input));
|
||||||
|
},
|
||||||
|
simulateSessionEvent(sessionId, event) {
|
||||||
|
const cb = sessionEventCallbacks.get(sessionId);
|
||||||
|
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
|
||||||
|
cb({ ...event, sessionId });
|
||||||
|
},
|
||||||
|
logs,
|
||||||
|
activity,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
1116
packages/plugins/sdk/src/types.ts
Normal file
1116
packages/plugins/sdk/src/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
310
packages/plugins/sdk/src/ui/components.ts
Normal file
310
packages/plugins/sdk/src/ui/components.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* Shared UI component declarations for plugin frontends.
|
||||||
|
*
|
||||||
|
* These components are exported from `@paperclipai/plugin-sdk/ui` and are
|
||||||
|
* provided by the host at runtime. They match the host's design tokens and
|
||||||
|
* visual language, reducing the boilerplate needed to build consistent plugin UIs.
|
||||||
|
*
|
||||||
|
* **Plugins are not required to use these components.** They exist to reduce
|
||||||
|
* boilerplate and keep visual consistency. A plugin may render entirely custom
|
||||||
|
* UI using any React component library.
|
||||||
|
*
|
||||||
|
* Component implementations are provided by the host — plugin bundles contain
|
||||||
|
* only the type declarations; the runtime implementations are injected via the
|
||||||
|
* host module registry.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import { renderSdkUiComponent } from "./runtime.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component prop interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trend value that can accompany a metric.
|
||||||
|
* Positive values indicate upward trends; negative values indicate downward trends.
|
||||||
|
*/
|
||||||
|
export interface MetricTrend {
|
||||||
|
/** Direction of the trend. */
|
||||||
|
direction: "up" | "down" | "flat";
|
||||||
|
/** Percentage change value (e.g. `12.5` for 12.5%). */
|
||||||
|
percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `MetricCard`. */
|
||||||
|
export interface MetricCardProps {
|
||||||
|
/** Short label describing the metric (e.g. `"Synced Issues"`). */
|
||||||
|
label: string;
|
||||||
|
/** The metric value to display. */
|
||||||
|
value: number | string;
|
||||||
|
/** Optional trend indicator. */
|
||||||
|
trend?: MetricTrend;
|
||||||
|
/** Optional sparkline data (array of numbers, latest last). */
|
||||||
|
sparkline?: number[];
|
||||||
|
/** Optional unit suffix (e.g. `"%"`, `"ms"`). */
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status variants for `StatusBadge`. */
|
||||||
|
export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||||
|
|
||||||
|
/** Props for `StatusBadge`. */
|
||||||
|
export interface StatusBadgeProps {
|
||||||
|
/** Human-readable label. */
|
||||||
|
label: string;
|
||||||
|
/** Visual variant determining colour. */
|
||||||
|
status: StatusBadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single column definition for `DataTable`. */
|
||||||
|
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||||
|
/** Column key, matching a field on the row object. */
|
||||||
|
key: keyof T & string;
|
||||||
|
/** Column header label. */
|
||||||
|
header: string;
|
||||||
|
/** Optional custom cell renderer. */
|
||||||
|
render?: (value: unknown, row: T) => React.ReactNode;
|
||||||
|
/** Whether this column is sortable. */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** CSS width (e.g. `"120px"`, `"20%"`). */
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `DataTable`. */
|
||||||
|
export interface DataTableProps<T = Record<string, unknown>> {
|
||||||
|
/** Column definitions. */
|
||||||
|
columns: DataTableColumn<T>[];
|
||||||
|
/** Row data. Each row should have a stable `id` field. */
|
||||||
|
rows: T[];
|
||||||
|
/** Whether the table is currently loading. */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Message shown when `rows` is empty. */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Total row count for pagination (if different from `rows.length`). */
|
||||||
|
totalCount?: number;
|
||||||
|
/** Current page (0-based, for pagination). */
|
||||||
|
page?: number;
|
||||||
|
/** Rows per page (for pagination). */
|
||||||
|
pageSize?: number;
|
||||||
|
/** Callback when page changes. */
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
/** Callback when a column header is clicked to sort. */
|
||||||
|
onSort?: (key: string, direction: "asc" | "desc") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single data point for `TimeseriesChart`. */
|
||||||
|
export interface TimeseriesDataPoint {
|
||||||
|
/** ISO 8601 timestamp. */
|
||||||
|
timestamp: string;
|
||||||
|
/** Numeric value. */
|
||||||
|
value: number;
|
||||||
|
/** Optional label for the point. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `TimeseriesChart`. */
|
||||||
|
export interface TimeseriesChartProps {
|
||||||
|
/** Series data. */
|
||||||
|
data: TimeseriesDataPoint[];
|
||||||
|
/** Chart title. */
|
||||||
|
title?: string;
|
||||||
|
/** Y-axis label. */
|
||||||
|
yLabel?: string;
|
||||||
|
/** Chart type. Defaults to `"line"`. */
|
||||||
|
type?: "line" | "bar";
|
||||||
|
/** Height of the chart in pixels. Defaults to `200`. */
|
||||||
|
height?: number;
|
||||||
|
/** Whether the chart is currently loading. */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `MarkdownBlock`. */
|
||||||
|
export interface MarkdownBlockProps {
|
||||||
|
/** Markdown content to render. */
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single key-value pair for `KeyValueList`. */
|
||||||
|
export interface KeyValuePair {
|
||||||
|
/** Label for the key. */
|
||||||
|
label: string;
|
||||||
|
/** Value to display. May be a string, number, or a React node. */
|
||||||
|
value: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `KeyValueList`. */
|
||||||
|
export interface KeyValueListProps {
|
||||||
|
/** Pairs to render in the list. */
|
||||||
|
pairs: KeyValuePair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single action button for `ActionBar`. */
|
||||||
|
export interface ActionBarItem {
|
||||||
|
/** Button label. */
|
||||||
|
label: string;
|
||||||
|
/** Action key to call via the plugin bridge. */
|
||||||
|
actionKey: string;
|
||||||
|
/** Optional parameters to pass to the action handler. */
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
/** Button variant. Defaults to `"default"`. */
|
||||||
|
variant?: "default" | "primary" | "destructive";
|
||||||
|
/** Whether to show a confirmation dialog before executing. */
|
||||||
|
confirm?: boolean;
|
||||||
|
/** Text for the confirmation dialog (used when `confirm` is true). */
|
||||||
|
confirmMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `ActionBar`. */
|
||||||
|
export interface ActionBarProps {
|
||||||
|
/** Action definitions. */
|
||||||
|
actions: ActionBarItem[];
|
||||||
|
/** Called after an action succeeds. Use to trigger data refresh. */
|
||||||
|
onSuccess?: (actionKey: string, result: unknown) => void;
|
||||||
|
/** Called when an action fails. */
|
||||||
|
onError?: (actionKey: string, error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single log line for `LogView`. */
|
||||||
|
export interface LogViewEntry {
|
||||||
|
/** ISO 8601 timestamp. */
|
||||||
|
timestamp: string;
|
||||||
|
/** Log level. */
|
||||||
|
level: "info" | "warn" | "error" | "debug";
|
||||||
|
/** Log message. */
|
||||||
|
message: string;
|
||||||
|
/** Optional structured metadata. */
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `LogView`. */
|
||||||
|
export interface LogViewProps {
|
||||||
|
/** Log entries to display. */
|
||||||
|
entries: LogViewEntry[];
|
||||||
|
/** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
|
||||||
|
maxHeight?: string;
|
||||||
|
/** Whether to auto-scroll to the latest entry. */
|
||||||
|
autoScroll?: boolean;
|
||||||
|
/** Whether the log is currently loading. */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `JsonTree`. */
|
||||||
|
export interface JsonTreeProps {
|
||||||
|
/** The data to render as a collapsible JSON tree. */
|
||||||
|
data: unknown;
|
||||||
|
/** Initial depth to expand. Defaults to `2`. */
|
||||||
|
defaultExpandDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `Spinner`. */
|
||||||
|
export interface SpinnerProps {
|
||||||
|
/** Size of the spinner. Defaults to `"md"`. */
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Accessible label for the spinner (used as `aria-label`). */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `ErrorBoundary`. */
|
||||||
|
export interface ErrorBoundaryProps {
|
||||||
|
/** Content to render inside the error boundary. */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Optional custom fallback to render when an error is caught. */
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
/** Called when an error is caught, for logging or reporting. */
|
||||||
|
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component declarations (provided by host at runtime)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// These are declared as ambient values so plugin TypeScript code can import
|
||||||
|
// and use them with full type-checking. The host's module registry provides
|
||||||
|
// the concrete React component implementations at bundle load time.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a single metric with an optional trend indicator and sparkline.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
function createSdkUiComponent<TProps>(name: string): React.ComponentType<TProps> {
|
||||||
|
return function PaperclipSdkUiComponent(props: TProps) {
|
||||||
|
return renderSdkUiComponent(name, props) as React.ReactNode;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetricCard = createSdkUiComponent<MetricCardProps>("MetricCard");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an inline status badge (ok / warning / error / info / pending).
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const StatusBadge = createSdkUiComponent<StatusBadgeProps>("StatusBadge");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sortable, paginated data table.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const DataTable = createSdkUiComponent<DataTableProps>("DataTable");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line or bar chart for time-series data.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("TimeseriesChart");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Markdown text as HTML.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a definition-list of label/value pairs.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const KeyValueList = createSdkUiComponent<KeyValueListProps>("KeyValueList");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row of action buttons wired to the plugin bridge's `performAction` handlers.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const ActionBar = createSdkUiComponent<ActionBarProps>("ActionBar");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrollable, timestamped log output viewer.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const LogView = createSdkUiComponent<LogViewProps>("LogView");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsible JSON tree for debugging or raw data inspection.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const JsonTree = createSdkUiComponent<JsonTreeProps>("JsonTree");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading indicator.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React error boundary that prevents plugin rendering errors from crashing
|
||||||
|
* the host page.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
|
*/
|
||||||
|
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||||
153
packages/plugins/sdk/src/ui/hooks.ts
Normal file
153
packages/plugins/sdk/src/ui/hooks.ts
Normal file
@@ -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<T>` with `data`, `loading`, `error`, and `refresh`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function SyncWidget({ context }: PluginWidgetProps) {
|
||||||
|
* const { data, loading, error } = usePluginData<SyncHealth>("sync-health", {
|
||||||
|
* companyId: context.companyId,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* if (loading) return <Spinner />;
|
||||||
|
* if (error) return <div>Error: {error.message}</div>;
|
||||||
|
* return <MetricCard label="Synced Issues" value={data!.syncedCount} />;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.8 — `getData`
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
|
*/
|
||||||
|
export function usePluginData<T = unknown>(
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): PluginDataResult<T> {
|
||||||
|
const impl = getSdkUiRuntimeValue<
|
||||||
|
(nextKey: string, nextParams?: Record<string, unknown>) => PluginDataResult<T>
|
||||||
|
>("usePluginData");
|
||||||
|
return impl(key, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginAction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a callable function that invokes the plugin worker's registered
|
||||||
|
* `performAction` handler.
|
||||||
|
*
|
||||||
|
* The returned function is async and throws a `PluginBridgeError` on failure.
|
||||||
|
*
|
||||||
|
* @param key - The action key matching the handler registered with `ctx.actions.register()`
|
||||||
|
* @returns An async function that sends the action to the worker and resolves with the result
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function ResyncButton({ context }: PluginWidgetProps) {
|
||||||
|
* const resync = usePluginAction("resync");
|
||||||
|
* const [error, setError] = useState<string | null>(null);
|
||||||
|
*
|
||||||
|
* async function handleClick() {
|
||||||
|
* try {
|
||||||
|
* await resync({ companyId: context.companyId });
|
||||||
|
* } catch (err) {
|
||||||
|
* setError((err as PluginBridgeError).message);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* return <button onClick={handleClick}>Resync Now</button>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
|
*/
|
||||||
|
export function usePluginAction(key: string): PluginActionFn {
|
||||||
|
const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction");
|
||||||
|
return impl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useHostContext
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current host context (active company, project, entity, user).
|
||||||
|
*
|
||||||
|
* Use this to know which context the plugin component is being rendered in
|
||||||
|
* so you can scope data requests and actions accordingly.
|
||||||
|
*
|
||||||
|
* @returns The current `PluginHostContext`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function IssueTab() {
|
||||||
|
* const { companyId, entityId } = useHostContext();
|
||||||
|
* const { data } = usePluginData("linear-link", { issueId: entityId });
|
||||||
|
* return <div>{data?.linearIssueUrl}</div>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||||
|
*/
|
||||||
|
export function useHostContext(): PluginHostContext {
|
||||||
|
const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext");
|
||||||
|
return impl();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginStream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a real-time event stream pushed from the plugin worker.
|
||||||
|
*
|
||||||
|
* Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel`
|
||||||
|
* and accumulates events as they arrive. The worker pushes events using
|
||||||
|
* `ctx.streams.emit(channel, event)`.
|
||||||
|
*
|
||||||
|
* @template T The expected shape of each streamed event
|
||||||
|
* @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`)
|
||||||
|
* @param options - Optional configuration for the stream
|
||||||
|
* @returns `PluginStreamResult<T>` with `events`, `lastEvent`, connection status, and `close()`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function ChatMessages() {
|
||||||
|
* const { events, connected, close } = usePluginStream<ChatToken>("chat-stream");
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* {events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||||
|
* {connected && <span className="pulse" />}
|
||||||
|
* <button onClick={close}>Stop</button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||||
|
*/
|
||||||
|
export function usePluginStream<T = unknown>(
|
||||||
|
channel: string,
|
||||||
|
options?: { companyId?: string },
|
||||||
|
): PluginStreamResult<T> {
|
||||||
|
const impl = getSdkUiRuntimeValue<
|
||||||
|
(nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult<T>
|
||||||
|
>("usePluginStream");
|
||||||
|
return impl(channel, options);
|
||||||
|
}
|
||||||
125
packages/plugins/sdk/src/ui/index.ts
Normal file
125
packages/plugins/sdk/src/ui/index.ts
Normal file
@@ -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 <Spinner />;
|
||||||
|
* if (error) return <div>Error: {error.message}</div>;
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <MetricCard label="Synced Issues" value={data!.syncedCount} />
|
||||||
|
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||||
|
* Resync Now
|
||||||
|
* </button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge hooks for plugin UI components to communicate with the plugin worker.
|
||||||
|
*
|
||||||
|
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||||
|
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||||
|
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||||
|
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
usePluginData,
|
||||||
|
usePluginAction,
|
||||||
|
useHostContext,
|
||||||
|
usePluginStream,
|
||||||
|
} 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";
|
||||||
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
type PluginBridgeRegistry = {
|
||||||
|
react?: {
|
||||||
|
createElement?: (type: unknown, props?: Record<string, unknown> | null) => unknown;
|
||||||
|
} | null;
|
||||||
|
sdkUi?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GlobalBridge = typeof globalThis & {
|
||||||
|
__paperclipPluginBridge__?: PluginBridgeRegistry;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBridgeRegistry(): PluginBridgeRegistry | undefined {
|
||||||
|
return (globalThis as GlobalBridge).__paperclipPluginBridge__;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingBridgeValueError(name: string): Error {
|
||||||
|
return new Error(
|
||||||
|
`Paperclip plugin UI runtime is not initialized for "${name}". ` +
|
||||||
|
'Ensure the host loaded the plugin bridge before rendering this UI module.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSdkUiRuntimeValue<T>(name: string): T {
|
||||||
|
const value = getBridgeRegistry()?.sdkUi?.[name];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw missingBridgeValueError(name);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSdkUiComponent<TProps>(
|
||||||
|
name: string,
|
||||||
|
props: TProps,
|
||||||
|
): unknown {
|
||||||
|
const registry = getBridgeRegistry();
|
||||||
|
const component = registry?.sdkUi?.[name];
|
||||||
|
if (component === undefined) {
|
||||||
|
throw missingBridgeValueError(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createElement = registry?.react?.createElement;
|
||||||
|
if (typeof createElement === "function") {
|
||||||
|
return createElement(component, props as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof component === "function") {
|
||||||
|
return component(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Paperclip plugin UI component "${name}" is not callable`);
|
||||||
|
}
|
||||||
358
packages/plugins/sdk/src/ui/types.ts
Normal file
358
packages/plugins/sdk/src/ui/types.ts
Normal file
@@ -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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close lifecycle hooks available when the plugin UI is rendered inside a
|
||||||
|
* host-managed launcher environment.
|
||||||
|
*/
|
||||||
|
export interface PluginRenderCloseLifecycle {
|
||||||
|
/** Register a callback before the host closes the current environment. */
|
||||||
|
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
|
||||||
|
/** Register a callback after the host closes the current environment. */
|
||||||
|
onClose?(handler: PluginRenderCloseHandler): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime information about the host container currently rendering a plugin UI.
|
||||||
|
*/
|
||||||
|
export interface PluginRenderEnvironmentContext
|
||||||
|
extends PluginLauncherRenderContextSnapshot {
|
||||||
|
/** Optional host callback for requesting new bounds while a modal is open. */
|
||||||
|
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
|
||||||
|
/** Optional close lifecycle callbacks for host-managed overlays. */
|
||||||
|
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slot component prop interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin page component.
|
||||||
|
*
|
||||||
|
* A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.1 — Global Operator Routes
|
||||||
|
* @see PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||||
|
*/
|
||||||
|
export interface PluginPageProps {
|
||||||
|
/** The current host context. */
|
||||||
|
context: PluginHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin dashboard widget component.
|
||||||
|
*
|
||||||
|
* A dashboard widget is rendered as a card or section on the main dashboard.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets
|
||||||
|
*/
|
||||||
|
export interface PluginWidgetProps {
|
||||||
|
/** The current host context. */
|
||||||
|
context: PluginHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin detail tab component.
|
||||||
|
*
|
||||||
|
* A detail tab is rendered as an additional tab on a project, issue, agent,
|
||||||
|
* goal, or run detail page.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||||
|
*/
|
||||||
|
export interface PluginDetailTabProps {
|
||||||
|
/** The current host context, always including `entityId` and `entityType`. */
|
||||||
|
context: PluginHostContext & {
|
||||||
|
entityId: string;
|
||||||
|
entityType: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin sidebar component.
|
||||||
|
*
|
||||||
|
* A sidebar entry adds a link or section to the application sidebar.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||||
|
*/
|
||||||
|
export interface PluginSidebarProps {
|
||||||
|
/** The current host context. */
|
||||||
|
context: PluginHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin project sidebar item component.
|
||||||
|
*
|
||||||
|
* A project sidebar item is rendered **once per project** under that project's
|
||||||
|
* row in the sidebar Projects list. The host passes the current project's id
|
||||||
|
* in `context.entityId` and `context.entityType` is `"project"`.
|
||||||
|
*
|
||||||
|
* Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to
|
||||||
|
* the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items
|
||||||
|
*/
|
||||||
|
export interface PluginProjectSidebarItemProps {
|
||||||
|
/** Host context plus entityId (project id) and entityType "project". */
|
||||||
|
context: PluginHostContext & {
|
||||||
|
entityId: string;
|
||||||
|
entityType: "project";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin comment annotation component.
|
||||||
|
*
|
||||||
|
* A comment annotation is rendered below each individual comment in the
|
||||||
|
* issue detail timeline. The host passes the comment ID as `entityId`
|
||||||
|
* and `"comment"` as `entityType`, plus the parent issue ID as
|
||||||
|
* `parentEntityId` so the plugin can scope data fetches to both.
|
||||||
|
*
|
||||||
|
* Use this slot to augment comments with parsed file links, sentiment
|
||||||
|
* badges, inline actions, or any per-comment metadata.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Comment Annotations
|
||||||
|
*/
|
||||||
|
export interface PluginCommentAnnotationProps {
|
||||||
|
/** Host context with comment and parent issue identifiers. */
|
||||||
|
context: PluginHostContext & {
|
||||||
|
/** UUID of the comment being annotated. */
|
||||||
|
entityId: string;
|
||||||
|
/** Always `"comment"` for comment annotation slots. */
|
||||||
|
entityType: "comment";
|
||||||
|
/** UUID of the parent issue containing this comment. */
|
||||||
|
parentEntityId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin comment context menu item component.
|
||||||
|
*
|
||||||
|
* A comment context menu item is rendered in a "more" dropdown menu on
|
||||||
|
* each comment in the issue detail timeline. The host passes the comment
|
||||||
|
* ID as `entityId` and `"comment"` as `entityType`, plus the parent
|
||||||
|
* issue ID as `parentEntityId`.
|
||||||
|
*
|
||||||
|
* Use this slot to add per-comment actions such as "Create sub-issue from
|
||||||
|
* comment", "Translate", "Flag for review", or any custom plugin action.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items
|
||||||
|
*/
|
||||||
|
export interface PluginCommentContextMenuItemProps {
|
||||||
|
/** Host context with comment and parent issue identifiers. */
|
||||||
|
context: PluginHostContext & {
|
||||||
|
/** UUID of the comment this menu item acts on. */
|
||||||
|
entityId: string;
|
||||||
|
/** Always `"comment"` for comment context menu item slots. */
|
||||||
|
entityType: "comment";
|
||||||
|
/** UUID of the parent issue containing this comment. */
|
||||||
|
parentEntityId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin settings page component.
|
||||||
|
*
|
||||||
|
* Overrides the auto-generated JSON Schema form when the plugin declares
|
||||||
|
* a `settingsPage` UI slot. The component is responsible for reading and
|
||||||
|
* writing config through the bridge.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI
|
||||||
|
*/
|
||||||
|
export interface PluginSettingsPageProps {
|
||||||
|
/** The current host context. */
|
||||||
|
context: PluginHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginData hook return type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return value of `usePluginData(key, params)`.
|
||||||
|
*
|
||||||
|
* Mirrors a standard async data-fetching hook pattern:
|
||||||
|
* exactly one of `data` or `error` is non-null at any time (unless `loading`).
|
||||||
|
*
|
||||||
|
* @template T The type of the data returned by the worker handler
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
|
*/
|
||||||
|
export interface PluginDataResult<T = unknown> {
|
||||||
|
/** The data returned by the worker's `getData` handler. `null` while loading or on error. */
|
||||||
|
data: T | null;
|
||||||
|
/** `true` while the initial request or a refresh is in flight. */
|
||||||
|
loading: boolean;
|
||||||
|
/** Bridge error if the request failed. `null` on success or while loading. */
|
||||||
|
error: PluginBridgeError | null;
|
||||||
|
/**
|
||||||
|
* Manually trigger a data refresh.
|
||||||
|
* Useful for poll-based updates or post-action refreshes.
|
||||||
|
*/
|
||||||
|
refresh(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginAction hook return type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginStream hook return type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return value of `usePluginStream<T>(channel)`.
|
||||||
|
*
|
||||||
|
* Provides a growing array of events pushed from the plugin worker via SSE,
|
||||||
|
* plus connection status metadata.
|
||||||
|
*
|
||||||
|
* @template T The type of each event emitted by the worker
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||||
|
*/
|
||||||
|
export interface PluginStreamResult<T = unknown> {
|
||||||
|
/** All events received so far, in arrival order. */
|
||||||
|
events: T[];
|
||||||
|
/** The most recently received event, or `null` if none yet. */
|
||||||
|
lastEvent: T | null;
|
||||||
|
/** `true` while the SSE connection is being established. */
|
||||||
|
connecting: boolean;
|
||||||
|
/** `true` once the SSE connection is open and receiving events. */
|
||||||
|
connected: boolean;
|
||||||
|
/** Error if the SSE connection failed or was interrupted. `null` otherwise. */
|
||||||
|
error: Error | null;
|
||||||
|
/** Close the SSE connection and stop receiving events. */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// usePluginAction hook return type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return value of `usePluginAction(key)`.
|
||||||
|
*
|
||||||
|
* Returns an async function that, when called, sends an action request
|
||||||
|
* to the worker's `performAction` handler and returns the result.
|
||||||
|
*
|
||||||
|
* On failure, the async function throws a `PluginBridgeError`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const resync = usePluginAction("resync");
|
||||||
|
* <button onClick={() => resync({ companyId }).catch(err => console.error(err))}>
|
||||||
|
* Resync Now
|
||||||
|
* </button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;
|
||||||
1221
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
1221
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/plugins/sdk/tsconfig.json
Normal file
9
packages/plugins/sdk/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["node", "react"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"http",
|
"http",
|
||||||
"claude_local",
|
"claude_local",
|
||||||
"codex_local",
|
"codex_local",
|
||||||
"gemini_local",
|
|
||||||
"opencode_local",
|
"opencode_local",
|
||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
@@ -213,6 +212,9 @@ export const LIVE_EVENT_TYPES = [
|
|||||||
"heartbeat.run.log",
|
"heartbeat.run.log",
|
||||||
"agent.status",
|
"agent.status",
|
||||||
"activity.logged",
|
"activity.logged",
|
||||||
|
"plugin.ui.updated",
|
||||||
|
"plugin.worker.crashed",
|
||||||
|
"plugin.worker.restarted",
|
||||||
] as const;
|
] as const;
|
||||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||||
|
|
||||||
@@ -246,3 +248,311 @@ export const PERMISSION_KEYS = [
|
|||||||
"joins:approve",
|
"joins:approve",
|
||||||
] as const;
|
] as const;
|
||||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
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];
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ export {
|
|||||||
JOIN_REQUEST_TYPES,
|
JOIN_REQUEST_TYPES,
|
||||||
JOIN_REQUEST_STATUSES,
|
JOIN_REQUEST_STATUSES,
|
||||||
PERMISSION_KEYS,
|
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 CompanyStatus,
|
||||||
type DeploymentMode,
|
type DeploymentMode,
|
||||||
type DeploymentExposure,
|
type DeploymentExposure,
|
||||||
@@ -61,6 +78,22 @@ export {
|
|||||||
type JoinRequestType,
|
type JoinRequestType,
|
||||||
type JoinRequestStatus,
|
type JoinRequestStatus,
|
||||||
type PermissionKey,
|
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";
|
} from "./constants.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -129,6 +162,28 @@ export type {
|
|||||||
AgentEnvConfig,
|
AgentEnvConfig,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
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";
|
} from "./types/index.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -238,6 +293,45 @@ export {
|
|||||||
type CompanyPortabilityExport,
|
type CompanyPortabilityExport,
|
||||||
type CompanyPortabilityPreview,
|
type CompanyPortabilityPreview,
|
||||||
type CompanyPortabilityImport,
|
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";
|
} from "./validators/index.js";
|
||||||
|
|
||||||
export { API_PREFIX, API } from "./api.js";
|
export { API_PREFIX, API } from "./api.js";
|
||||||
|
|||||||
@@ -79,3 +79,27 @@ export type {
|
|||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
CompanyPortabilityExportRequest,
|
CompanyPortabilityExportRequest,
|
||||||
} from "./company-portability.js";
|
} 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";
|
||||||
|
|||||||
545
packages/shared/src/types/plugin.ts
Normal file
545
packages/shared/src/types/plugin.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a scheduled job a plugin can run.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||||
|
*/
|
||||||
|
export interface PluginJobDeclaration {
|
||||||
|
/** Stable identifier for this job, unique within the plugin. */
|
||||||
|
jobKey: string;
|
||||||
|
/** Human-readable name shown in the operator UI. */
|
||||||
|
displayName: string;
|
||||||
|
/** Optional description of what the job does. */
|
||||||
|
description?: string;
|
||||||
|
/** Cron expression for the schedule (e.g. "star/15 star star star star" or "0 * * * *"). */
|
||||||
|
schedule?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a webhook endpoint the plugin can receive.
|
||||||
|
* Route: `POST /api/plugins/:pluginId/webhooks/:endpointKey`
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||||
|
*/
|
||||||
|
export interface PluginWebhookDeclaration {
|
||||||
|
/** Stable identifier for this endpoint, unique within the plugin. */
|
||||||
|
endpointKey: string;
|
||||||
|
/** Human-readable name shown in the operator UI. */
|
||||||
|
displayName: string;
|
||||||
|
/** Optional description of what this webhook handles. */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares an agent tool contributed by the plugin. Tools are namespaced
|
||||||
|
* by plugin ID at runtime (e.g. `linear:search-issues`).
|
||||||
|
*
|
||||||
|
* Requires the `agent.tools.register` capability.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||||
|
*/
|
||||||
|
export interface PluginToolDeclaration {
|
||||||
|
/** Tool name, unique within the plugin. Namespaced by plugin ID at runtime. */
|
||||||
|
name: string;
|
||||||
|
/** Human-readable name shown to agents and in the UI. */
|
||||||
|
displayName: string;
|
||||||
|
/** Description provided to the agent so it knows when to use this tool. */
|
||||||
|
description: string;
|
||||||
|
/** JSON Schema describing the tool's input parameters. */
|
||||||
|
parametersSchema: JsonSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a UI extension slot the plugin fills with a React component.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||||
|
*/
|
||||||
|
export interface PluginUiSlotDeclaration {
|
||||||
|
/** The type of UI mount point (page, detailTab, taskDetailView, toolbarButton, etc.). */
|
||||||
|
type: PluginUiSlotType;
|
||||||
|
/** Unique slot identifier within the plugin. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable name shown in navigation or tab labels. */
|
||||||
|
displayName: string;
|
||||||
|
/** Which export name in the UI bundle provides this component. */
|
||||||
|
exportName: string;
|
||||||
|
/**
|
||||||
|
* Entity targets for context-sensitive slots.
|
||||||
|
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
|
||||||
|
*/
|
||||||
|
entityTypes?: PluginUiSlotEntityType[];
|
||||||
|
/**
|
||||||
|
* Optional ordering hint within a slot surface. Lower numbers appear first.
|
||||||
|
* Defaults to host-defined ordering if omitted.
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the action triggered by a plugin launcher surface.
|
||||||
|
*/
|
||||||
|
export interface PluginLauncherActionDeclaration {
|
||||||
|
/** What kind of launch behavior the host should perform. */
|
||||||
|
type: PluginLauncherAction;
|
||||||
|
/**
|
||||||
|
* Stable target identifier or URL. The meaning depends on `type`
|
||||||
|
* (for example a route, tab key, action key, or external URL).
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
/** Optional arbitrary parameters passed along to the target. */
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional render metadata for the destination opened by a launcher.
|
||||||
|
*/
|
||||||
|
export interface PluginLauncherRenderDeclaration {
|
||||||
|
/** High-level container the launcher expects the host to use. */
|
||||||
|
environment: PluginLauncherRenderEnvironment;
|
||||||
|
/** Optional size hint for the destination surface. */
|
||||||
|
bounds?: PluginLauncherBounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable runtime snapshot of the host launcher/container environment.
|
||||||
|
*/
|
||||||
|
export interface PluginLauncherRenderContextSnapshot {
|
||||||
|
/** The current launcher/container environment selected by the host. */
|
||||||
|
environment: PluginLauncherRenderEnvironment | null;
|
||||||
|
/** Launcher id that opened this surface, if any. */
|
||||||
|
launcherId: string | null;
|
||||||
|
/** Current host-applied bounds hint for the environment, if any. */
|
||||||
|
bounds: PluginLauncherBounds | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a plugin launcher surface independent of the low-level slot
|
||||||
|
* implementation that mounts it.
|
||||||
|
*/
|
||||||
|
export interface PluginLauncherDeclaration {
|
||||||
|
/** Stable identifier for this launcher, unique within the plugin. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label shown for the launcher. */
|
||||||
|
displayName: string;
|
||||||
|
/** Optional description for operator-facing docs or future UI affordances. */
|
||||||
|
description?: string;
|
||||||
|
/** Where in the host UI this launcher should be placed. */
|
||||||
|
placementZone: PluginLauncherPlacementZone;
|
||||||
|
/** Optional export name in the UI bundle when the launcher has custom UI. */
|
||||||
|
exportName?: string;
|
||||||
|
/**
|
||||||
|
* Optional entity targeting for context-sensitive launcher zones.
|
||||||
|
* Reuses the same entity union as UI slots for consistency.
|
||||||
|
*/
|
||||||
|
entityTypes?: PluginUiSlotEntityType[];
|
||||||
|
/** Optional ordering hint within the placement zone. */
|
||||||
|
order?: number;
|
||||||
|
/** What should happen when the launcher is activated. */
|
||||||
|
action: PluginLauncherActionDeclaration;
|
||||||
|
/** Optional render/container hints for the launched destination. */
|
||||||
|
render?: PluginLauncherRenderDeclaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower-bound semver requirement for the Paperclip host.
|
||||||
|
*
|
||||||
|
* The host should reject installation when its running version is lower than
|
||||||
|
* the declared minimum.
|
||||||
|
*/
|
||||||
|
export type PluginMinimumHostVersion = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups plugin UI declarations that are served from the shared UI bundle
|
||||||
|
* root declared in `entrypoints.ui`.
|
||||||
|
*/
|
||||||
|
export interface PluginUiDeclaration {
|
||||||
|
/** UI extension slots this plugin fills. */
|
||||||
|
slots?: PluginUiSlotDeclaration[];
|
||||||
|
/** Declarative launcher metadata for host-mounted plugin entry points. */
|
||||||
|
launchers?: PluginLauncherDeclaration[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Manifest V1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The manifest shape every plugin package must export.
|
||||||
|
* See PLUGIN_SPEC.md §10.1 for the normative definition.
|
||||||
|
*/
|
||||||
|
export interface PaperclipPluginManifestV1 {
|
||||||
|
/** Globally unique plugin identifier (e.g. `"acme.linear-sync"`). Must be lowercase alphanumeric with dots, hyphens, or underscores. */
|
||||||
|
id: string;
|
||||||
|
/** Plugin API version. Must be `1` for the current spec. */
|
||||||
|
apiVersion: 1;
|
||||||
|
/** Semver version of the plugin package (e.g. `"1.2.0"`). */
|
||||||
|
version: string;
|
||||||
|
/** Human-readable name (max 100 chars). */
|
||||||
|
displayName: string;
|
||||||
|
/** Short description (max 500 chars). */
|
||||||
|
description: string;
|
||||||
|
/** Author name (max 200 chars). May include email in angle brackets, e.g. `"Jane Doe <jane@example.com>"`. */
|
||||||
|
author: string;
|
||||||
|
/** One or more categories classifying this plugin. */
|
||||||
|
categories: PluginCategory[];
|
||||||
|
/**
|
||||||
|
* Minimum host version required (semver lower bound).
|
||||||
|
* Preferred generic field for new manifests.
|
||||||
|
*/
|
||||||
|
minimumHostVersion?: PluginMinimumHostVersion;
|
||||||
|
/**
|
||||||
|
* Legacy alias for `minimumHostVersion`.
|
||||||
|
* Kept for backwards compatibility with existing manifests and docs.
|
||||||
|
*/
|
||||||
|
minimumPaperclipVersion?: PluginMinimumHostVersion;
|
||||||
|
/** Capabilities this plugin requires from the host. Enforced at runtime. */
|
||||||
|
capabilities: PluginCapability[];
|
||||||
|
/** Entrypoint paths relative to the package root. */
|
||||||
|
entrypoints: {
|
||||||
|
/** Path to the worker entrypoint (required). */
|
||||||
|
worker: string;
|
||||||
|
/** Path to the UI bundle directory (required when `ui.slots` is declared). */
|
||||||
|
ui?: string;
|
||||||
|
};
|
||||||
|
/** JSON Schema for operator-editable instance configuration. */
|
||||||
|
instanceConfigSchema?: JsonSchema;
|
||||||
|
/** Scheduled jobs this plugin declares. Requires `jobs.schedule` capability. */
|
||||||
|
jobs?: PluginJobDeclaration[];
|
||||||
|
/** Webhook endpoints this plugin declares. Requires `webhooks.receive` capability. */
|
||||||
|
webhooks?: PluginWebhookDeclaration[];
|
||||||
|
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
|
||||||
|
tools?: PluginToolDeclaration[];
|
||||||
|
/**
|
||||||
|
* Legacy top-level launcher declarations.
|
||||||
|
* Prefer `ui.launchers` for new manifests.
|
||||||
|
*/
|
||||||
|
launchers?: PluginLauncherDeclaration[];
|
||||||
|
/** UI bundle declarations. Requires `entrypoints.ui` when populated. */
|
||||||
|
ui?: PluginUiDeclaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Record – represents a row in the `plugins` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for an installed plugin as persisted in the `plugins` table.
|
||||||
|
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||||
|
*/
|
||||||
|
export interface PluginRecord {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** Unique key derived from `manifest.id`. Used for lookups. */
|
||||||
|
pluginKey: string;
|
||||||
|
/** npm package name (e.g. `"@acme/plugin-linear"`). */
|
||||||
|
packageName: string;
|
||||||
|
/** Installed semver version. */
|
||||||
|
version: string;
|
||||||
|
/** Plugin API version from the manifest. */
|
||||||
|
apiVersion: number;
|
||||||
|
/** Plugin categories from the manifest. */
|
||||||
|
categories: PluginCategory[];
|
||||||
|
/** Full manifest snapshot persisted at install/upgrade time. */
|
||||||
|
manifestJson: PaperclipPluginManifestV1;
|
||||||
|
/** Current lifecycle status. */
|
||||||
|
status: PluginStatus;
|
||||||
|
/** Deterministic load order (null if not yet assigned). */
|
||||||
|
installOrder: number | null;
|
||||||
|
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||||
|
packagePath: string | null;
|
||||||
|
/** Most recent error message, or operator-provided disable reason. */
|
||||||
|
lastError: string | null;
|
||||||
|
/** Timestamp when the plugin was first installed. */
|
||||||
|
installedAt: Date;
|
||||||
|
/** Timestamp of the most recent status or metadata change. */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin State – represents a row in the `plugin_state` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for a single scoped key-value entry in the `plugin_state` table.
|
||||||
|
* Plugins read and write these entries through `ctx.state` in the SDK.
|
||||||
|
*
|
||||||
|
* The five-part composite key `(pluginId, scopeKind, scopeId, namespace, stateKey)`
|
||||||
|
* uniquely identifies a state entry.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||||
|
*/
|
||||||
|
export interface PluginStateRecord {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** FK to `plugins.id`. */
|
||||||
|
pluginId: string;
|
||||||
|
/** Granularity of the scope. */
|
||||||
|
scopeKind: PluginStateScopeKind;
|
||||||
|
/**
|
||||||
|
* UUID or text identifier for the scoped object.
|
||||||
|
* `null` for `instance` scope (no associated entity).
|
||||||
|
*/
|
||||||
|
scopeId: string | null;
|
||||||
|
/**
|
||||||
|
* Sub-namespace within the scope to avoid key collisions.
|
||||||
|
* Defaults to `"default"` if not explicitly set by the plugin.
|
||||||
|
*/
|
||||||
|
namespace: string;
|
||||||
|
/** The key for this state entry within the namespace. */
|
||||||
|
stateKey: string;
|
||||||
|
/** Stored JSON value. May be any JSON-serializable type. */
|
||||||
|
valueJson: unknown;
|
||||||
|
/** Timestamp of the most recent write. */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Config – represents a row in the `plugin_config` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for a plugin's instance configuration as persisted in the
|
||||||
|
* `plugin_config` table.
|
||||||
|
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||||
|
*/
|
||||||
|
export interface PluginConfig {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
|
||||||
|
pluginId: string;
|
||||||
|
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
||||||
|
configJson: Record<string, unknown>;
|
||||||
|
/** Most recent config validation error, if any. */
|
||||||
|
lastError: string | null;
|
||||||
|
/** Timestamp when the config row was created. */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Timestamp of the most recent config update. */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string, unknown>;
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
/** ISO 8601 creation timestamp. */
|
||||||
|
createdAt: Date;
|
||||||
|
/** ISO 8601 last-updated timestamp. */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Job – represents a row in the `plugin_jobs` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for a registered plugin job as persisted in the `plugin_jobs` table.
|
||||||
|
*/
|
||||||
|
export interface PluginJobRecord {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** FK to `plugins.id`. */
|
||||||
|
pluginId: string;
|
||||||
|
/** Job key matching the manifest declaration. */
|
||||||
|
jobKey: string;
|
||||||
|
/** Cron expression for the schedule. */
|
||||||
|
schedule: string;
|
||||||
|
/** Current job status. */
|
||||||
|
status: "active" | "paused" | "failed";
|
||||||
|
/** Last time the job was executed. */
|
||||||
|
lastRunAt: Date | null;
|
||||||
|
/** Next scheduled execution time. */
|
||||||
|
nextRunAt: Date | null;
|
||||||
|
/** ISO 8601 creation timestamp. */
|
||||||
|
createdAt: Date;
|
||||||
|
/** ISO 8601 last-updated timestamp. */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Job Run – represents a row in the `plugin_job_runs` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for a job execution history record.
|
||||||
|
*/
|
||||||
|
export interface PluginJobRunRecord {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** FK to `plugin_jobs.id`. */
|
||||||
|
jobId: string;
|
||||||
|
/** FK to `plugins.id`. */
|
||||||
|
pluginId: string;
|
||||||
|
/** What triggered this run. */
|
||||||
|
trigger: "schedule" | "manual" | "retry";
|
||||||
|
/** Current run status. */
|
||||||
|
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||||
|
/** Run duration in milliseconds. */
|
||||||
|
durationMs: number | null;
|
||||||
|
/** Error message if the run failed. */
|
||||||
|
error: string | null;
|
||||||
|
/** Run logs. */
|
||||||
|
logs: string[];
|
||||||
|
/** ISO 8601 start timestamp. */
|
||||||
|
startedAt: Date | null;
|
||||||
|
/** ISO 8601 finish timestamp. */
|
||||||
|
finishedAt: Date | null;
|
||||||
|
/** ISO 8601 creation timestamp. */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Webhook Delivery – represents a row in the `plugin_webhook_deliveries` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain type for an inbound webhook delivery record.
|
||||||
|
*/
|
||||||
|
export interface PluginWebhookDeliveryRecord {
|
||||||
|
/** UUID primary key. */
|
||||||
|
id: string;
|
||||||
|
/** FK to `plugins.id`. */
|
||||||
|
pluginId: string;
|
||||||
|
/** Webhook endpoint key matching the manifest. */
|
||||||
|
webhookKey: string;
|
||||||
|
/** External identifier from the remote system. */
|
||||||
|
externalId: string | null;
|
||||||
|
/** Delivery status. */
|
||||||
|
status: "pending" | "success" | "failed";
|
||||||
|
/** Processing duration in milliseconds. */
|
||||||
|
durationMs: number | null;
|
||||||
|
/** Error message if processing failed. */
|
||||||
|
error: string | null;
|
||||||
|
/** Webhook payload. */
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
/** Webhook headers. */
|
||||||
|
headers: Record<string, string>;
|
||||||
|
/** ISO 8601 start timestamp. */
|
||||||
|
startedAt: Date | null;
|
||||||
|
/** ISO 8601 finish timestamp. */
|
||||||
|
finishedAt: Date | null;
|
||||||
|
/** ISO 8601 creation timestamp. */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -137,3 +137,45 @@ export {
|
|||||||
type UpdateMemberPermissions,
|
type UpdateMemberPermissions,
|
||||||
type UpdateUserCompanyAccess,
|
type UpdateUserCompanyAccess,
|
||||||
} from "./access.js";
|
} 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";
|
||||||
|
|||||||
694
packages/shared/src/validators/plugin.ts
Normal file
694
packages/shared/src/validators/plugin.ts
Normal file
@@ -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<string, unknown>`
|
||||||
|
* that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`).
|
||||||
|
* Empty objects are also accepted.
|
||||||
|
*
|
||||||
|
* Used to validate `instanceConfigSchema` and `parametersSchema` fields in the
|
||||||
|
* plugin manifest without fully parsing JSON Schema.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||||
|
*/
|
||||||
|
export const jsonSchemaSchema = z.record(z.unknown()).refine(
|
||||||
|
(val) => {
|
||||||
|
// Must have a "type" field if non-empty, or be a valid JSON Schema object
|
||||||
|
if (Object.keys(val).length === 0) return true;
|
||||||
|
return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined;
|
||||||
|
},
|
||||||
|
{ message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manifest sub-type schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a {@link PluginJobDeclaration} — a scheduled job declared in the
|
||||||
|
* plugin manifest. Requires `jobKey` and `displayName`; `description` and
|
||||||
|
* `schedule` (cron expression) are optional.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Validates a cron expression has exactly 5 whitespace-separated fields,
|
||||||
|
* each containing only valid cron characters (digits, *, /, -, ,).
|
||||||
|
*
|
||||||
|
* Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists.
|
||||||
|
*/
|
||||||
|
const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/;
|
||||||
|
|
||||||
|
function isValidCronExpression(expression: string): boolean {
|
||||||
|
const trimmed = expression.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const fields = trimmed.split(/\s+/);
|
||||||
|
if (fields.length !== 5) return false;
|
||||||
|
return fields.every((f) => CRON_FIELD_PATTERN.test(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginJobDeclarationSchema = z.object({
|
||||||
|
jobKey: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
schedule: z.string().refine(
|
||||||
|
(val) => isValidCronExpression(val),
|
||||||
|
{ message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" },
|
||||||
|
).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginJobDeclarationInput = z.infer<typeof pluginJobDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared
|
||||||
|
* in the plugin manifest. Requires `endpointKey` and `displayName`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||||
|
*/
|
||||||
|
export const pluginWebhookDeclarationSchema = z.object({
|
||||||
|
endpointKey: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginWebhookDeclarationInput = z.infer<typeof pluginWebhookDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a {@link PluginToolDeclaration} — an agent tool contributed by the
|
||||||
|
* plugin. Requires `name`, `displayName`, `description`, and a valid
|
||||||
|
* `parametersSchema`. Requires the `agent.tools.register` capability.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||||
|
*/
|
||||||
|
export const pluginToolDeclarationSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
parametersSchema: jsonSchemaSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||||
|
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||||
|
* requirements such as `entityTypes` for context-sensitive slots.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||||
|
*/
|
||||||
|
export const pluginUiSlotDeclarationSchema = z.object({
|
||||||
|
type: z.enum(PLUGIN_UI_SLOT_TYPES),
|
||||||
|
id: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
exportName: z.string().min(1),
|
||||||
|
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||||
|
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<typeof pluginUiSlotDeclarationSchema>;
|
||||||
|
|
||||||
|
const entityScopedLauncherPlacementZones = [
|
||||||
|
"detailTab",
|
||||||
|
"taskDetailView",
|
||||||
|
"contextMenuItem",
|
||||||
|
"commentAnnotation",
|
||||||
|
"commentContextMenuItem",
|
||||||
|
"projectSidebarItem",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const launcherBoundsByEnvironment: Record<
|
||||||
|
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number],
|
||||||
|
readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][]
|
||||||
|
> = {
|
||||||
|
hostInline: ["inline", "compact", "default"],
|
||||||
|
hostOverlay: ["compact", "default", "wide", "full"],
|
||||||
|
hostRoute: ["default", "wide", "full"],
|
||||||
|
external: [],
|
||||||
|
iframe: ["compact", "default", "wide", "full"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the action payload for a declarative plugin launcher.
|
||||||
|
*/
|
||||||
|
export const pluginLauncherActionDeclarationSchema = z.object({
|
||||||
|
type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
|
||||||
|
target: z.string().min(1),
|
||||||
|
params: z.record(z.unknown()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.type === "performAction" && value.target.includes("/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "performAction launchers must target an action key, not a route or URL",
|
||||||
|
path: ["target"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.type === "navigate" && /^https?:\/\//.test(value.target)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "navigate launchers must target a host route, not an absolute URL",
|
||||||
|
path: ["target"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginLauncherActionDeclarationInput =
|
||||||
|
z.infer<typeof pluginLauncherActionDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates optional render hints for a plugin launcher destination.
|
||||||
|
*/
|
||||||
|
export const pluginLauncherRenderDeclarationSchema = z.object({
|
||||||
|
environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS),
|
||||||
|
bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (!value.bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedBounds = launcherBoundsByEnvironment[value.environment];
|
||||||
|
if (!supportedBounds.includes(value.bounds)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`,
|
||||||
|
path: ["bounds"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginLauncherRenderDeclarationInput =
|
||||||
|
z.infer<typeof pluginLauncherRenderDeclarationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates declarative launcher metadata in a plugin manifest.
|
||||||
|
*/
|
||||||
|
export const pluginLauncherDeclarationSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
displayName: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES),
|
||||||
|
exportName: z.string().min(1).optional(),
|
||||||
|
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
action: pluginLauncherActionDeclarationSchema,
|
||||||
|
render: pluginLauncherRenderDeclarationSchema.optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (
|
||||||
|
entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone)
|
||||||
|
&& (!value.entityTypes || value.entityTypes.length === 0)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `${value.placementZone} launchers require at least one entityType`,
|
||||||
|
path: ["entityTypes"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.placementZone === "projectSidebarItem"
|
||||||
|
&& value.entityTypes
|
||||||
|
&& !value.entityTypes.includes("project")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "projectSidebarItem launchers require entityTypes to include \"project\"",
|
||||||
|
path: ["entityTypes"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.action.type === "performAction" && value.render) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "performAction launchers cannot declare render hints",
|
||||||
|
path: ["render"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
["openModal", "openDrawer", "openPopover"].includes(value.action.type)
|
||||||
|
&& !value.render
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `${value.action.type} launchers require render metadata`,
|
||||||
|
path: ["render"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.action.type === "openModal" && value.render?.environment === "hostInline") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "openModal launchers cannot use the hostInline render environment",
|
||||||
|
path: ["render", "environment"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
value.action.type === "openDrawer"
|
||||||
|
&& value.render
|
||||||
|
&& !["hostOverlay", "iframe"].includes(value.render.environment)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "openDrawer launchers must use hostOverlay or iframe render environments",
|
||||||
|
path: ["render", "environment"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "openPopover launchers cannot use the hostRoute render environment",
|
||||||
|
path: ["render", "environment"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin Manifest V1 schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime
|
||||||
|
* validator for plugin manifests read at install time.
|
||||||
|
*
|
||||||
|
* Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules):
|
||||||
|
*
|
||||||
|
* | Field | Type | Constraints |
|
||||||
|
* |--------------------------|------------|----------------------------------------------|
|
||||||
|
* | `id` | string | `^[a-z0-9][a-z0-9._-]*$` |
|
||||||
|
* | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` |
|
||||||
|
* | `version` | string | semver (`\d+\.\d+\.\d+`) |
|
||||||
|
* | `displayName` | string | 1–100 chars |
|
||||||
|
* | `description` | string | 1–500 chars |
|
||||||
|
* | `author` | string | 1–200 chars |
|
||||||
|
* | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES |
|
||||||
|
* | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`|
|
||||||
|
* | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` |
|
||||||
|
* | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES|
|
||||||
|
* | `entrypoints.worker` | string | min 1 char |
|
||||||
|
* | `entrypoints.ui` | string? | required when `ui.slots` is declared |
|
||||||
|
*
|
||||||
|
* Cross-field rules enforced via `superRefine`:
|
||||||
|
* - `entrypoints.ui` required when `ui.slots` declared
|
||||||
|
* - `agent.tools.register` capability required when `tools` declared
|
||||||
|
* - `jobs.schedule` capability required when `jobs` declared
|
||||||
|
* - `webhooks.receive` capability required when `webhooks` declared
|
||||||
|
* - duplicate `jobs[].jobKey` values are rejected
|
||||||
|
* - duplicate `webhooks[].endpointKey` values are rejected
|
||||||
|
* - duplicate `tools[].name` values are rejected
|
||||||
|
* - duplicate `ui.slots[].id` values are rejected
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||||
|
* @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type
|
||||||
|
*/
|
||||||
|
export const pluginManifestV1Schema = z.object({
|
||||||
|
id: z.string().min(1).regex(
|
||||||
|
/^[a-z0-9][a-z0-9._-]*$/,
|
||||||
|
"Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
|
||||||
|
),
|
||||||
|
apiVersion: z.literal(1),
|
||||||
|
version: z.string().min(1).regex(
|
||||||
|
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||||
|
"Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)",
|
||||||
|
),
|
||||||
|
displayName: z.string().min(1).max(100),
|
||||||
|
description: z.string().min(1).max(500),
|
||||||
|
author: z.string().min(1).max(200),
|
||||||
|
categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1),
|
||||||
|
minimumHostVersion: z.string().regex(
|
||||||
|
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||||
|
"minimumHostVersion must follow semver (e.g. 1.0.0)",
|
||||||
|
).optional(),
|
||||||
|
minimumPaperclipVersion: z.string().regex(
|
||||||
|
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||||
|
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
|
||||||
|
).optional(),
|
||||||
|
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
|
||||||
|
entrypoints: z.object({
|
||||||
|
worker: z.string().min(1),
|
||||||
|
ui: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
instanceConfigSchema: jsonSchemaSchema.optional(),
|
||||||
|
jobs: z.array(pluginJobDeclarationSchema).optional(),
|
||||||
|
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
|
||||||
|
tools: z.array(pluginToolDeclarationSchema).optional(),
|
||||||
|
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||||
|
ui: z.object({
|
||||||
|
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||||
|
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||||
|
}).optional(),
|
||||||
|
}).superRefine((manifest, ctx) => {
|
||||||
|
// ── Entrypoint ↔ UI slot consistency ──────────────────────────────────
|
||||||
|
// Plugins that declare UI slots must also declare a UI entrypoint so the
|
||||||
|
// host knows where to load the bundle from (PLUGIN_SPEC.md §10.1).
|
||||||
|
const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0;
|
||||||
|
const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0;
|
||||||
|
if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "entrypoints.ui is required when ui.slots or ui.launchers are declared",
|
||||||
|
path: ["entrypoints", "ui"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
manifest.minimumHostVersion
|
||||||
|
&& manifest.minimumPaperclipVersion
|
||||||
|
&& manifest.minimumHostVersion !== manifest.minimumPaperclipVersion
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared",
|
||||||
|
path: ["minimumHostVersion"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Capability ↔ feature declaration consistency ───────────────────────
|
||||||
|
// The host enforces capabilities at install and runtime. A plugin must
|
||||||
|
// declare every capability it needs up-front; silently having more features
|
||||||
|
// than capabilities would cause runtime rejections.
|
||||||
|
|
||||||
|
// tools require agent.tools.register (PLUGIN_SPEC.md §11)
|
||||||
|
if (manifest.tools && manifest.tools.length > 0) {
|
||||||
|
if (!manifest.capabilities.includes("agent.tools.register")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Capability 'agent.tools.register' is required when tools are declared",
|
||||||
|
path: ["capabilities"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||||
|
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||||
|
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Capability 'jobs.schedule' is required when jobs are declared",
|
||||||
|
path: ["capabilities"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhooks require webhooks.receive (PLUGIN_SPEC.md §18)
|
||||||
|
if (manifest.webhooks && manifest.webhooks.length > 0) {
|
||||||
|
if (!manifest.capabilities.includes("webhooks.receive")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Capability 'webhooks.receive' is required when webhooks are declared",
|
||||||
|
path: ["capabilities"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Uniqueness checks ──────────────────────────────────────────────────
|
||||||
|
// Duplicate keys within a plugin's own manifest are always a bug. The host
|
||||||
|
// would not know which declaration takes precedence, so we reject early.
|
||||||
|
|
||||||
|
// job keys must be unique within the plugin (used as identifiers in the DB)
|
||||||
|
if (manifest.jobs) {
|
||||||
|
const jobKeys = manifest.jobs.map((j) => j.jobKey);
|
||||||
|
const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||||
|
path: ["jobs"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhook endpoint keys must be unique within the plugin (used in routes)
|
||||||
|
if (manifest.webhooks) {
|
||||||
|
const endpointKeys = manifest.webhooks.map((w) => w.endpointKey);
|
||||||
|
const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||||
|
path: ["webhooks"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tool names must be unique within the plugin (namespaced at runtime)
|
||||||
|
if (manifest.tools) {
|
||||||
|
const toolNames = manifest.tools.map((t) => t.name);
|
||||||
|
const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`,
|
||||||
|
path: ["tools"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||||
|
if (manifest.ui) {
|
||||||
|
if (manifest.ui.slots) {
|
||||||
|
const slotIds = manifest.ui.slots.map((s) => s.id);
|
||||||
|
const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||||
|
path: ["ui", "slots"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// launcher ids must be unique within the plugin
|
||||||
|
const allLaunchers = [
|
||||||
|
...(manifest.launchers ?? []),
|
||||||
|
...(manifest.ui?.launchers ?? []),
|
||||||
|
];
|
||||||
|
if (allLaunchers.length > 0) {
|
||||||
|
const launcherIds = allLaunchers.map((launcher) => launcher.id);
|
||||||
|
const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i);
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||||
|
path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginManifestV1Input = z.infer<typeof pluginManifestV1Schema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin installation / registration request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for installing (registering) a plugin.
|
||||||
|
* The server receives the packageName and resolves the manifest from the
|
||||||
|
* installed package.
|
||||||
|
*/
|
||||||
|
export const installPluginSchema = z.object({
|
||||||
|
packageName: z.string().min(1),
|
||||||
|
version: z.string().min(1).optional(),
|
||||||
|
/** Set by loader for local-path installs so the worker can be resolved. */
|
||||||
|
packagePath: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InstallPlugin = z.infer<typeof installPluginSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin config (instance configuration) schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating or updating a plugin's instance configuration.
|
||||||
|
* configJson is validated permissively here; runtime validation against
|
||||||
|
* the plugin's instanceConfigSchema is done at the service layer.
|
||||||
|
*/
|
||||||
|
export const upsertPluginConfigSchema = z.object({
|
||||||
|
configJson: z.record(z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for partially updating a plugin's instance configuration.
|
||||||
|
* Allows a partial merge of config values.
|
||||||
|
*/
|
||||||
|
export const patchPluginConfigSchema = z.object({
|
||||||
|
configJson: z.record(z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<typeof upsertPluginCompanySettingsSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof updateCompanyPluginAvailabilitySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query schema for company plugin availability list endpoints.
|
||||||
|
*/
|
||||||
|
export const listCompanyPluginAvailabilitySchema = z.object({
|
||||||
|
available: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListCompanyPluginAvailability = z.infer<typeof listCompanyPluginAvailabilitySchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin status update
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating a plugin's lifecycle status. Used by the lifecycle
|
||||||
|
* manager to persist state transitions.
|
||||||
|
*
|
||||||
|
* @see {@link PLUGIN_STATUSES} for the valid status values
|
||||||
|
*/
|
||||||
|
export const updatePluginStatusSchema = z.object({
|
||||||
|
status: z.enum(PLUGIN_STATUSES),
|
||||||
|
lastError: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdatePluginStatus = z.infer<typeof updatePluginStatusSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin uninstall
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Schema for the uninstall request. `removeData` controls hard vs soft delete. */
|
||||||
|
export const uninstallPluginSchema = z.object({
|
||||||
|
removeData: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UninstallPlugin = z.infer<typeof uninstallPluginSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin state (key-value storage) schemas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for a plugin state scope key — identifies the exact location where
|
||||||
|
* state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and
|
||||||
|
* `ctx.state.delete()` SDK methods.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 `plugin_state`
|
||||||
|
*/
|
||||||
|
export const pluginStateScopeKeySchema = z.object({
|
||||||
|
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||||
|
scopeId: z.string().min(1).optional(),
|
||||||
|
namespace: z.string().min(1).optional(),
|
||||||
|
stateKey: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PluginStateScopeKey = z.infer<typeof pluginStateScopeKeySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for setting a plugin state value.
|
||||||
|
*/
|
||||||
|
export const setPluginStateSchema = z.object({
|
||||||
|
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||||
|
scopeId: z.string().min(1).optional(),
|
||||||
|
namespace: z.string().min(1).optional(),
|
||||||
|
stateKey: z.string().min(1),
|
||||||
|
/** JSON-serializable value to store. */
|
||||||
|
value: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SetPluginState = z.infer<typeof setPluginStateSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for querying plugin state entries. All fields are optional to allow
|
||||||
|
* flexible list queries (e.g. all state for a plugin within a scope).
|
||||||
|
*/
|
||||||
|
export const listPluginStateSchema = z.object({
|
||||||
|
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(),
|
||||||
|
scopeId: z.string().min(1).optional(),
|
||||||
|
namespace: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListPluginState = z.infer<typeof listPluginStateSchema>;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
packages:
|
packages:
|
||||||
- packages/*
|
- packages/*
|
||||||
- packages/adapters/*
|
- packages/adapters/*
|
||||||
|
- packages/plugins/*
|
||||||
|
- packages/plugins/examples/*
|
||||||
- server
|
- server
|
||||||
- ui
|
- ui
|
||||||
- cli
|
- cli
|
||||||
|
|||||||
46
scripts/ensure-plugin-build-deps.mjs
Normal file
46
scripts/ensure-plugin-build-deps.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = path.resolve(scriptDir, "..");
|
||||||
|
const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc");
|
||||||
|
|
||||||
|
const buildTargets = [
|
||||||
|
{
|
||||||
|
name: "@paperclipai/shared",
|
||||||
|
output: path.join(rootDir, "packages/shared/dist/index.js"),
|
||||||
|
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "@paperclipai/plugin-sdk",
|
||||||
|
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
|
||||||
|
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!fs.existsSync(tscCliPath)) {
|
||||||
|
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of buildTargets) {
|
||||||
|
if (fs.existsSync(target.output)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"postpack": "rm -rf ui-dist",
|
"postpack": "rm -rf ui-dist",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
@@ -43,7 +43,10 @@
|
|||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
|
"@paperclipai/plugin-sdk": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
"ajv": "^8.18.0",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
"better-auth": "1.4.18",
|
"better-auth": "1.4.18",
|
||||||
"detect-port": "^2.1.0",
|
"detect-port": "^2.1.0",
|
||||||
"dotenv": "^17.0.1",
|
"dotenv": "^17.0.1",
|
||||||
|
|||||||
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||||
|
|
||||||
|
describe("plugin-worker-manager stderr failure context", () => {
|
||||||
|
it("appends worker stderr context to failure messages", () => {
|
||||||
|
expect(
|
||||||
|
formatWorkerFailureMessage(
|
||||||
|
"Worker process exited (code=1, signal=null)",
|
||||||
|
"TypeError: Unknown file extension \".ts\"",
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate stderr that is already present", () => {
|
||||||
|
const message = [
|
||||||
|
"Worker process exited (code=1, signal=null)",
|
||||||
|
"",
|
||||||
|
"Worker stderr:",
|
||||||
|
"TypeError: Unknown file extension \".ts\"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
||||||
|
).toBe(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the latest stderr excerpt", () => {
|
||||||
|
let excerpt = "";
|
||||||
|
excerpt = appendStderrExcerpt(excerpt, "first line");
|
||||||
|
excerpt = appendStderrExcerpt(excerpt, "second line");
|
||||||
|
|
||||||
|
expect(excerpt).toContain("first line");
|
||||||
|
expect(excerpt).toContain("second line");
|
||||||
|
|
||||||
|
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
||||||
|
|
||||||
|
expect(excerpt).not.toContain("first line");
|
||||||
|
expect(excerpt).not.toContain("second line");
|
||||||
|
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,23 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
|||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
import { accessRoutes } from "./routes/access.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 { 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";
|
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||||
|
|
||||||
type UiMode = "none" | "static" | "vite-dev";
|
type UiMode = "none" | "static" | "vite-dev";
|
||||||
@@ -41,13 +57,20 @@ export async function createApp(
|
|||||||
bindHost: string;
|
bindHost: string;
|
||||||
authReady: boolean;
|
authReady: boolean;
|
||||||
companyDeletionEnabled: boolean;
|
companyDeletionEnabled: boolean;
|
||||||
|
instanceId?: string;
|
||||||
|
hostVersion?: string;
|
||||||
|
localPluginDir?: string;
|
||||||
betterAuthHandler?: express.RequestHandler;
|
betterAuthHandler?: express.RequestHandler;
|
||||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const app = express();
|
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);
|
app.use(httpLogger);
|
||||||
const privateHostnameGateEnabled =
|
const privateHostnameGateEnabled =
|
||||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
|
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
|
||||||
@@ -114,6 +137,75 @@ export async function createApp(
|
|||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
|
const hostServicesDisposers = new Map<string, () => 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(
|
api.use(
|
||||||
accessRoutes(db, {
|
accessRoutes(db, {
|
||||||
deploymentMode: opts.deploymentMode,
|
deploymentMode: opts.deploymentMode,
|
||||||
@@ -126,6 +218,9 @@ export async function createApp(
|
|||||||
app.use("/api", (_req, res) => {
|
app.use("/api", (_req, res) => {
|
||||||
res.status(404).json({ error: "API route not found" });
|
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));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
if (opts.uiMode === "static") {
|
if (opts.uiMode === "static") {
|
||||||
@@ -179,5 +274,33 @@ export async function createApp(
|
|||||||
|
|
||||||
app.use(errorHandler);
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
496
server/src/routes/plugin-ui-static.ts
Normal file
496
server/src/routes/plugin-ui-static.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Plugin UI static file serving route
|
||||||
|
*
|
||||||
|
* Serves plugin UI bundles from the plugin's dist/ui/ directory under the
|
||||||
|
* `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md
|
||||||
|
* §19.0.3 (Bundle Serving).
|
||||||
|
*
|
||||||
|
* Plugin UI bundles are pre-built ESM that the host serves as static assets.
|
||||||
|
* The host dynamically imports the plugin's UI entry module from this path,
|
||||||
|
* resolves the named export declared in `ui.slots[].exportName`, and mounts
|
||||||
|
* it into the extension slot.
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - Path traversal is prevented by resolving the requested path and verifying
|
||||||
|
* it stays within the plugin's UI directory.
|
||||||
|
* - Only plugins in 'ready' status have their UI served.
|
||||||
|
* - Only plugins that declare `entrypoints.ui` serve UI bundles.
|
||||||
|
*
|
||||||
|
* Cache Headers:
|
||||||
|
* - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`)
|
||||||
|
* receive `Cache-Control: public, max-age=31536000, immutable`.
|
||||||
|
* - Other files receive `Cache-Control: public, max-age=0, must-revalidate`
|
||||||
|
* with ETag-based conditional request support.
|
||||||
|
*
|
||||||
|
* @module server/routes/plugin-ui-static
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex to detect content-hashed filenames.
|
||||||
|
*
|
||||||
|
* Matches patterns like:
|
||||||
|
* - `index-a1b2c3d4.js`
|
||||||
|
* - `styles.abc123def.css`
|
||||||
|
* - `chunk-ABCDEF01.mjs`
|
||||||
|
*
|
||||||
|
* The hash portion must be at least 8 hex characters to avoid false positives.
|
||||||
|
*/
|
||||||
|
const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-Control header for content-hashed files.
|
||||||
|
* These files are immutable by definition (the hash changes when content changes).
|
||||||
|
*/
|
||||||
|
/** 1 year in seconds — standard for content-hashed immutable resources. */
|
||||||
|
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000
|
||||||
|
const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-Control header for non-hashed files.
|
||||||
|
* These files must be revalidated on each request (ETag-based).
|
||||||
|
*/
|
||||||
|
const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME types for common plugin UI bundle file extensions.
|
||||||
|
*/
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".mjs": "application/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a plugin's UI directory from its package location.
|
||||||
|
*
|
||||||
|
* The plugin's `packageName` is stored in the DB. We resolve the package path
|
||||||
|
* from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in
|
||||||
|
* `node_modules`. If the plugin was installed from a local path, the manifest
|
||||||
|
* `entrypoints.ui` path is resolved relative to the package directory.
|
||||||
|
*
|
||||||
|
* @param localPluginDir - The plugin installation directory
|
||||||
|
* @param packageName - The npm package name
|
||||||
|
* @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/")
|
||||||
|
* @returns Absolute path to the UI directory, or null if not found
|
||||||
|
*/
|
||||||
|
export function resolvePluginUiDir(
|
||||||
|
localPluginDir: string,
|
||||||
|
packageName: string,
|
||||||
|
entrypointsUi: string,
|
||||||
|
packagePath?: string | null,
|
||||||
|
): string | null {
|
||||||
|
// For local-path installs, prefer the persisted package path.
|
||||||
|
if (packagePath) {
|
||||||
|
const resolvedPackagePath = path.resolve(packagePath);
|
||||||
|
if (fs.existsSync(resolvedPackagePath)) {
|
||||||
|
const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi);
|
||||||
|
if (
|
||||||
|
uiDirFromPackagePath.startsWith(resolvedPackagePath)
|
||||||
|
&& fs.existsSync(uiDirFromPackagePath)
|
||||||
|
) {
|
||||||
|
return uiDirFromPackagePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the package root within the local plugin directory's node_modules.
|
||||||
|
// npm installs go to <localPluginDir>/node_modules/<packageName>/
|
||||||
|
let packageRoot: string;
|
||||||
|
if (packageName.startsWith("@")) {
|
||||||
|
// Scoped package: @scope/name -> node_modules/@scope/name
|
||||||
|
packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
||||||
|
} else {
|
||||||
|
packageRoot = path.join(localPluginDir, "node_modules", packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the standard location doesn't exist, the plugin may have been installed
|
||||||
|
// from a local path. Try to check if the package.json is accessible at the
|
||||||
|
// computed path or if the package is found elsewhere.
|
||||||
|
if (!fs.existsSync(packageRoot)) {
|
||||||
|
// For local-path installs, the packageName may be a directory that doesn't
|
||||||
|
// live inside node_modules. Check if the package exists directly at the
|
||||||
|
// localPluginDir level.
|
||||||
|
const directPath = path.join(localPluginDir, packageName);
|
||||||
|
if (fs.existsSync(directPath)) {
|
||||||
|
packageRoot = directPath;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the UI directory relative to the package root
|
||||||
|
const uiDir = path.resolve(packageRoot, entrypointsUi);
|
||||||
|
|
||||||
|
// Verify the resolved UI directory exists and is actually inside the package
|
||||||
|
if (!fs.existsSync(uiDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uiDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute an ETag from file stat (size + mtime).
|
||||||
|
* This is a lightweight approach that avoids reading the file content.
|
||||||
|
*/
|
||||||
|
function computeETag(size: number, mtimeMs: number): string {
|
||||||
|
const ETAG_VERSION = "v2";
|
||||||
|
const hash = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
return `"${hash}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the plugin UI static route.
|
||||||
|
*/
|
||||||
|
export interface PluginUiStaticRouteOptions {
|
||||||
|
/**
|
||||||
|
* The local plugin installation directory.
|
||||||
|
* This is where plugins are installed via `npm install --prefix`.
|
||||||
|
* Defaults to the standard `~/.paperclip/plugins/` location.
|
||||||
|
*/
|
||||||
|
localPluginDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Express router that serves plugin UI static files.
|
||||||
|
*
|
||||||
|
* This route handles `GET /_plugins/:pluginId/ui/*` requests by:
|
||||||
|
* 1. Looking up the plugin in the registry by ID or key
|
||||||
|
* 2. Verifying the plugin is in 'ready' status with UI declared
|
||||||
|
* 3. Resolving the file path within the plugin's dist/ui/ directory
|
||||||
|
* 4. Serving the file with appropriate cache headers
|
||||||
|
*
|
||||||
|
* @param db - Database connection for plugin registry lookups
|
||||||
|
* @param options - Configuration options
|
||||||
|
* @returns Express router
|
||||||
|
*/
|
||||||
|
export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) {
|
||||||
|
const router = Router();
|
||||||
|
const registry = pluginRegistryService(db);
|
||||||
|
const log = logger.child({ service: "plugin-ui-static" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /_plugins/:pluginId/ui/*
|
||||||
|
*
|
||||||
|
* Serve a static file from a plugin's UI bundle directory.
|
||||||
|
*
|
||||||
|
* The :pluginId parameter accepts either:
|
||||||
|
* - Database UUID
|
||||||
|
* - Plugin key (e.g., "acme.linear")
|
||||||
|
*
|
||||||
|
* The wildcard captures the relative file path within the UI directory.
|
||||||
|
*
|
||||||
|
* Cache strategy:
|
||||||
|
* - Content-hashed filenames → immutable, 1-year max-age
|
||||||
|
* - Other files → must-revalidate with ETag
|
||||||
|
*/
|
||||||
|
router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => {
|
||||||
|
const { pluginId } = req.params;
|
||||||
|
|
||||||
|
// Extract the relative file path from the named wildcard.
|
||||||
|
// In Express 5 with path-to-regexp v8, named wildcards may return
|
||||||
|
// an array of path segments or a single string.
|
||||||
|
const rawParam = req.params.filePath;
|
||||||
|
const rawFilePath = Array.isArray(rawParam)
|
||||||
|
? rawParam.join("/")
|
||||||
|
: rawParam as string | undefined;
|
||||||
|
|
||||||
|
if (!rawFilePath || rawFilePath.length === 0) {
|
||||||
|
res.status(400).json({ error: "File path is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Look up the plugin
|
||||||
|
let plugin = null;
|
||||||
|
try {
|
||||||
|
plugin = await registry.getById(pluginId);
|
||||||
|
} catch (error) {
|
||||||
|
const maybeCode =
|
||||||
|
typeof error === "object" && error !== null && "code" in error
|
||||||
|
? (error as { code?: unknown }).code
|
||||||
|
: undefined;
|
||||||
|
if (maybeCode !== "22P02") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!plugin) {
|
||||||
|
plugin = await registry.getByKey(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
res.status(404).json({ error: "Plugin not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Verify the plugin is ready and has UI declared
|
||||||
|
if (plugin.status !== "ready") {
|
||||||
|
res.status(403).json({
|
||||||
|
error: `Plugin UI is not available (status: ${plugin.status})`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = plugin.manifestJson;
|
||||||
|
if (!manifest?.entrypoints?.ui) {
|
||||||
|
res.status(404).json({ error: "Plugin does not declare a UI bundle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
|
||||||
|
// when a plugin author has configured a dev server URL for hot-reload.
|
||||||
|
// See PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||||
|
try {
|
||||||
|
const configRow = await registry.getConfig(plugin.id);
|
||||||
|
const devUiUrl =
|
||||||
|
configRow &&
|
||||||
|
typeof configRow === "object" &&
|
||||||
|
"configJson" in configRow &&
|
||||||
|
(configRow as { configJson: Record<string, unknown> }).configJson?.devUiUrl;
|
||||||
|
|
||||||
|
if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
|
||||||
|
// Dev proxy is only available in development mode
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId: plugin.id },
|
||||||
|
"plugin-ui-static: devUiUrl ignored in production",
|
||||||
|
);
|
||||||
|
// Fall through to static file serving below
|
||||||
|
} else {
|
||||||
|
// Guard against rawFilePath overriding the base URL via protocol
|
||||||
|
// scheme (e.g. "https://evil.com/x") or protocol-relative paths
|
||||||
|
// (e.g. "//evil.com/x") which cause `new URL(path, base)` to
|
||||||
|
// ignore the base entirely.
|
||||||
|
// Normalize percent-encoding so encoded slashes (%2F) can't bypass
|
||||||
|
// the protocol/path checks below.
|
||||||
|
let decodedPath: string;
|
||||||
|
try {
|
||||||
|
decodedPath = decodeURIComponent(rawFilePath);
|
||||||
|
} catch {
|
||||||
|
res.status(400).json({ error: "Invalid file path" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
decodedPath.includes("://") ||
|
||||||
|
decodedPath.startsWith("//") ||
|
||||||
|
decodedPath.startsWith("\\\\")
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: "Invalid file path" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy the request to the dev server
|
||||||
|
const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/");
|
||||||
|
|
||||||
|
// SSRF protection: only allow http/https and localhost targets for dev proxy
|
||||||
|
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
||||||
|
res.status(400).json({ error: "devUiUrl must use http or https protocol" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev proxy is restricted to loopback addresses only.
|
||||||
|
// Validate the *constructed* targetUrl hostname (not the base) to
|
||||||
|
// catch any path-based override that slipped past the checks above.
|
||||||
|
const devHost = targetUrl.hostname;
|
||||||
|
const isLoopback =
|
||||||
|
devHost === "localhost" ||
|
||||||
|
devHost === "127.0.0.1" ||
|
||||||
|
devHost === "::1" ||
|
||||||
|
devHost === "[::1]";
|
||||||
|
if (!isLoopback) {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId: plugin.id, devUiUrl, host: devHost },
|
||||||
|
"plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
|
||||||
|
);
|
||||||
|
res.status(400).json({ error: "devUiUrl must target localhost" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
{ pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
|
||||||
|
"plugin-ui-static: proxying to devUiUrl",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(targetUrl.href, { signal: controller.signal });
|
||||||
|
if (!upstream.ok) {
|
||||||
|
res.status(upstream.status).json({
|
||||||
|
error: `Dev server returned ${upstream.status}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = upstream.headers.get("content-type");
|
||||||
|
if (contentType) res.set("Content-Type", contentType);
|
||||||
|
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
|
||||||
|
const body = await upstream.arrayBuffer();
|
||||||
|
res.send(Buffer.from(body));
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch (proxyErr) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
pluginId: plugin.id,
|
||||||
|
devUiUrl,
|
||||||
|
err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr),
|
||||||
|
},
|
||||||
|
"plugin-ui-static: failed to proxy to devUiUrl, falling back to static",
|
||||||
|
);
|
||||||
|
// Fall through to static serving below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config lookup failure is non-fatal — fall through to static serving
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Resolve the plugin's UI directory
|
||||||
|
const uiDir = resolvePluginUiDir(
|
||||||
|
options.localPluginDir,
|
||||||
|
plugin.packageName,
|
||||||
|
manifest.entrypoints.ui,
|
||||||
|
plugin.packagePath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uiDir) {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName },
|
||||||
|
"plugin-ui-static: UI directory not found on disk",
|
||||||
|
);
|
||||||
|
res.status(404).json({ error: "Plugin UI directory not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Resolve the requested file path and prevent traversal (including symlinks)
|
||||||
|
const resolvedFilePath = path.resolve(uiDir, rawFilePath);
|
||||||
|
|
||||||
|
// Step 5: Check that the file exists and is a regular file
|
||||||
|
let fileStat: fs.Stats;
|
||||||
|
try {
|
||||||
|
fileStat = fs.statSync(resolvedFilePath);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: "File not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: resolve symlinks via realpathSync and verify containment.
|
||||||
|
// This prevents symlink-based traversal that string-based startsWith misses.
|
||||||
|
let realFilePath: string;
|
||||||
|
let realUiDir: string;
|
||||||
|
try {
|
||||||
|
realFilePath = fs.realpathSync(resolvedFilePath);
|
||||||
|
realUiDir = fs.realpathSync(uiDir);
|
||||||
|
} catch {
|
||||||
|
res.status(404).json({ error: "File not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = path.relative(realUiDir, realFilePath);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
res.status(403).json({ error: "Access denied" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
res.status(404).json({ error: "File not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Determine cache strategy based on filename
|
||||||
|
const basename = path.basename(resolvedFilePath);
|
||||||
|
const isContentHashed = CONTENT_HASH_PATTERN.test(basename);
|
||||||
|
|
||||||
|
// Step 7: Set cache headers
|
||||||
|
if (isContentHashed) {
|
||||||
|
res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
|
||||||
|
} else {
|
||||||
|
res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);
|
||||||
|
|
||||||
|
// Compute and set ETag for conditional request support
|
||||||
|
const etag = computeETag(fileStat.size, fileStat.mtimeMs);
|
||||||
|
res.set("ETag", etag);
|
||||||
|
|
||||||
|
// Check If-None-Match for 304 Not Modified
|
||||||
|
const ifNoneMatch = req.headers["if-none-match"];
|
||||||
|
if (ifNoneMatch === etag) {
|
||||||
|
res.status(304).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Set Content-Type
|
||||||
|
const ext = path.extname(resolvedFilePath).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext];
|
||||||
|
if (contentType) {
|
||||||
|
res.set("Content-Type", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev)
|
||||||
|
res.set("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
// Step 10: Send the file
|
||||||
|
// The plugin source can live in Git worktrees (e.g. ".worktrees/...").
|
||||||
|
// `send` defaults to dotfiles:"ignore", which treats dot-directories as
|
||||||
|
// not found. We already enforce traversal safety above, so allow dot paths.
|
||||||
|
res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => {
|
||||||
|
if (err) {
|
||||||
|
log.error(
|
||||||
|
{ err, pluginId: plugin.id, filePath: resolvedFilePath },
|
||||||
|
"plugin-ui-static: error sending file",
|
||||||
|
);
|
||||||
|
// Only send error if headers haven't been sent yet
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Failed to serve file" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
2417
server/src/routes/plugins.ts
Normal file
2417
server/src/routes/plugins.ts
Normal file
File diff suppressed because it is too large
Load Diff
373
server/src/services/cron.ts
Normal file
373
server/src/services/cron.ts
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight cron expression parser and next-run calculator.
|
||||||
|
*
|
||||||
|
* Supports standard 5-field cron expressions:
|
||||||
|
*
|
||||||
|
* ┌────────────── minute (0–59)
|
||||||
|
* │ ┌──────────── hour (0–23)
|
||||||
|
* │ │ ┌────────── day of month (1–31)
|
||||||
|
* │ │ │ ┌──────── month (1–12)
|
||||||
|
* │ │ │ │ ┌────── day of week (0–6, Sun=0)
|
||||||
|
* │ │ │ │ │
|
||||||
|
* * * * * *
|
||||||
|
*
|
||||||
|
* Supported syntax per field:
|
||||||
|
* - `*` — any value
|
||||||
|
* - `N` — exact value
|
||||||
|
* - `N-M` — range (inclusive)
|
||||||
|
* - `N/S` — start at N, step S (within field bounds)
|
||||||
|
* - `* /S` — every S (from field min) [no space — shown to avoid comment termination]
|
||||||
|
* - `N-M/S` — range with step
|
||||||
|
* - `N,M,...` — list of values, ranges, or steps
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed cron schedule. Each field is a sorted array of valid integer values
|
||||||
|
* for that field.
|
||||||
|
*/
|
||||||
|
export interface ParsedCron {
|
||||||
|
minutes: number[];
|
||||||
|
hours: number[];
|
||||||
|
daysOfMonth: number[];
|
||||||
|
months: number[];
|
||||||
|
daysOfWeek: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field bounds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FieldSpec {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_SPECS: FieldSpec[] = [
|
||||||
|
{ min: 0, max: 59, name: "minute" },
|
||||||
|
{ min: 0, max: 23, name: "hour" },
|
||||||
|
{ min: 1, max: 31, name: "day of month" },
|
||||||
|
{ min: 1, max: 12, name: "month" },
|
||||||
|
{ min: 0, max: 6, name: "day of week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single cron field token (e.g. `"5"`, `"1-3"`, `"* /10"`, `"1,3,5"`).
|
||||||
|
*
|
||||||
|
* @returns Sorted deduplicated array of matching integer values within bounds.
|
||||||
|
* @throws {Error} on invalid syntax or out-of-range values.
|
||||||
|
*/
|
||||||
|
function parseField(token: string, spec: FieldSpec): number[] {
|
||||||
|
const values = new Set<number>();
|
||||||
|
|
||||||
|
// Split on commas first — each part can be a value, range, or step
|
||||||
|
const parts = token.split(",");
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
throw new Error(`Empty element in cron ${spec.name} field`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for step syntax: "X/S" where X is "*" or a range or a number
|
||||||
|
const slashIdx = trimmed.indexOf("/");
|
||||||
|
if (slashIdx !== -1) {
|
||||||
|
const base = trimmed.slice(0, slashIdx);
|
||||||
|
const stepStr = trimmed.slice(slashIdx + 1);
|
||||||
|
const step = parseInt(stepStr, 10);
|
||||||
|
if (isNaN(step) || step <= 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid step "${stepStr}" in cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rangeStart = spec.min;
|
||||||
|
let rangeEnd = spec.max;
|
||||||
|
|
||||||
|
if (base === "*") {
|
||||||
|
// */S — every S from field min
|
||||||
|
} else if (base.includes("-")) {
|
||||||
|
// N-M/S — range with step
|
||||||
|
const [a, b] = base.split("-").map((s) => parseInt(s, 10));
|
||||||
|
if (isNaN(a!) || isNaN(b!)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range "${base}" in cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rangeStart = a!;
|
||||||
|
rangeEnd = b!;
|
||||||
|
} else {
|
||||||
|
// N/S — start at N, step S
|
||||||
|
const start = parseInt(base, 10);
|
||||||
|
if (isNaN(start)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid start "${base}" in cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rangeStart = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateBounds(rangeStart, spec);
|
||||||
|
validateBounds(rangeEnd, spec);
|
||||||
|
|
||||||
|
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
||||||
|
values.add(i);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for range syntax: "N-M"
|
||||||
|
if (trimmed.includes("-")) {
|
||||||
|
const [aStr, bStr] = trimmed.split("-");
|
||||||
|
const a = parseInt(aStr!, 10);
|
||||||
|
const b = parseInt(bStr!, 10);
|
||||||
|
if (isNaN(a) || isNaN(b)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range "${trimmed}" in cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateBounds(a, spec);
|
||||||
|
validateBounds(b, spec);
|
||||||
|
if (a > b) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid range ${a}-${b} in cron ${spec.name} field (start > end)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (let i = a; i <= b; i++) {
|
||||||
|
values.add(i);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard
|
||||||
|
if (trimmed === "*") {
|
||||||
|
for (let i = spec.min; i <= spec.max; i++) {
|
||||||
|
values.add(i);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single value
|
||||||
|
const val = parseInt(trimmed, 10);
|
||||||
|
if (isNaN(val)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid value "${trimmed}" in cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateBounds(val, spec);
|
||||||
|
values.add(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.size === 0) {
|
||||||
|
throw new Error(`Empty result for cron ${spec.name} field`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...values].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBounds(value: number, spec: FieldSpec): void {
|
||||||
|
if (value < spec.min || value > spec.max) {
|
||||||
|
throw new Error(
|
||||||
|
`Value ${value} out of range [${spec.min}–${spec.max}] for cron ${spec.name} field`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a cron expression string into a structured {@link ParsedCron}.
|
||||||
|
*
|
||||||
|
* @param expression — A standard 5-field cron expression.
|
||||||
|
* @returns Parsed cron with sorted valid values for each field.
|
||||||
|
* @throws {Error} on invalid syntax.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const parsed = parseCron("0 * * * *"); // every hour at minute 0
|
||||||
|
* // parsed.minutes === [0]
|
||||||
|
* // parsed.hours === [0,1,2,...,23]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function parseCron(expression: string): ParsedCron {
|
||||||
|
const trimmed = expression.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Cron expression must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = trimmed.split(/\s+/);
|
||||||
|
if (tokens.length !== 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Cron expression must have exactly 5 fields, got ${tokens.length}: "${trimmed}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minutes: parseField(tokens[0]!, FIELD_SPECS[0]!),
|
||||||
|
hours: parseField(tokens[1]!, FIELD_SPECS[1]!),
|
||||||
|
daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!),
|
||||||
|
months: parseField(tokens[3]!, FIELD_SPECS[3]!),
|
||||||
|
daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a cron expression string. Returns `null` if valid, or an error
|
||||||
|
* message string if invalid.
|
||||||
|
*
|
||||||
|
* @param expression — A cron expression string to validate.
|
||||||
|
* @returns `null` on success, error message on failure.
|
||||||
|
*/
|
||||||
|
export function validateCron(expression: string): string | null {
|
||||||
|
try {
|
||||||
|
parseCron(expression);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next run time after `after` for the given parsed cron schedule.
|
||||||
|
*
|
||||||
|
* Starts from the minute immediately following `after` and walks forward
|
||||||
|
* until a matching minute is found (up to a safety limit of ~4 years to
|
||||||
|
* prevent infinite loops on impossible schedules).
|
||||||
|
*
|
||||||
|
* @param cron — Parsed cron schedule.
|
||||||
|
* @param after — The reference date. The returned date will be strictly after this.
|
||||||
|
* @returns The next matching `Date`, or `null` if no match found within the search window.
|
||||||
|
*/
|
||||||
|
export function nextCronTick(cron: ParsedCron, after: Date): Date | null {
|
||||||
|
// Work in local minutes — start from the minute after `after`
|
||||||
|
const d = new Date(after.getTime());
|
||||||
|
// Advance to the next whole minute
|
||||||
|
d.setUTCSeconds(0, 0);
|
||||||
|
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
||||||
|
|
||||||
|
// Safety: search up to 4 years worth of minutes (~2.1M iterations max).
|
||||||
|
// Uses 366 to account for leap years.
|
||||||
|
const MAX_CRON_SEARCH_YEARS = 4;
|
||||||
|
const maxIterations = MAX_CRON_SEARCH_YEARS * 366 * 24 * 60;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
const month = d.getUTCMonth() + 1; // 1-12
|
||||||
|
const dayOfMonth = d.getUTCDate(); // 1-31
|
||||||
|
const dayOfWeek = d.getUTCDay(); // 0-6
|
||||||
|
const hour = d.getUTCHours(); // 0-23
|
||||||
|
const minute = d.getUTCMinutes(); // 0-59
|
||||||
|
|
||||||
|
// Check month
|
||||||
|
if (!cron.months.includes(month)) {
|
||||||
|
// Skip to the first day of the next matching month
|
||||||
|
advanceToNextMonth(d, cron.months);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check day of month AND day of week (both must match)
|
||||||
|
if (!cron.daysOfMonth.includes(dayOfMonth) || !cron.daysOfWeek.includes(dayOfWeek)) {
|
||||||
|
// Advance one day
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hour
|
||||||
|
if (!cron.hours.includes(hour)) {
|
||||||
|
// Advance to next matching hour within the day
|
||||||
|
const nextHour = findNext(cron.hours, hour);
|
||||||
|
if (nextHour !== null) {
|
||||||
|
d.setUTCHours(nextHour, 0, 0, 0);
|
||||||
|
} else {
|
||||||
|
// No matching hour left today — advance to next day
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minute
|
||||||
|
if (!cron.minutes.includes(minute)) {
|
||||||
|
const nextMin = findNext(cron.minutes, minute);
|
||||||
|
if (nextMin !== null) {
|
||||||
|
d.setUTCMinutes(nextMin, 0, 0);
|
||||||
|
} else {
|
||||||
|
// No matching minute left this hour — advance to next hour
|
||||||
|
d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All fields match!
|
||||||
|
return new Date(d.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match found within the search window
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: parse a cron expression and compute the next run time.
|
||||||
|
*
|
||||||
|
* @param expression — 5-field cron expression string.
|
||||||
|
* @param after — Reference date (defaults to `new Date()`).
|
||||||
|
* @returns The next matching Date, or `null` if no match within 4 years.
|
||||||
|
* @throws {Error} if the cron expression is invalid.
|
||||||
|
*/
|
||||||
|
export function nextCronTickFromExpression(
|
||||||
|
expression: string,
|
||||||
|
after: Date = new Date(),
|
||||||
|
): Date | null {
|
||||||
|
const cron = parseCron(expression);
|
||||||
|
return nextCronTick(cron, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next value in `sortedValues` that is greater than `current`.
|
||||||
|
* Returns `null` if no such value exists.
|
||||||
|
*/
|
||||||
|
function findNext(sortedValues: number[], current: number): number | null {
|
||||||
|
for (const v of sortedValues) {
|
||||||
|
if (v > current) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance `d` (mutated in place) to midnight UTC of the first day of the next
|
||||||
|
* month whose 1-based month number is in `months`.
|
||||||
|
*/
|
||||||
|
function advanceToNextMonth(d: Date, months: number[]): void {
|
||||||
|
let year = d.getUTCFullYear();
|
||||||
|
let month = d.getUTCMonth() + 1; // 1-based
|
||||||
|
|
||||||
|
// Walk months forward until we find one in the set (max 48 iterations = 4 years)
|
||||||
|
for (let i = 0; i < 48; i++) {
|
||||||
|
month++;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
if (months.includes(month)) {
|
||||||
|
d.setUTCFullYear(year, month - 1, 1);
|
||||||
|
d.setUTCHours(0, 0, 0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,21 @@ export function publishLiveEvent(input: {
|
|||||||
return event;
|
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) {
|
export function subscribeCompanyLiveEvents(companyId: string, listener: LiveEventListener) {
|
||||||
emitter.on(companyId, listener);
|
emitter.on(companyId, listener);
|
||||||
return () => emitter.off(companyId, listener);
|
return () => emitter.off(companyId, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeGlobalLiveEvents(listener: LiveEventListener) {
|
||||||
|
emitter.on("*", listener);
|
||||||
|
return () => emitter.off("*", listener);
|
||||||
|
}
|
||||||
|
|||||||
451
server/src/services/plugin-capability-validator.ts
Normal file
451
server/src/services/plugin-capability-validator.ts
Normal file
@@ -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<string, readonly PluginCapability[]> = {
|
||||||
|
// Data read operations
|
||||||
|
"companies.list": ["companies.read"],
|
||||||
|
"companies.get": ["companies.read"],
|
||||||
|
"projects.list": ["projects.read"],
|
||||||
|
"projects.get": ["projects.read"],
|
||||||
|
"project.workspaces.list": ["project.workspaces.read"],
|
||||||
|
"project.workspaces.get": ["project.workspaces.read"],
|
||||||
|
"issues.list": ["issues.read"],
|
||||||
|
"issues.get": ["issues.read"],
|
||||||
|
"issue.comments.list": ["issue.comments.read"],
|
||||||
|
"issue.comments.get": ["issue.comments.read"],
|
||||||
|
"agents.list": ["agents.read"],
|
||||||
|
"agents.get": ["agents.read"],
|
||||||
|
"goals.list": ["goals.read"],
|
||||||
|
"goals.get": ["goals.read"],
|
||||||
|
"activity.list": ["activity.read"],
|
||||||
|
"activity.get": ["activity.read"],
|
||||||
|
"costs.list": ["costs.read"],
|
||||||
|
"costs.get": ["costs.read"],
|
||||||
|
"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<PluginUiSlotType, PluginCapability> = {
|
||||||
|
sidebar: "ui.sidebar.register",
|
||||||
|
sidebarPanel: "ui.sidebar.register",
|
||||||
|
projectSidebarItem: "ui.sidebar.register",
|
||||||
|
page: "ui.page.register",
|
||||||
|
detailTab: "ui.detailTab.register",
|
||||||
|
taskDetailView: "ui.detailTab.register",
|
||||||
|
dashboardWidget: "ui.dashboardWidget.register",
|
||||||
|
toolbarButton: "ui.action.register",
|
||||||
|
contextMenuItem: "ui.action.register",
|
||||||
|
commentAnnotation: "ui.commentAnnotation.register",
|
||||||
|
commentContextMenuItem: "ui.action.register",
|
||||||
|
settingsPage: "instance.settings.register",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launcher placement zones align with host UI surfaces and therefore inherit
|
||||||
|
* the same capability requirements as the equivalent slot type.
|
||||||
|
*/
|
||||||
|
const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||||||
|
PluginLauncherPlacementZone,
|
||||||
|
PluginCapability
|
||||||
|
> = {
|
||||||
|
page: "ui.page.register",
|
||||||
|
detailTab: "ui.detailTab.register",
|
||||||
|
taskDetailView: "ui.detailTab.register",
|
||||||
|
dashboardWidget: "ui.dashboardWidget.register",
|
||||||
|
sidebar: "ui.sidebar.register",
|
||||||
|
sidebarPanel: "ui.sidebar.register",
|
||||||
|
projectSidebarItem: "ui.sidebar.register",
|
||||||
|
toolbarButton: "ui.action.register",
|
||||||
|
contextMenuItem: "ui.action.register",
|
||||||
|
commentAnnotation: "ui.commentAnnotation.register",
|
||||||
|
commentContextMenuItem: "ui.action.register",
|
||||||
|
settingsPage: "instance.settings.register",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps feature declarations in the manifest to their required capabilities.
|
||||||
|
*/
|
||||||
|
const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||||
|
tools: "agent.tools.register",
|
||||||
|
jobs: "jobs.schedule",
|
||||||
|
webhooks: "webhooks.receive",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a capability check. When `allowed` is false, `missing` contains
|
||||||
|
* the capabilities that the plugin does not declare but the operation requires.
|
||||||
|
*/
|
||||||
|
export interface CapabilityCheckResult {
|
||||||
|
allowed: boolean;
|
||||||
|
missing: PluginCapability[];
|
||||||
|
operation?: string;
|
||||||
|
pluginId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PluginCapabilityValidator interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PluginCapabilityValidator {
|
||||||
|
/**
|
||||||
|
* Check whether a plugin has a specific capability.
|
||||||
|
*/
|
||||||
|
hasCapability(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
capability: PluginCapability,
|
||||||
|
): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin has all of the specified capabilities.
|
||||||
|
*/
|
||||||
|
hasAllCapabilities(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
capabilities: PluginCapability[],
|
||||||
|
): CapabilityCheckResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin has at least one of the specified capabilities.
|
||||||
|
*/
|
||||||
|
hasAnyCapability(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
capabilities: PluginCapability[],
|
||||||
|
): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin is allowed to perform the named operation.
|
||||||
|
*
|
||||||
|
* Operations are mapped to required capabilities via OPERATION_CAPABILITIES.
|
||||||
|
* Unknown operations are rejected by default.
|
||||||
|
*/
|
||||||
|
checkOperation(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
operation: string,
|
||||||
|
): CapabilityCheckResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a plugin is allowed to perform an operation.
|
||||||
|
* Throws a 403 HttpError if the capability check fails.
|
||||||
|
*/
|
||||||
|
assertOperation(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
operation: string,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a plugin has a specific capability.
|
||||||
|
* Throws a 403 HttpError if the capability is missing.
|
||||||
|
*/
|
||||||
|
assertCapability(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
capability: PluginCapability,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a plugin can register the given UI slot type.
|
||||||
|
*/
|
||||||
|
checkUiSlot(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
slotType: PluginUiSlotType,
|
||||||
|
): CapabilityCheckResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a manifest's declared capabilities are consistent with its
|
||||||
|
* declared features (tools, jobs, webhooks, UI slots).
|
||||||
|
*
|
||||||
|
* Returns all missing capabilities rather than failing on the first one.
|
||||||
|
* This is useful for install-time validation to give comprehensive feedback.
|
||||||
|
*/
|
||||||
|
validateManifestCapabilities(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
): CapabilityCheckResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the capabilities required for a named operation.
|
||||||
|
* Returns an empty array if the operation is unknown.
|
||||||
|
*/
|
||||||
|
getRequiredCapabilities(operation: string): readonly PluginCapability[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the capability required for a UI slot type.
|
||||||
|
*/
|
||||||
|
getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a PluginCapabilityValidator.
|
||||||
|
*
|
||||||
|
* This service enforces capability gates for plugin operations. The host
|
||||||
|
* uses it to verify that a plugin's declared capabilities permit the
|
||||||
|
* operation it is attempting, both at install time (manifest validation)
|
||||||
|
* and at runtime (bridge call gating).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* const validator = pluginCapabilityValidator();
|
||||||
|
*
|
||||||
|
* // Runtime: gate a bridge call
|
||||||
|
* validator.assertOperation(plugin.manifestJson, "issues.create");
|
||||||
|
*
|
||||||
|
* // Install time: validate manifest consistency
|
||||||
|
* const result = validator.validateManifestCapabilities(manifest);
|
||||||
|
* if (!result.allowed) {
|
||||||
|
* throw badRequest("Missing capabilities", result.missing);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
||||||
|
const log = logger.child({ service: "plugin-capability-validator" });
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function capabilitySet(manifest: PaperclipPluginManifestV1): Set<PluginCapability> {
|
||||||
|
return new Set(manifest.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForbiddenMessage(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
operation: string,
|
||||||
|
missing: PluginCapability[],
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
`Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` +
|
||||||
|
`Missing required capabilities: ${missing.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasCapability(manifest, capability) {
|
||||||
|
return manifest.capabilities.includes(capability);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAllCapabilities(manifest, capabilities) {
|
||||||
|
const declared = capabilitySet(manifest);
|
||||||
|
const missing = capabilities.filter((cap) => !declared.has(cap));
|
||||||
|
return {
|
||||||
|
allowed: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAnyCapability(manifest, capabilities) {
|
||||||
|
const declared = capabilitySet(manifest);
|
||||||
|
return capabilities.some((cap) => declared.has(cap));
|
||||||
|
},
|
||||||
|
|
||||||
|
checkOperation(manifest, operation) {
|
||||||
|
const required = OPERATION_CAPABILITIES[operation];
|
||||||
|
|
||||||
|
if (!required) {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId: manifest.id, operation },
|
||||||
|
"capability check for unknown operation – rejecting by default",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
missing: [],
|
||||||
|
operation,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const declared = capabilitySet(manifest);
|
||||||
|
const missing = required.filter((cap) => !declared.has(cap));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
log.debug(
|
||||||
|
{ pluginId: manifest.id, operation, missing },
|
||||||
|
"capability check failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
operation,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
assertOperation(manifest, operation) {
|
||||||
|
const result = this.checkOperation(manifest, operation);
|
||||||
|
if (!result.allowed) {
|
||||||
|
const msg = result.missing.length > 0
|
||||||
|
? buildForbiddenMessage(manifest, operation, result.missing)
|
||||||
|
: `Plugin '${manifest.id}' attempted unknown operation '${operation}'`;
|
||||||
|
throw forbidden(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
assertCapability(manifest, capability) {
|
||||||
|
if (!this.hasCapability(manifest, capability)) {
|
||||||
|
throw forbidden(
|
||||||
|
`Plugin '${manifest.id}' lacks required capability '${capability}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkUiSlot(manifest, slotType) {
|
||||||
|
const required = UI_SLOT_CAPABILITIES[slotType];
|
||||||
|
if (!required) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
missing: [],
|
||||||
|
operation: `ui.${slotType}.register`,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const has = manifest.capabilities.includes(required);
|
||||||
|
return {
|
||||||
|
allowed: has,
|
||||||
|
missing: has ? [] : [required],
|
||||||
|
operation: `ui.${slotType}.register`,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
validateManifestCapabilities(manifest) {
|
||||||
|
const declared = capabilitySet(manifest);
|
||||||
|
const allMissing: PluginCapability[] = [];
|
||||||
|
|
||||||
|
// Check feature declarations → required capabilities
|
||||||
|
for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) {
|
||||||
|
const featureValue = manifest[feature as keyof PaperclipPluginManifestV1];
|
||||||
|
if (Array.isArray(featureValue) && featureValue.length > 0) {
|
||||||
|
if (!declared.has(requiredCap)) {
|
||||||
|
allMissing.push(requiredCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check UI slots → required capabilities
|
||||||
|
const uiSlots = manifest.ui?.slots ?? [];
|
||||||
|
if (uiSlots.length > 0) {
|
||||||
|
for (const slot of uiSlots) {
|
||||||
|
const requiredCap = UI_SLOT_CAPABILITIES[slot.type];
|
||||||
|
if (requiredCap && !declared.has(requiredCap)) {
|
||||||
|
if (!allMissing.includes(requiredCap)) {
|
||||||
|
allMissing.push(requiredCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check launcher declarations → required capabilities
|
||||||
|
const launchers = [
|
||||||
|
...(manifest.launchers ?? []),
|
||||||
|
...(manifest.ui?.launchers ?? []),
|
||||||
|
];
|
||||||
|
if (launchers.length > 0) {
|
||||||
|
for (const launcher of launchers) {
|
||||||
|
const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone];
|
||||||
|
if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||||||
|
allMissing.push(requiredCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: allMissing.length === 0,
|
||||||
|
missing: allMissing,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getRequiredCapabilities(operation) {
|
||||||
|
return OPERATION_CAPABILITIES[operation] ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getUiSlotCapability(slotType) {
|
||||||
|
return UI_SLOT_CAPABILITIES[slotType];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
50
server/src/services/plugin-config-validator.ts
Normal file
50
server/src/services/plugin-config-validator.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Validates plugin instance configuration against its JSON Schema.
|
||||||
|
*
|
||||||
|
* Uses Ajv to validate `configJson` values against the `instanceConfigSchema`
|
||||||
|
* declared in a plugin's manifest. This ensures that invalid configuration is
|
||||||
|
* rejected at the API boundary, not discovered later at worker startup.
|
||||||
|
*
|
||||||
|
* @module server/services/plugin-config-validator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Ajv, { type ErrorObject } from "ajv";
|
||||||
|
import addFormats from "ajv-formats";
|
||||||
|
import type { JsonSchema } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export interface ConfigValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors?: { field: string; message: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a config object against a JSON Schema.
|
||||||
|
*
|
||||||
|
* @param configJson - The configuration values to validate.
|
||||||
|
* @param schema - The JSON Schema from the plugin manifest's `instanceConfigSchema`.
|
||||||
|
* @returns Validation result with structured field errors on failure.
|
||||||
|
*/
|
||||||
|
export function validateInstanceConfig(
|
||||||
|
configJson: Record<string, unknown>,
|
||||||
|
schema: JsonSchema,
|
||||||
|
): ConfigValidationResult {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||||
|
const ajv = new AjvCtor({ allErrors: true });
|
||||||
|
// ajv-formats v3 default export is a FormatsPlugin object; call it as a plugin.
|
||||||
|
const applyFormats = (addFormats as any).default ?? addFormats;
|
||||||
|
applyFormats(ajv);
|
||||||
|
const validate = ajv.compile(schema);
|
||||||
|
const valid = validate(configJson);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = (validate.errors ?? []).map((err: ErrorObject) => ({
|
||||||
|
field: err.instancePath || "/",
|
||||||
|
message: err.message ?? "validation failed",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { valid: false, errors };
|
||||||
|
}
|
||||||
189
server/src/services/plugin-dev-watcher.ts
Normal file
189
server/src/services/plugin-dev-watcher.ts
Normal file
@@ -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<string | null | undefined>;
|
||||||
|
|
||||||
|
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<string, FSWatcher>();
|
||||||
|
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
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<void> {
|
||||||
|
if (!resolvePluginPackagePath) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packagePath = await resolvePluginPackagePath(pluginId);
|
||||||
|
if (!packagePath) return;
|
||||||
|
watchPlugin(pluginId, packagePath);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"plugin-dev-watcher: failed to resolve plugin package path",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginLoaded(payload: { pluginId: string }): void {
|
||||||
|
void watchLocalPluginById(payload.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginEnabled(payload: { pluginId: string }): void {
|
||||||
|
void watchLocalPluginById(payload.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginDisabled(payload: { pluginId: string }): void {
|
||||||
|
unwatchPlugin(payload.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePluginUnloaded(payload: { pluginId: string }): void {
|
||||||
|
unwatchPlugin(payload.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle.on("plugin.loaded", handlePluginLoaded);
|
||||||
|
lifecycle.on("plugin.enabled", handlePluginEnabled);
|
||||||
|
lifecycle.on("plugin.disabled", handlePluginDisabled);
|
||||||
|
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
watch: watchPlugin,
|
||||||
|
unwatch: unwatchPlugin,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
}
|
||||||
515
server/src/services/plugin-event-bus.ts
Normal file
515
server/src/services/plugin-event-bus.ts
Normal file
@@ -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.<pluginId>.<eventName>`.
|
||||||
|
* - Guard the core namespace: plugins may not emit events with the `plugin.` prefix.
|
||||||
|
* - Isolate subscriptions per plugin — a plugin cannot enumerate or interfere with
|
||||||
|
* another plugin's subscriptions.
|
||||||
|
* - Support wildcard subscriptions via prefix matching (e.g. `plugin.acme.linear.*`).
|
||||||
|
*
|
||||||
|
* The bus operates in-process. In the full out-of-process architecture the host
|
||||||
|
* calls `bus.emit()` after receiving events from the DB/queue layer, and the bus
|
||||||
|
* forwards to handlers that proxy the call to the relevant worker process via IPC.
|
||||||
|
* That IPC layer is separate; this module only handles routing and filtering.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §16 — Event System
|
||||||
|
* @see PLUGIN_SPEC.md §16.1 — Event Filtering
|
||||||
|
* @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginEventType } from "@paperclipai/shared";
|
||||||
|
import type { PluginEvent, EventFilter } from "@paperclipai/plugin-sdk";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registered subscription record stored per plugin.
|
||||||
|
*/
|
||||||
|
interface Subscription {
|
||||||
|
/** The event name or prefix pattern this subscription matches. */
|
||||||
|
eventPattern: string;
|
||||||
|
/** Optional server-side filter applied before delivery. */
|
||||||
|
filter: EventFilter | null;
|
||||||
|
/** Async handler to invoke when a matching event passes the filter. */
|
||||||
|
handler: (event: PluginEvent) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pattern matching helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the event type matches the subscription pattern.
|
||||||
|
*
|
||||||
|
* Matching rules:
|
||||||
|
* - Exact match: `"issue.created"` matches `"issue.created"`.
|
||||||
|
* - Wildcard suffix: `"plugin.acme.*"` matches any event type that starts with
|
||||||
|
* `"plugin.acme."`. The wildcard `*` is only supported as a trailing token.
|
||||||
|
*
|
||||||
|
* No full glob syntax is supported — only trailing `*` after a `.` separator.
|
||||||
|
*/
|
||||||
|
function matchesPattern(eventType: string, pattern: string): boolean {
|
||||||
|
if (pattern === eventType) return true;
|
||||||
|
|
||||||
|
// Trailing wildcard: "plugin.foo.*" → prefix is "plugin.foo."
|
||||||
|
if (pattern.endsWith(".*")) {
|
||||||
|
const prefix = pattern.slice(0, -1); // remove the trailing "*", keep the "."
|
||||||
|
return eventType.startsWith(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the event passes all fields of the filter.
|
||||||
|
* A `null` or empty filter object passes all events.
|
||||||
|
*
|
||||||
|
* **Resolution strategy per field:**
|
||||||
|
*
|
||||||
|
* - `projectId` — checked against `event.entityId` when `entityType === "project"`,
|
||||||
|
* otherwise against `payload.projectId`. This covers both direct project events
|
||||||
|
* (e.g. `project.created`) and secondary events that embed a project reference in
|
||||||
|
* their payload (e.g. `issue.created` with `payload.projectId`).
|
||||||
|
*
|
||||||
|
* - `companyId` — always resolved from `payload.companyId`. Core domain events that
|
||||||
|
* belong to a company embed the company ID in their payload.
|
||||||
|
*
|
||||||
|
* - `agentId` — checked against `event.entityId` when `entityType === "agent"`,
|
||||||
|
* otherwise against `payload.agentId`. Covers both direct agent lifecycle events
|
||||||
|
* (e.g. `agent.created`) and run-level events with `payload.agentId` (e.g.
|
||||||
|
* `agent.run.started`).
|
||||||
|
*
|
||||||
|
* Multiple filter fields are ANDed — all specified fields must match.
|
||||||
|
*/
|
||||||
|
function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
|
||||||
|
if (!filter) return true;
|
||||||
|
|
||||||
|
const payload = event.payload as Record<string, unknown> | null;
|
||||||
|
|
||||||
|
if (filter.projectId !== undefined) {
|
||||||
|
const projectId = event.entityType === "project"
|
||||||
|
? event.entityId
|
||||||
|
: (typeof payload?.projectId === "string" ? payload.projectId : undefined);
|
||||||
|
if (projectId !== filter.projectId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.companyId !== undefined) {
|
||||||
|
if (event.companyId !== filter.companyId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.agentId !== undefined) {
|
||||||
|
const agentId = event.entityType === "agent"
|
||||||
|
? event.entityId
|
||||||
|
: (typeof payload?.agentId === "string" ? payload.agentId : undefined);
|
||||||
|
if (agentId !== filter.agentId) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, Subscription[]>();
|
||||||
|
|
||||||
|
// Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt }
|
||||||
|
const availabilityCache = new Map<string, { enabled: boolean; expiresAt: number }>();
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<PluginEventBusEmitResult> {
|
||||||
|
const errors: Array<{ pluginId: string; error: unknown }> = [];
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// 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<string> | 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<void>),
|
||||||
|
maybeFn?: (event: PluginEvent) => Promise<void>,
|
||||||
|
): void {
|
||||||
|
let filter: EventFilter | null = null;
|
||||||
|
let handler: (event: PluginEvent) => Promise<void>;
|
||||||
|
|
||||||
|
if (typeof fnOrFilter === "function") {
|
||||||
|
handler = fnOrFilter;
|
||||||
|
} else {
|
||||||
|
filter = fnOrFilter;
|
||||||
|
if (!maybeFn) throw new Error("Handler function is required when a filter is provided");
|
||||||
|
handler = maybeFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
subsFor(pluginId).push({ eventPattern, filter, handler });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a plugin-namespaced event. The event type is automatically
|
||||||
|
* prefixed with `plugin.<pluginId>.` so:
|
||||||
|
* - `emit("sync-done", payload)` becomes `"plugin.acme.linear.sync-done"`.
|
||||||
|
*
|
||||||
|
* Requires the `events.emit` capability (enforced by the host layer).
|
||||||
|
*
|
||||||
|
* @throws {Error} if `name` already contains the `plugin.` prefix
|
||||||
|
* (prevents cross-namespace spoofing).
|
||||||
|
*/
|
||||||
|
async emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult> {
|
||||||
|
if (!name || name.trim() === "") {
|
||||||
|
throw new Error(`Plugin "${pluginId}" must provide a non-empty event name.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyId || companyId.trim() === "") {
|
||||||
|
throw new Error(`Plugin "${pluginId}" must provide a companyId when emitting events.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("plugin.")) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin "${pluginId}" must not include the "plugin." prefix when emitting events. ` +
|
||||||
|
`Emit the bare event name (e.g. "sync-done") and the bus will namespace it automatically.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = `plugin.${pluginId}.${name}` as const;
|
||||||
|
const event: PluginEvent = {
|
||||||
|
eventId: crypto.randomUUID(),
|
||||||
|
eventType,
|
||||||
|
companyId,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
actorType: "plugin",
|
||||||
|
actorId: pluginId,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
return emit(event);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Remove all subscriptions registered by this plugin. */
|
||||||
|
clear(): void {
|
||||||
|
clearPlugin(pluginId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
emit,
|
||||||
|
forPlugin,
|
||||||
|
clearPlugin,
|
||||||
|
/** Expose subscription count for a plugin (useful for tests and diagnostics). */
|
||||||
|
subscriptionCount(pluginId?: string): number {
|
||||||
|
if (pluginId !== undefined) {
|
||||||
|
return registry.get(pluginId)?.length ?? 0;
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
for (const subs of registry.values()) total += subs.length;
|
||||||
|
return total;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result returned from `emit()`. Handler errors are collected and returned
|
||||||
|
* rather than thrown so a single misbehaving plugin cannot block delivery to
|
||||||
|
* other plugins.
|
||||||
|
*/
|
||||||
|
export interface PluginEventBusEmitResult {
|
||||||
|
/** Errors thrown by individual handlers, keyed by the plugin that failed. */
|
||||||
|
errors: Array<{ pluginId: string; error: unknown }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full event bus — held by the host process.
|
||||||
|
*
|
||||||
|
* Call `forPlugin(id)` to obtain a `ScopedPluginEventBus` for each plugin worker.
|
||||||
|
*/
|
||||||
|
export interface PluginEventBus {
|
||||||
|
/**
|
||||||
|
* Emit a typed domain event to all matching subscribers.
|
||||||
|
*
|
||||||
|
* Called by the host when a domain event occurs (e.g. from the DB layer or
|
||||||
|
* message queue). All registered subscriptions across all plugins are checked.
|
||||||
|
*/
|
||||||
|
emit(event: PluginEvent): Promise<PluginEventBusEmitResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a scoped handle for a specific plugin worker.
|
||||||
|
*
|
||||||
|
* The scoped handle isolates the plugin's subscriptions and enforces the
|
||||||
|
* plugin namespace on outbound events.
|
||||||
|
*/
|
||||||
|
forPlugin(pluginId: string): ScopedPluginEventBus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all subscriptions for a plugin (called on worker shutdown/uninstall).
|
||||||
|
*/
|
||||||
|
clearPlugin(pluginId: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the total number of active subscriptions, or the count for a
|
||||||
|
* specific plugin if `pluginId` is provided.
|
||||||
|
*/
|
||||||
|
subscriptionCount(pluginId?: string): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin-scoped view of the event bus. Handed to the plugin worker (or its
|
||||||
|
* host-side proxy) during initialisation.
|
||||||
|
*
|
||||||
|
* Plugins use this to:
|
||||||
|
* 1. Subscribe to domain events (with optional server-side filter).
|
||||||
|
* 2. Emit plugin-namespaced events for other plugins to consume.
|
||||||
|
*
|
||||||
|
* Note: `subscribe` overloads mirror the `PluginEventsClient.on()` interface
|
||||||
|
* from the SDK. `emit` intentionally returns `PluginEventBusEmitResult` rather
|
||||||
|
* than `void` so the host layer can inspect handler errors; the SDK-facing
|
||||||
|
* `PluginEventsClient.emit()` wraps this and returns `void`.
|
||||||
|
*/
|
||||||
|
export interface ScopedPluginEventBus {
|
||||||
|
/**
|
||||||
|
* Subscribe to a core domain event or a plugin-namespaced event.
|
||||||
|
*
|
||||||
|
* **Pattern syntax:**
|
||||||
|
* - Exact match: `"issue.created"` — receives only that event type.
|
||||||
|
* - Wildcard suffix: `"plugin.acme.linear.*"` — receives all events emitted by
|
||||||
|
* the `acme.linear` plugin. The `*` is supported only as a trailing token after
|
||||||
|
* a `.` separator; no other glob syntax is supported.
|
||||||
|
* - Top-level plugin wildcard: `"plugin.*"` — receives all plugin-emitted events
|
||||||
|
* regardless of which plugin emitted them.
|
||||||
|
*
|
||||||
|
* Wildcards apply only to the `plugin.*` namespace. Core domain events must be
|
||||||
|
* subscribed to by exact name (e.g. `"issue.created"`, not `"issue.*"`).
|
||||||
|
*
|
||||||
|
* An optional `EventFilter` can be passed as the second argument to perform
|
||||||
|
* server-side pre-filtering; filtered-out events are never delivered to the handler.
|
||||||
|
*/
|
||||||
|
subscribe(
|
||||||
|
eventPattern: PluginEventType | `plugin.${string}`,
|
||||||
|
fn: (event: PluginEvent) => Promise<void>,
|
||||||
|
): void;
|
||||||
|
subscribe(
|
||||||
|
eventPattern: PluginEventType | `plugin.${string}`,
|
||||||
|
filter: EventFilter,
|
||||||
|
fn: (event: PluginEvent) => Promise<void>,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a plugin-namespaced event. The bus automatically prepends
|
||||||
|
* `plugin.<pluginId>.` to the `name`, so passing `"sync-done"` from plugin
|
||||||
|
* `"acme.linear"` produces the event type `"plugin.acme.linear.sync-done"`.
|
||||||
|
*
|
||||||
|
* @param name Bare event name (e.g. `"sync-done"`). Must be non-empty and
|
||||||
|
* must not include the `plugin.` prefix — the bus adds that automatically.
|
||||||
|
* @param companyId UUID of the company this event belongs to.
|
||||||
|
* @param payload Arbitrary JSON-serializable data to attach to the event.
|
||||||
|
*
|
||||||
|
* @throws {Error} if `name` is empty or whitespace-only.
|
||||||
|
* @throws {Error} if `name` starts with `"plugin."` (namespace spoofing guard).
|
||||||
|
*/
|
||||||
|
emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all subscriptions registered by this plugin.
|
||||||
|
*/
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
59
server/src/services/plugin-host-service-cleanup.ts
Normal file
59
server/src/services/plugin-host-service-cleanup.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||||
|
|
||||||
|
type LifecycleLike = Pick<PluginLifecycleManager, "on" | "off">;
|
||||||
|
|
||||||
|
export interface PluginWorkerRuntimeEvent {
|
||||||
|
type: "plugin.worker.crashed" | "plugin.worker.restarted";
|
||||||
|
pluginId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostServiceCleanupController {
|
||||||
|
handleWorkerEvent(event: PluginWorkerRuntimeEvent): void;
|
||||||
|
disposeAll(): void;
|
||||||
|
teardown(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginHostServiceCleanup(
|
||||||
|
lifecycle: LifecycleLike,
|
||||||
|
disposers: Map<string, () => void>,
|
||||||
|
): PluginHostServiceCleanupController {
|
||||||
|
const runDispose = (pluginId: string, remove = false) => {
|
||||||
|
const dispose = disposers.get(pluginId);
|
||||||
|
if (!dispose) return;
|
||||||
|
dispose();
|
||||||
|
if (remove) {
|
||||||
|
disposers.delete(pluginId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWorkerStopped = ({ pluginId }: { pluginId: string }) => {
|
||||||
|
runDispose(pluginId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePluginUnloaded = ({ pluginId }: { pluginId: string }) => {
|
||||||
|
runDispose(pluginId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
lifecycle.on("plugin.worker_stopped", handleWorkerStopped);
|
||||||
|
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleWorkerEvent(event) {
|
||||||
|
if (event.type === "plugin.worker.crashed") {
|
||||||
|
runDispose(event.pluginId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disposeAll() {
|
||||||
|
for (const dispose of disposers.values()) {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
disposers.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
lifecycle.off("plugin.worker_stopped", handleWorkerStopped);
|
||||||
|
lifecycle.off("plugin.unloaded", handlePluginUnloaded);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1077
server/src/services/plugin-host-services.ts
Normal file
1077
server/src/services/plugin-host-services.ts
Normal file
File diff suppressed because it is too large
Load Diff
260
server/src/services/plugin-job-coordinator.ts
Normal file
260
server/src/services/plugin-job-coordinator.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* PluginJobCoordinator — bridges the plugin lifecycle manager with the
|
||||||
|
* job scheduler and job store.
|
||||||
|
*
|
||||||
|
* This service listens to lifecycle events and performs the corresponding
|
||||||
|
* scheduler and job store operations:
|
||||||
|
*
|
||||||
|
* - **plugin.loaded** → sync job declarations from manifest, then register
|
||||||
|
* the plugin with the scheduler (computes `nextRunAt` for active jobs).
|
||||||
|
*
|
||||||
|
* - **plugin.disabled / plugin.unloaded** → unregister the plugin from the
|
||||||
|
* scheduler (cancels in-flight runs, clears tracking state).
|
||||||
|
*
|
||||||
|
* ## Why a separate coordinator?
|
||||||
|
*
|
||||||
|
* The lifecycle manager, scheduler, and job store are independent services
|
||||||
|
* with clean single-responsibility boundaries. The coordinator provides
|
||||||
|
* the "glue" between them without adding coupling. This pattern is used
|
||||||
|
* throughout Paperclip (e.g. heartbeat service coordinates timers + runs).
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||||
|
* @see ./plugin-job-scheduler.ts — Scheduler service
|
||||||
|
* @see ./plugin-job-store.ts — Persistence layer
|
||||||
|
* @see ./plugin-lifecycle.ts — Plugin state machine
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||||
|
import type { PluginJobScheduler } from "./plugin-job-scheduler.js";
|
||||||
|
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||||
|
import { pluginRegistryService } from "./plugin-registry.js";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a PluginJobCoordinator.
|
||||||
|
*/
|
||||||
|
export interface PluginJobCoordinatorOptions {
|
||||||
|
/** Drizzle database instance. */
|
||||||
|
db: Db;
|
||||||
|
/** The plugin lifecycle manager to listen to. */
|
||||||
|
lifecycle: PluginLifecycleManager;
|
||||||
|
/** The job scheduler to register/unregister plugins with. */
|
||||||
|
scheduler: PluginJobScheduler;
|
||||||
|
/** The job store for syncing declarations. */
|
||||||
|
jobStore: PluginJobStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public interface of the job coordinator.
|
||||||
|
*/
|
||||||
|
export interface PluginJobCoordinator {
|
||||||
|
/**
|
||||||
|
* Start listening to lifecycle events.
|
||||||
|
*
|
||||||
|
* This wires up the `plugin.loaded`, `plugin.disabled`, and
|
||||||
|
* `plugin.unloaded` event handlers.
|
||||||
|
*/
|
||||||
|
start(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop listening to lifecycle events.
|
||||||
|
*
|
||||||
|
* Removes all event subscriptions added by `start()`.
|
||||||
|
*/
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a PluginJobCoordinator.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const coordinator = createPluginJobCoordinator({
|
||||||
|
* db,
|
||||||
|
* lifecycle,
|
||||||
|
* scheduler,
|
||||||
|
* jobStore,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Start listening to lifecycle events
|
||||||
|
* coordinator.start();
|
||||||
|
*
|
||||||
|
* // On server shutdown
|
||||||
|
* coordinator.stop();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createPluginJobCoordinator(
|
||||||
|
options: PluginJobCoordinatorOptions,
|
||||||
|
): PluginJobCoordinator {
|
||||||
|
const { db, lifecycle, scheduler, jobStore } = options;
|
||||||
|
const log = logger.child({ service: "plugin-job-coordinator" });
|
||||||
|
const registry = pluginRegistryService(db);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Event handlers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a plugin is loaded (transitions to `ready`):
|
||||||
|
* 1. Look up the manifest from the registry
|
||||||
|
* 2. Sync job declarations from the manifest into the DB
|
||||||
|
* 3. Register the plugin with the scheduler (computes nextRunAt)
|
||||||
|
*/
|
||||||
|
async function onPluginLoaded(payload: { pluginId: string; pluginKey: string }): Promise<void> {
|
||||||
|
const { pluginId, pluginKey } = payload;
|
||||||
|
log.info({ pluginId, pluginKey }, "plugin loaded — syncing jobs and registering with scheduler");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the manifest from the registry
|
||||||
|
const plugin = await registry.getById(pluginId);
|
||||||
|
if (!plugin?.manifestJson) {
|
||||||
|
log.warn({ pluginId, pluginKey }, "plugin loaded but no manifest found — skipping job sync");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync job declarations from the manifest
|
||||||
|
const manifest = plugin.manifestJson;
|
||||||
|
const jobDeclarations = manifest.jobs ?? [];
|
||||||
|
|
||||||
|
if (jobDeclarations.length > 0) {
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey, jobCount: jobDeclarations.length },
|
||||||
|
"syncing job declarations from manifest",
|
||||||
|
);
|
||||||
|
await jobStore.syncJobDeclarations(pluginId, jobDeclarations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with the scheduler (computes nextRunAt for active jobs)
|
||||||
|
await scheduler.registerPlugin(pluginId);
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"failed to sync jobs or register plugin with scheduler",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a plugin is disabled (transitions to `error` with "disabled by
|
||||||
|
* operator" or genuine error): unregister from the scheduler.
|
||||||
|
*/
|
||||||
|
async function onPluginDisabled(payload: {
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
reason?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { pluginId, pluginKey, reason } = payload;
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey, reason },
|
||||||
|
"plugin disabled — unregistering from scheduler",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scheduler.unregisterPlugin(pluginId);
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"failed to unregister plugin from scheduler",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a plugin is unloaded (uninstalled): unregister from the scheduler.
|
||||||
|
*/
|
||||||
|
async function onPluginUnloaded(payload: {
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
removeData: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { pluginId, pluginKey, removeData } = payload;
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey, removeData },
|
||||||
|
"plugin unloaded — unregistering from scheduler",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scheduler.unregisterPlugin(pluginId);
|
||||||
|
|
||||||
|
// If data is being purged, also delete all job definitions and runs
|
||||||
|
if (removeData) {
|
||||||
|
log.info({ pluginId, pluginKey }, "purging job data for uninstalled plugin");
|
||||||
|
await jobStore.deleteAllJobs(pluginId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
pluginKey,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"failed to unregister plugin from scheduler during unload",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
let attached = false;
|
||||||
|
|
||||||
|
// We need stable references for on/off since the lifecycle manager
|
||||||
|
// uses them for matching. We wrap the async handlers in sync wrappers
|
||||||
|
// that fire-and-forget (swallowing unhandled rejections via the try/catch
|
||||||
|
// inside each handler).
|
||||||
|
const boundOnLoaded = (payload: { pluginId: string; pluginKey: string }) => {
|
||||||
|
void onPluginLoaded(payload);
|
||||||
|
};
|
||||||
|
const boundOnDisabled = (payload: { pluginId: string; pluginKey: string; reason?: string }) => {
|
||||||
|
void onPluginDisabled(payload);
|
||||||
|
};
|
||||||
|
const boundOnUnloaded = (payload: { pluginId: string; pluginKey: string; removeData: boolean }) => {
|
||||||
|
void onPluginUnloaded(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
start(): void {
|
||||||
|
if (attached) return;
|
||||||
|
attached = true;
|
||||||
|
|
||||||
|
lifecycle.on("plugin.loaded", boundOnLoaded);
|
||||||
|
lifecycle.on("plugin.disabled", boundOnDisabled);
|
||||||
|
lifecycle.on("plugin.unloaded", boundOnUnloaded);
|
||||||
|
|
||||||
|
log.info("plugin job coordinator started — listening to lifecycle events");
|
||||||
|
},
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!attached) return;
|
||||||
|
attached = false;
|
||||||
|
|
||||||
|
lifecycle.off("plugin.loaded", boundOnLoaded);
|
||||||
|
lifecycle.off("plugin.disabled", boundOnDisabled);
|
||||||
|
lifecycle.off("plugin.unloaded", boundOnUnloaded);
|
||||||
|
|
||||||
|
log.info("plugin job coordinator stopped");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
752
server/src/services/plugin-job-scheduler.ts
Normal file
752
server/src/services/plugin-job-scheduler.ts
Normal file
@@ -0,0 +1,752 @@
|
|||||||
|
/**
|
||||||
|
* PluginJobScheduler — tick-based scheduler for plugin scheduled jobs.
|
||||||
|
*
|
||||||
|
* The scheduler is the central coordinator for all plugin cron jobs. It
|
||||||
|
* periodically ticks (default every 30 seconds), queries the `plugin_jobs`
|
||||||
|
* table for jobs whose `nextRunAt` has passed, dispatches `runJob` RPC calls
|
||||||
|
* to the appropriate worker processes, records each execution in the
|
||||||
|
* `plugin_job_runs` table, and advances the scheduling pointer.
|
||||||
|
*
|
||||||
|
* ## Responsibilities
|
||||||
|
*
|
||||||
|
* 1. **Tick loop** — A `setInterval`-based loop fires every `tickIntervalMs`
|
||||||
|
* (default 30s). Each tick scans for due jobs and dispatches them.
|
||||||
|
*
|
||||||
|
* 2. **Cron parsing & next-run calculation** — Uses the lightweight built-in
|
||||||
|
* cron parser ({@link parseCron}, {@link nextCronTick}) to compute the
|
||||||
|
* `nextRunAt` timestamp after each run or when a new job is registered.
|
||||||
|
*
|
||||||
|
* 3. **Overlap prevention** — Before dispatching a job, the scheduler checks
|
||||||
|
* for an existing `running` run for the same job. If one exists, the job
|
||||||
|
* is skipped for that tick.
|
||||||
|
*
|
||||||
|
* 4. **Job run recording** — Every execution creates a `plugin_job_runs` row:
|
||||||
|
* `queued` → `running` → `succeeded` | `failed`. Duration and error are
|
||||||
|
* captured.
|
||||||
|
*
|
||||||
|
* 5. **Lifecycle integration** — The scheduler exposes `registerPlugin()` and
|
||||||
|
* `unregisterPlugin()` so the host lifecycle manager can wire up job
|
||||||
|
* scheduling when plugins start/stop. On registration, the scheduler
|
||||||
|
* computes `nextRunAt` for all active jobs that don't already have one.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||||
|
* @see ./plugin-job-store.ts — Persistence layer
|
||||||
|
* @see ./cron.ts — Cron parsing utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, lte, or } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||||
|
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||||
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||||
|
import { parseCron, nextCronTick, validateCron } from "./cron.js";
|
||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Default interval between scheduler ticks (30 seconds). */
|
||||||
|
const DEFAULT_TICK_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Default timeout for a runJob RPC call (5 minutes). */
|
||||||
|
const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
|
/** Maximum number of concurrent job executions across all plugins. */
|
||||||
|
const DEFAULT_MAX_CONCURRENT_JOBS = 10;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a PluginJobScheduler.
|
||||||
|
*/
|
||||||
|
export interface PluginJobSchedulerOptions {
|
||||||
|
/** Drizzle database instance. */
|
||||||
|
db: Db;
|
||||||
|
/** Persistence layer for jobs and runs. */
|
||||||
|
jobStore: PluginJobStore;
|
||||||
|
/** Worker process manager for RPC calls. */
|
||||||
|
workerManager: PluginWorkerManager;
|
||||||
|
/** Interval between scheduler ticks in ms (default: 30s). */
|
||||||
|
tickIntervalMs?: number;
|
||||||
|
/** Timeout for individual job RPC calls in ms (default: 5min). */
|
||||||
|
jobTimeoutMs?: number;
|
||||||
|
/** Maximum number of concurrent job executions (default: 10). */
|
||||||
|
maxConcurrentJobs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a manual job trigger.
|
||||||
|
*/
|
||||||
|
export interface TriggerJobResult {
|
||||||
|
/** The created run ID. */
|
||||||
|
runId: string;
|
||||||
|
/** The job ID that was triggered. */
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic information about the scheduler.
|
||||||
|
*/
|
||||||
|
export interface SchedulerDiagnostics {
|
||||||
|
/** Whether the tick loop is running. */
|
||||||
|
running: boolean;
|
||||||
|
/** Number of jobs currently executing. */
|
||||||
|
activeJobCount: number;
|
||||||
|
/** Set of job IDs currently in-flight. */
|
||||||
|
activeJobIds: string[];
|
||||||
|
/** Total number of ticks executed since start. */
|
||||||
|
tickCount: number;
|
||||||
|
/** Timestamp of the last tick (ISO 8601). */
|
||||||
|
lastTickAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public interface of the job scheduler.
|
||||||
|
*/
|
||||||
|
export interface PluginJobScheduler {
|
||||||
|
/**
|
||||||
|
* Start the scheduler tick loop.
|
||||||
|
*
|
||||||
|
* Safe to call multiple times — subsequent calls are no-ops.
|
||||||
|
*/
|
||||||
|
start(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scheduler tick loop.
|
||||||
|
*
|
||||||
|
* In-flight job runs are NOT cancelled — they are allowed to finish
|
||||||
|
* naturally. The tick loop simply stops firing.
|
||||||
|
*/
|
||||||
|
stop(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a plugin with the scheduler.
|
||||||
|
*
|
||||||
|
* Computes `nextRunAt` for all active jobs that are missing it. This is
|
||||||
|
* typically called after a plugin's worker process starts and
|
||||||
|
* `syncJobDeclarations()` has been called.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the plugin
|
||||||
|
*/
|
||||||
|
registerPlugin(pluginId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a plugin from the scheduler.
|
||||||
|
*
|
||||||
|
* Cancels any in-flight runs for the plugin and removes tracking state.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the plugin
|
||||||
|
*/
|
||||||
|
unregisterPlugin(pluginId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually trigger a specific job (outside of the cron schedule).
|
||||||
|
*
|
||||||
|
* Creates a run with `trigger: "manual"` and dispatches immediately,
|
||||||
|
* respecting the overlap prevention check.
|
||||||
|
*
|
||||||
|
* @param jobId - UUID of the job to trigger
|
||||||
|
* @param trigger - What triggered this run (default: "manual")
|
||||||
|
* @returns The created run info
|
||||||
|
* @throws {Error} if the job is not found, not active, or already running
|
||||||
|
*/
|
||||||
|
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single scheduler tick immediately (for testing).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
tick(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get diagnostic information about the scheduler state.
|
||||||
|
*/
|
||||||
|
diagnostics(): SchedulerDiagnostics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new PluginJobScheduler.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const scheduler = createPluginJobScheduler({
|
||||||
|
* db,
|
||||||
|
* jobStore,
|
||||||
|
* workerManager,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Start the tick loop
|
||||||
|
* scheduler.start();
|
||||||
|
*
|
||||||
|
* // When a plugin comes online, register it
|
||||||
|
* await scheduler.registerPlugin(pluginId);
|
||||||
|
*
|
||||||
|
* // Manually trigger a job
|
||||||
|
* const { runId } = await scheduler.triggerJob(jobId);
|
||||||
|
*
|
||||||
|
* // On server shutdown
|
||||||
|
* scheduler.stop();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createPluginJobScheduler(
|
||||||
|
options: PluginJobSchedulerOptions,
|
||||||
|
): PluginJobScheduler {
|
||||||
|
const {
|
||||||
|
db,
|
||||||
|
jobStore,
|
||||||
|
workerManager,
|
||||||
|
tickIntervalMs = DEFAULT_TICK_INTERVAL_MS,
|
||||||
|
jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS,
|
||||||
|
maxConcurrentJobs = DEFAULT_MAX_CONCURRENT_JOBS,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const log = logger.child({ service: "plugin-job-scheduler" });
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Timer handle for the tick loop. */
|
||||||
|
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Whether the scheduler is running. */
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
/** Set of job IDs currently being executed (for overlap prevention). */
|
||||||
|
const activeJobs = new Set<string>();
|
||||||
|
|
||||||
|
/** Total number of ticks since start. */
|
||||||
|
let tickCount = 0;
|
||||||
|
|
||||||
|
/** Timestamp of the last tick. */
|
||||||
|
let lastTickAt: Date | null = null;
|
||||||
|
|
||||||
|
/** Guard against concurrent tick execution. */
|
||||||
|
let tickInProgress = false;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Core: tick
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single scheduler tick. Queries for due jobs and dispatches them.
|
||||||
|
*/
|
||||||
|
async function tick(): Promise<void> {
|
||||||
|
// Prevent overlapping ticks (in case a tick takes longer than the interval)
|
||||||
|
if (tickInProgress) {
|
||||||
|
log.debug("skipping tick — previous tick still in progress");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tickInProgress = true;
|
||||||
|
tickCount++;
|
||||||
|
lastTickAt = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Query for jobs whose nextRunAt has passed and are active.
|
||||||
|
// We include jobs with null nextRunAt since they may have just been
|
||||||
|
// registered and need their first run calculated.
|
||||||
|
const dueJobs = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginJobs.status, "active"),
|
||||||
|
lte(pluginJobs.nextRunAt, now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dueJobs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug({ count: dueJobs.length }, "found due jobs");
|
||||||
|
|
||||||
|
// Dispatch each due job (respecting concurrency limits)
|
||||||
|
const dispatches: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const job of dueJobs) {
|
||||||
|
// Concurrency limit
|
||||||
|
if (activeJobs.size >= maxConcurrentJobs) {
|
||||||
|
log.warn(
|
||||||
|
{ maxConcurrentJobs, activeJobCount: activeJobs.size },
|
||||||
|
"max concurrent jobs reached, deferring remaining jobs",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlap prevention: skip if this job is already running
|
||||||
|
if (activeJobs.has(job.id)) {
|
||||||
|
log.debug(
|
||||||
|
{ jobId: job.id, jobKey: job.jobKey, pluginId: job.pluginId },
|
||||||
|
"skipping job — already running (overlap prevention)",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the worker is available
|
||||||
|
if (!workerManager.isRunning(job.pluginId)) {
|
||||||
|
log.debug(
|
||||||
|
{ jobId: job.id, pluginId: job.pluginId },
|
||||||
|
"skipping job — worker not running",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cron expression before dispatching
|
||||||
|
if (!job.schedule) {
|
||||||
|
log.warn(
|
||||||
|
{ jobId: job.id, jobKey: job.jobKey },
|
||||||
|
"skipping job — no schedule defined",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatches.push(dispatchJob(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatches.length > 0) {
|
||||||
|
await Promise.allSettled(dispatches);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"scheduler tick error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tickInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Core: dispatch a single job
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a single job run — create the run record, call the worker,
|
||||||
|
* record the result, and advance the schedule pointer.
|
||||||
|
*/
|
||||||
|
async function dispatchJob(
|
||||||
|
job: typeof pluginJobs.$inferSelect,
|
||||||
|
): Promise<void> {
|
||||||
|
const { id: jobId, pluginId, jobKey, schedule } = job;
|
||||||
|
const jobLog = log.child({ jobId, pluginId, jobKey });
|
||||||
|
|
||||||
|
// Mark as active (overlap prevention)
|
||||||
|
activeJobs.add(jobId);
|
||||||
|
|
||||||
|
let runId: string | undefined;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create run record
|
||||||
|
const run = await jobStore.createRun({
|
||||||
|
jobId,
|
||||||
|
pluginId,
|
||||||
|
trigger: "schedule",
|
||||||
|
});
|
||||||
|
runId = run.id;
|
||||||
|
|
||||||
|
jobLog.info({ runId }, "dispatching scheduled job");
|
||||||
|
|
||||||
|
// 2. Mark run as running
|
||||||
|
await jobStore.markRunning(runId);
|
||||||
|
|
||||||
|
// 3. Call worker via RPC
|
||||||
|
await workerManager.call(
|
||||||
|
pluginId,
|
||||||
|
"runJob",
|
||||||
|
{
|
||||||
|
job: {
|
||||||
|
jobKey,
|
||||||
|
runId,
|
||||||
|
trigger: "schedule" as const,
|
||||||
|
scheduledAt: (job.nextRunAt ?? new Date()).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
jobTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Mark run as succeeded
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
await jobStore.completeRun(runId, {
|
||||||
|
status: "succeeded",
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
jobLog.info({ runId, durationMs }, "job completed successfully");
|
||||||
|
} catch (err) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
jobLog.error(
|
||||||
|
{ runId, durationMs, err: errorMessage },
|
||||||
|
"job execution failed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record the failure
|
||||||
|
if (runId) {
|
||||||
|
try {
|
||||||
|
await jobStore.completeRun(runId, {
|
||||||
|
status: "failed",
|
||||||
|
error: errorMessage,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
} catch (completeErr) {
|
||||||
|
jobLog.error(
|
||||||
|
{
|
||||||
|
runId,
|
||||||
|
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||||
|
},
|
||||||
|
"failed to record job failure",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Remove from active set
|
||||||
|
activeJobs.delete(jobId);
|
||||||
|
|
||||||
|
// 5. Always advance the schedule pointer (even on failure)
|
||||||
|
try {
|
||||||
|
await advanceSchedulePointer(job);
|
||||||
|
} catch (err) {
|
||||||
|
jobLog.error(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"failed to advance schedule pointer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Core: manual trigger
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function triggerJob(
|
||||||
|
jobId: string,
|
||||||
|
trigger: "manual" | "retry" = "manual",
|
||||||
|
): Promise<TriggerJobResult> {
|
||||||
|
const job = await jobStore.getJobById(jobId);
|
||||||
|
if (!job) {
|
||||||
|
throw new Error(`Job not found: ${jobId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status !== "active") {
|
||||||
|
throw new Error(
|
||||||
|
`Job "${job.jobKey}" is not active (status: ${job.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlap prevention
|
||||||
|
if (activeJobs.has(jobId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Job "${job.jobKey}" is already running — cannot trigger while in progress`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check DB for running runs (defensive — covers multi-instance)
|
||||||
|
const existingRuns = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginJobRuns.jobId, jobId),
|
||||||
|
eq(pluginJobRuns.status, "running"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRuns.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Job "${job.jobKey}" already has a running execution — cannot trigger while in progress`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check worker availability
|
||||||
|
if (!workerManager.isRunning(job.pluginId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Worker for plugin "${job.pluginId}" is not running — cannot trigger job`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the run and dispatch (non-blocking)
|
||||||
|
const run = await jobStore.createRun({
|
||||||
|
jobId,
|
||||||
|
pluginId: job.pluginId,
|
||||||
|
trigger,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch in background — don't block the caller
|
||||||
|
void dispatchManualRun(job, run.id, trigger);
|
||||||
|
|
||||||
|
return { runId: run.id, jobId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a manually triggered job run.
|
||||||
|
*/
|
||||||
|
async function dispatchManualRun(
|
||||||
|
job: typeof pluginJobs.$inferSelect,
|
||||||
|
runId: string,
|
||||||
|
trigger: "manual" | "retry",
|
||||||
|
): Promise<void> {
|
||||||
|
const { id: jobId, pluginId, jobKey } = job;
|
||||||
|
const jobLog = log.child({ jobId, pluginId, jobKey, runId, trigger });
|
||||||
|
|
||||||
|
activeJobs.add(jobId);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobStore.markRunning(runId);
|
||||||
|
|
||||||
|
await workerManager.call(
|
||||||
|
pluginId,
|
||||||
|
"runJob",
|
||||||
|
{
|
||||||
|
job: {
|
||||||
|
jobKey,
|
||||||
|
runId,
|
||||||
|
trigger,
|
||||||
|
scheduledAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
jobTimeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
await jobStore.completeRun(runId, {
|
||||||
|
status: "succeeded",
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
jobLog.info({ durationMs }, "manual job completed successfully");
|
||||||
|
} catch (err) {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
jobLog.error({ durationMs, err: errorMessage }, "manual job failed");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobStore.completeRun(runId, {
|
||||||
|
status: "failed",
|
||||||
|
error: errorMessage,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
} catch (completeErr) {
|
||||||
|
jobLog.error(
|
||||||
|
{
|
||||||
|
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||||
|
},
|
||||||
|
"failed to record manual job failure",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeJobs.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Schedule pointer management
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the `lastRunAt` and `nextRunAt` timestamps on a job after a run.
|
||||||
|
*/
|
||||||
|
async function advanceSchedulePointer(
|
||||||
|
job: typeof pluginJobs.$inferSelect,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
let nextRunAt: Date | null = null;
|
||||||
|
|
||||||
|
if (job.schedule) {
|
||||||
|
const validationError = validateCron(job.schedule);
|
||||||
|
if (validationError) {
|
||||||
|
log.warn(
|
||||||
|
{ jobId: job.id, schedule: job.schedule, error: validationError },
|
||||||
|
"invalid cron schedule — cannot compute next run",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const cron = parseCron(job.schedule);
|
||||||
|
nextRunAt = nextCronTick(cron, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await jobStore.updateRunTimestamps(job.id, now, nextRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all active jobs for a plugin have a `nextRunAt` value.
|
||||||
|
* Called when a plugin is registered with the scheduler.
|
||||||
|
*/
|
||||||
|
async function ensureNextRunTimestamps(pluginId: string): Promise<void> {
|
||||||
|
const jobs = await jobStore.listJobs(pluginId, "active");
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
// Skip jobs that already have a valid nextRunAt in the future
|
||||||
|
if (job.nextRunAt && job.nextRunAt.getTime() > Date.now()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip jobs without a schedule
|
||||||
|
if (!job.schedule) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateCron(job.schedule);
|
||||||
|
if (validationError) {
|
||||||
|
log.warn(
|
||||||
|
{ jobId: job.id, jobKey: job.jobKey, schedule: job.schedule, error: validationError },
|
||||||
|
"skipping job with invalid cron schedule",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cron = parseCron(job.schedule);
|
||||||
|
const nextRunAt = nextCronTick(cron, new Date());
|
||||||
|
|
||||||
|
if (nextRunAt) {
|
||||||
|
await jobStore.updateRunTimestamps(
|
||||||
|
job.id,
|
||||||
|
job.lastRunAt ?? new Date(0),
|
||||||
|
nextRunAt,
|
||||||
|
);
|
||||||
|
log.debug(
|
||||||
|
{ jobId: job.id, jobKey: job.jobKey, nextRunAt: nextRunAt.toISOString() },
|
||||||
|
"computed nextRunAt for job",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Plugin registration
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function registerPlugin(pluginId: string): Promise<void> {
|
||||||
|
log.info({ pluginId }, "registering plugin with job scheduler");
|
||||||
|
await ensureNextRunTimestamps(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregisterPlugin(pluginId: string): Promise<void> {
|
||||||
|
log.info({ pluginId }, "unregistering plugin from job scheduler");
|
||||||
|
|
||||||
|
// Cancel any in-flight run records for this plugin that are still
|
||||||
|
// queued or running. Active jobs in-memory will finish naturally.
|
||||||
|
try {
|
||||||
|
const runningRuns = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginJobRuns.pluginId, pluginId),
|
||||||
|
or(
|
||||||
|
eq(pluginJobRuns.status, "running"),
|
||||||
|
eq(pluginJobRuns.status, "queued"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const run of runningRuns) {
|
||||||
|
await jobStore.completeRun(run.id, {
|
||||||
|
status: "cancelled",
|
||||||
|
error: "Plugin unregistered",
|
||||||
|
durationMs: run.startedAt
|
||||||
|
? Date.now() - run.startedAt.getTime()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
pluginId,
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"error cancelling in-flight runs during unregister",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any active tracking for jobs owned by this plugin
|
||||||
|
const jobs = await jobStore.listJobs(pluginId);
|
||||||
|
for (const job of jobs) {
|
||||||
|
activeJobs.delete(job.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Lifecycle: start / stop
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function start(): void {
|
||||||
|
if (running) {
|
||||||
|
log.debug("scheduler already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
tickTimer = setInterval(() => {
|
||||||
|
void tick();
|
||||||
|
}, tickIntervalMs);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ tickIntervalMs, maxConcurrentJobs },
|
||||||
|
"plugin job scheduler started",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
// Always clear the timer defensively, even if `running` is already false,
|
||||||
|
// to prevent leaked interval timers.
|
||||||
|
if (tickTimer !== null) {
|
||||||
|
clearInterval(tickTimer);
|
||||||
|
tickTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running) return;
|
||||||
|
running = false;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ activeJobCount: activeJobs.size },
|
||||||
|
"plugin job scheduler stopped",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Diagnostics
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function diagnostics(): SchedulerDiagnostics {
|
||||||
|
return {
|
||||||
|
running,
|
||||||
|
activeJobCount: activeJobs.size,
|
||||||
|
activeJobIds: [...activeJobs],
|
||||||
|
tickCount,
|
||||||
|
lastTickAt: lastTickAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
registerPlugin,
|
||||||
|
unregisterPlugin,
|
||||||
|
triggerJob,
|
||||||
|
tick,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
465
server/src/services/plugin-job-store.ts
Normal file
465
server/src/services/plugin-job-store.ts
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* Plugin Job Store — persistence layer for scheduled plugin jobs and their
|
||||||
|
* execution history.
|
||||||
|
*
|
||||||
|
* This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is
|
||||||
|
* the server-side backing store for the `ctx.jobs` SDK surface exposed to
|
||||||
|
* plugin workers.
|
||||||
|
*
|
||||||
|
* ## Responsibilities
|
||||||
|
*
|
||||||
|
* 1. **Sync job declarations** — When a plugin is installed or started, the
|
||||||
|
* host calls `syncJobDeclarations()` to upsert the manifest's declared jobs
|
||||||
|
* into the `plugin_jobs` table. Jobs removed from the manifest are marked
|
||||||
|
* `paused` (not deleted) to preserve history.
|
||||||
|
*
|
||||||
|
* 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin.
|
||||||
|
*
|
||||||
|
* 3. **Run lifecycle** — Create job run records, update their status, and
|
||||||
|
* record results (duration, errors, logs).
|
||||||
|
*
|
||||||
|
* 4. **Next-run calculation** — After a run completes the host should call
|
||||||
|
* `updateNextRunAt()` with the next cron tick so the scheduler knows when
|
||||||
|
* to fire next.
|
||||||
|
*
|
||||||
|
* The capability check (`jobs.schedule`) is enforced upstream by the host
|
||||||
|
* client factory and manifest validator — this store trusts that the caller
|
||||||
|
* has already been authorised.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||||
|
import type {
|
||||||
|
PluginJobDeclaration,
|
||||||
|
PluginJobRunStatus,
|
||||||
|
PluginJobRunTrigger,
|
||||||
|
PluginJobRecord,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { notFound } from "../errors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statuses used for job *definitions* in the `plugin_jobs` table.
|
||||||
|
* Aliased from `PluginJobRecord` to keep the store API aligned with
|
||||||
|
* the domain type (`"active" | "paused" | "failed"`).
|
||||||
|
*/
|
||||||
|
type JobDefinitionStatus = PluginJobRecord["status"];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for creating a job run record.
|
||||||
|
*/
|
||||||
|
export interface CreateJobRunInput {
|
||||||
|
/** FK to the plugin_jobs row. */
|
||||||
|
jobId: string;
|
||||||
|
/** FK to the plugins row. */
|
||||||
|
pluginId: string;
|
||||||
|
/** What triggered this run. */
|
||||||
|
trigger: PluginJobRunTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for completing (or failing) a job run.
|
||||||
|
*/
|
||||||
|
export interface CompleteJobRunInput {
|
||||||
|
/** Final run status. */
|
||||||
|
status: PluginJobRunStatus;
|
||||||
|
/** Error message if the run failed. */
|
||||||
|
error?: string | null;
|
||||||
|
/** Run duration in milliseconds. */
|
||||||
|
durationMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a PluginJobStore backed by the given Drizzle database instance.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const jobStore = pluginJobStore(db);
|
||||||
|
*
|
||||||
|
* // On plugin install/start — sync declared jobs into the DB
|
||||||
|
* await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []);
|
||||||
|
*
|
||||||
|
* // Before dispatching a runJob RPC — create a run record
|
||||||
|
* const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" });
|
||||||
|
*
|
||||||
|
* // After the RPC completes — record the result
|
||||||
|
* await jobStore.completeRun(run.id, {
|
||||||
|
* status: "succeeded",
|
||||||
|
* durationMs: Date.now() - startedAt,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function pluginJobStore(db: Db) {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function assertPluginExists(pluginId: string): Promise<void> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: plugins.id })
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.id, pluginId));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw notFound(`Plugin not found: ${pluginId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// =====================================================================
|
||||||
|
// Job declarations (plugin_jobs)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync declared jobs from a plugin manifest into the `plugin_jobs` table.
|
||||||
|
*
|
||||||
|
* This is called at plugin install and on each worker startup so the DB
|
||||||
|
* always reflects the manifest's declared jobs:
|
||||||
|
*
|
||||||
|
* - **New jobs** are inserted with status `active`.
|
||||||
|
* - **Existing jobs** have their `schedule` updated if it changed.
|
||||||
|
* - **Removed jobs** (present in DB but absent from the manifest) are
|
||||||
|
* set to `paused` so their history is preserved.
|
||||||
|
*
|
||||||
|
* The unique constraint `(pluginId, jobKey)` is used for conflict
|
||||||
|
* resolution.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the owning plugin
|
||||||
|
* @param declarations - Job declarations from the plugin manifest
|
||||||
|
*/
|
||||||
|
async syncJobDeclarations(
|
||||||
|
pluginId: string,
|
||||||
|
declarations: PluginJobDeclaration[],
|
||||||
|
): Promise<void> {
|
||||||
|
await assertPluginExists(pluginId);
|
||||||
|
|
||||||
|
// Fetch existing jobs for this plugin
|
||||||
|
const existingJobs = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(eq(pluginJobs.pluginId, pluginId));
|
||||||
|
|
||||||
|
const existingByKey = new Map(
|
||||||
|
existingJobs.map((j) => [j.jobKey, j]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const declaredKeys = new Set<string>();
|
||||||
|
|
||||||
|
// Upsert each declared job
|
||||||
|
for (const decl of declarations) {
|
||||||
|
declaredKeys.add(decl.jobKey);
|
||||||
|
|
||||||
|
const existing = existingByKey.get(decl.jobKey);
|
||||||
|
const schedule = decl.schedule ?? "";
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update schedule if it changed; re-activate if it was paused
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (existing.schedule !== schedule) {
|
||||||
|
updates.schedule = schedule;
|
||||||
|
}
|
||||||
|
if (existing.status === "paused") {
|
||||||
|
updates.status = "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(pluginJobs)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(pluginJobs.id, existing.id));
|
||||||
|
} else {
|
||||||
|
// Insert new job
|
||||||
|
await db.insert(pluginJobs).values({
|
||||||
|
pluginId,
|
||||||
|
jobKey: decl.jobKey,
|
||||||
|
schedule,
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause jobs that are no longer declared in the manifest
|
||||||
|
for (const existing of existingJobs) {
|
||||||
|
if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") {
|
||||||
|
await db
|
||||||
|
.update(pluginJobs)
|
||||||
|
.set({ status: "paused", updatedAt: new Date() })
|
||||||
|
.where(eq(pluginJobs.id, existing.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all jobs for a plugin, optionally filtered by status.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the owning plugin
|
||||||
|
* @param status - Optional status filter
|
||||||
|
*/
|
||||||
|
async listJobs(
|
||||||
|
pluginId: string,
|
||||||
|
status?: JobDefinitionStatus,
|
||||||
|
): Promise<(typeof pluginJobs.$inferSelect)[]> {
|
||||||
|
const conditions = [eq(pluginJobs.pluginId, pluginId)];
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq(pluginJobs.status, status));
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(and(...conditions));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single job by its composite key `(pluginId, jobKey)`.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the owning plugin
|
||||||
|
* @param jobKey - Stable job identifier from the manifest
|
||||||
|
* @returns The job row, or `null` if not found
|
||||||
|
*/
|
||||||
|
async getJobByKey(
|
||||||
|
pluginId: string,
|
||||||
|
jobKey: string,
|
||||||
|
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(pluginJobs.pluginId, pluginId),
|
||||||
|
eq(pluginJobs.jobKey, jobKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single job by its primary key (UUID).
|
||||||
|
*
|
||||||
|
* @param jobId - UUID of the job row
|
||||||
|
* @returns The job row, or `null` if not found
|
||||||
|
*/
|
||||||
|
async getJobById(
|
||||||
|
jobId: string,
|
||||||
|
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(eq(pluginJobs.id, jobId));
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single job by ID, scoped to a specific plugin.
|
||||||
|
*
|
||||||
|
* Returns `null` if the job does not exist or does not belong to the
|
||||||
|
* given plugin — callers should treat both cases as "not found".
|
||||||
|
*/
|
||||||
|
async getJobByIdForPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
jobId: string,
|
||||||
|
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobs)
|
||||||
|
.where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId)));
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a job's status.
|
||||||
|
*
|
||||||
|
* @param jobId - UUID of the job row
|
||||||
|
* @param status - New status
|
||||||
|
*/
|
||||||
|
async updateJobStatus(
|
||||||
|
jobId: string,
|
||||||
|
status: JobDefinitionStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(pluginJobs)
|
||||||
|
.set({ status, updatedAt: new Date() })
|
||||||
|
.where(eq(pluginJobs.id, jobId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the `lastRunAt` and `nextRunAt` timestamps on a job.
|
||||||
|
*
|
||||||
|
* Called by the scheduler after a run completes to advance the
|
||||||
|
* scheduling pointer.
|
||||||
|
*
|
||||||
|
* @param jobId - UUID of the job row
|
||||||
|
* @param lastRunAt - When the last run started
|
||||||
|
* @param nextRunAt - When the next run should fire
|
||||||
|
*/
|
||||||
|
async updateRunTimestamps(
|
||||||
|
jobId: string,
|
||||||
|
lastRunAt: Date,
|
||||||
|
nextRunAt: Date | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(pluginJobs)
|
||||||
|
.set({
|
||||||
|
lastRunAt,
|
||||||
|
nextRunAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pluginJobs.id, jobId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all jobs (and cascaded runs) owned by a plugin.
|
||||||
|
*
|
||||||
|
* Called during plugin uninstall when `removeData = true`.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the owning plugin
|
||||||
|
*/
|
||||||
|
async deleteAllJobs(pluginId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(pluginJobs)
|
||||||
|
.where(eq(pluginJobs.pluginId, pluginId));
|
||||||
|
},
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Job runs (plugin_job_runs)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job run record with status `queued`.
|
||||||
|
*
|
||||||
|
* The caller should create the run record *before* dispatching the
|
||||||
|
* `runJob` RPC to the worker, then update it to `running` once the
|
||||||
|
* worker begins execution.
|
||||||
|
*
|
||||||
|
* @param input - Job run input (jobId, pluginId, trigger)
|
||||||
|
* @returns The newly created run row
|
||||||
|
*/
|
||||||
|
async createRun(
|
||||||
|
input: CreateJobRunInput,
|
||||||
|
): Promise<typeof pluginJobRuns.$inferSelect> {
|
||||||
|
const rows = await db
|
||||||
|
.insert(pluginJobRuns)
|
||||||
|
.values({
|
||||||
|
jobId: input.jobId,
|
||||||
|
pluginId: input.pluginId,
|
||||||
|
trigger: input.trigger,
|
||||||
|
status: "queued",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return rows[0]!;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a run as `running` and set its `startedAt` timestamp.
|
||||||
|
*
|
||||||
|
* @param runId - UUID of the run row
|
||||||
|
*/
|
||||||
|
async markRunning(runId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(pluginJobRuns)
|
||||||
|
.set({
|
||||||
|
status: "running" as PluginJobRunStatus,
|
||||||
|
startedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pluginJobRuns.id, runId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a run — set its final status, error, duration, and
|
||||||
|
* `finishedAt` timestamp.
|
||||||
|
*
|
||||||
|
* @param runId - UUID of the run row
|
||||||
|
* @param input - Completion details
|
||||||
|
*/
|
||||||
|
async completeRun(
|
||||||
|
runId: string,
|
||||||
|
input: CompleteJobRunInput,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(pluginJobRuns)
|
||||||
|
.set({
|
||||||
|
status: input.status,
|
||||||
|
error: input.error ?? null,
|
||||||
|
durationMs: input.durationMs ?? null,
|
||||||
|
finishedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pluginJobRuns.id, runId));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a run by its primary key.
|
||||||
|
*
|
||||||
|
* @param runId - UUID of the run row
|
||||||
|
* @returns The run row, or `null` if not found
|
||||||
|
*/
|
||||||
|
async getRunById(
|
||||||
|
runId: string,
|
||||||
|
): Promise<(typeof pluginJobRuns.$inferSelect) | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobRuns)
|
||||||
|
.where(eq(pluginJobRuns.id, runId));
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List runs for a specific job, ordered by creation time descending.
|
||||||
|
*
|
||||||
|
* @param jobId - UUID of the job
|
||||||
|
* @param limit - Maximum number of rows to return (default: 50)
|
||||||
|
*/
|
||||||
|
async listRunsByJob(
|
||||||
|
jobId: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobRuns)
|
||||||
|
.where(eq(pluginJobRuns.jobId, jobId))
|
||||||
|
.orderBy(desc(pluginJobRuns.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List runs for a plugin, optionally filtered by status.
|
||||||
|
*
|
||||||
|
* @param pluginId - UUID of the owning plugin
|
||||||
|
* @param status - Optional status filter
|
||||||
|
* @param limit - Maximum number of rows to return (default: 50)
|
||||||
|
*/
|
||||||
|
async listRunsByPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
status?: PluginJobRunStatus,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||||
|
const conditions = [eq(pluginJobRuns.pluginId, pluginId)];
|
||||||
|
if (status) {
|
||||||
|
conditions.push(eq(pluginJobRuns.status, status));
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(pluginJobRuns)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(pluginJobRuns.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type alias for the return value of `pluginJobStore()`. */
|
||||||
|
export type PluginJobStore = ReturnType<typeof pluginJobStore>;
|
||||||
807
server/src/services/plugin-lifecycle.ts
Normal file
807
server/src/services/plugin-lifecycle.ts
Normal file
@@ -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<string, readonly PluginStatus[]> = {
|
||||||
|
installed: ["ready", "error", "uninstalled"],
|
||||||
|
ready: ["ready", "disabled", "error", "upgrade_pending", "uninstalled"],
|
||||||
|
disabled: ["ready", "uninstalled"],
|
||||||
|
error: ["ready", "uninstalled"],
|
||||||
|
upgrade_pending: ["ready", "error", "uninstalled"],
|
||||||
|
uninstalled: ["installed"], // reinstall
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a transition from `from` → `to` is valid.
|
||||||
|
*/
|
||||||
|
function isValidTransition(from: PluginStatus, to: PluginStatus): boolean {
|
||||||
|
return VALID_TRANSITIONS[from]?.includes(to) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by the PluginLifecycleManager.
|
||||||
|
* Consumers can subscribe to these for routing-table updates, UI refresh
|
||||||
|
* notifications, and observability.
|
||||||
|
*/
|
||||||
|
export interface PluginLifecycleEvents {
|
||||||
|
/** Emitted after a plugin is loaded (installed → ready). */
|
||||||
|
"plugin.loaded": { pluginId: string; pluginKey: string };
|
||||||
|
/** Emitted after a plugin transitions to ready (enabled). */
|
||||||
|
"plugin.enabled": { pluginId: string; pluginKey: string };
|
||||||
|
/** Emitted after a plugin is disabled (ready → disabled). */
|
||||||
|
"plugin.disabled": { pluginId: string; pluginKey: string; reason?: string };
|
||||||
|
/** Emitted after a plugin is unloaded (any → uninstalled). */
|
||||||
|
"plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean };
|
||||||
|
/** Emitted on any status change. */
|
||||||
|
"plugin.status_changed": {
|
||||||
|
pluginId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
previousStatus: PluginStatus;
|
||||||
|
newStatus: PluginStatus;
|
||||||
|
};
|
||||||
|
/** Emitted when a plugin enters an error state. */
|
||||||
|
"plugin.error": { pluginId: string; pluginKey: string; error: string };
|
||||||
|
/** Emitted when a plugin enters upgrade_pending. */
|
||||||
|
"plugin.upgrade_pending": { pluginId: string; pluginKey: string };
|
||||||
|
/** Emitted when a plugin worker process has been started. */
|
||||||
|
"plugin.worker_started": { pluginId: string; pluginKey: string };
|
||||||
|
/** Emitted when a plugin worker process has been stopped. */
|
||||||
|
"plugin.worker_stopped": { pluginId: string; pluginKey: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleEventName = keyof PluginLifecycleEvents;
|
||||||
|
type LifecycleEventPayload<K extends LifecycleEventName> = PluginLifecycleEvents[K];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PluginLifecycleManager
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PluginLifecycleManager {
|
||||||
|
/**
|
||||||
|
* Load a newly installed plugin – transitions `installed` → `ready`.
|
||||||
|
*
|
||||||
|
* This is called after the registry has persisted the initial install record.
|
||||||
|
* The caller should have already spawned the worker and performed health
|
||||||
|
* checks before calling this. If the worker fails, call `markError` instead.
|
||||||
|
*/
|
||||||
|
load(pluginId: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state.
|
||||||
|
* Transitions → `ready`.
|
||||||
|
*/
|
||||||
|
enable(pluginId: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a running plugin.
|
||||||
|
* Transitions `ready` → `disabled`.
|
||||||
|
*/
|
||||||
|
disable(pluginId: string, reason?: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload (uninstall) a plugin from any active state.
|
||||||
|
* Transitions → `uninstalled`.
|
||||||
|
*
|
||||||
|
* When `removeData` is true, the plugin row and cascaded config are
|
||||||
|
* hard-deleted. Otherwise a soft-delete sets status to `uninstalled`.
|
||||||
|
*/
|
||||||
|
unload(pluginId: string, removeData?: boolean): Promise<PluginRecord | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a plugin as errored (e.g. worker crash, health-check failure).
|
||||||
|
* Transitions → `error`.
|
||||||
|
*/
|
||||||
|
markError(pluginId: string, error: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a plugin as requiring upgrade approval.
|
||||||
|
* Transitions `ready` → `upgrade_pending`.
|
||||||
|
*/
|
||||||
|
markUpgradePending(pluginId: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade a plugin to a newer version.
|
||||||
|
* This is a placeholder that handles the lifecycle state transition.
|
||||||
|
* The actual package installation is handled by plugin-loader.
|
||||||
|
*
|
||||||
|
* If the upgrade adds new capabilities, transitions to `upgrade_pending`.
|
||||||
|
* Otherwise, transitions to `ready` directly.
|
||||||
|
*/
|
||||||
|
upgrade(pluginId: string, version?: string): Promise<PluginRecord>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the worker process for a plugin that is already in `ready` state.
|
||||||
|
*
|
||||||
|
* This is used by the server startup orchestration to start workers for
|
||||||
|
* plugins that were persisted as `ready`. It requires a `PluginWorkerManager`
|
||||||
|
* to have been provided at construction time.
|
||||||
|
*
|
||||||
|
* @param pluginId - The UUID of the plugin to start
|
||||||
|
* @param options - Worker start options (entrypoint path, config, etc.)
|
||||||
|
* @throws if no worker manager is configured or the plugin is not ready
|
||||||
|
*/
|
||||||
|
startWorker(pluginId: string, options: WorkerStartOptions): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the worker process for a plugin without changing lifecycle state.
|
||||||
|
*
|
||||||
|
* This is used during server shutdown to gracefully stop all workers.
|
||||||
|
* It does not transition the plugin state — plugins remain in their
|
||||||
|
* current status so they can be restarted on next server boot.
|
||||||
|
*
|
||||||
|
* @param pluginId - The UUID of the plugin to stop
|
||||||
|
*/
|
||||||
|
stopWorker(pluginId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart the worker process for a running plugin.
|
||||||
|
*
|
||||||
|
* Stops and re-starts the worker process. The plugin remains in `ready`
|
||||||
|
* state throughout. This is typically called after a config change.
|
||||||
|
*
|
||||||
|
* @param pluginId - The UUID of the plugin to restart
|
||||||
|
* @throws if no worker manager is configured or the plugin is not ready
|
||||||
|
*/
|
||||||
|
restartWorker(pluginId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current lifecycle state for a plugin.
|
||||||
|
*/
|
||||||
|
getStatus(pluginId: string): Promise<PluginStatus | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a transition is allowed from the plugin's current state.
|
||||||
|
*/
|
||||||
|
canTransition(pluginId: string, to: PluginStatus): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to lifecycle events.
|
||||||
|
*/
|
||||||
|
on<K extends LifecycleEventName>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from lifecycle events.
|
||||||
|
*/
|
||||||
|
off<K extends LifecycleEventName>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a lifecycle event once.
|
||||||
|
*/
|
||||||
|
once<K extends LifecycleEventName>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for constructing a PluginLifecycleManager.
|
||||||
|
*/
|
||||||
|
export interface PluginLifecycleManagerOptions {
|
||||||
|
/** Plugin loader instance. Falls back to the default if omitted. */
|
||||||
|
loader?: PluginLoader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker process manager. When provided, lifecycle transitions that bring
|
||||||
|
* a plugin online (load, enable, upgrade-to-ready) will start the worker
|
||||||
|
* process, and transitions that take a plugin offline (disable, unload,
|
||||||
|
* markError) will stop it.
|
||||||
|
*
|
||||||
|
* When omitted the lifecycle manager operates in state-only mode — the
|
||||||
|
* caller is responsible for managing worker processes externally.
|
||||||
|
*/
|
||||||
|
workerManager?: PluginWorkerManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a PluginLifecycleManager.
|
||||||
|
*
|
||||||
|
* This service orchestrates plugin state transitions on top of the
|
||||||
|
* `pluginRegistryService` (which handles raw DB persistence). It enforces
|
||||||
|
* the lifecycle state machine, emits events for downstream consumers
|
||||||
|
* (routing tables, UI, observability), and manages worker processes via
|
||||||
|
* the `PluginWorkerManager` when one is provided.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* const lifecycle = pluginLifecycleManager(db, {
|
||||||
|
* workerManager: createPluginWorkerManager(),
|
||||||
|
* });
|
||||||
|
* lifecycle.on("plugin.enabled", ({ pluginId }) => { ... });
|
||||||
|
* await lifecycle.load(pluginId);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §21.3 — `plugins.status` column
|
||||||
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||||
|
*/
|
||||||
|
export function pluginLifecycleManager(
|
||||||
|
db: Db,
|
||||||
|
options?: PluginLoader | PluginLifecycleManagerOptions,
|
||||||
|
): PluginLifecycleManager {
|
||||||
|
// Support the legacy signature: pluginLifecycleManager(db, loader)
|
||||||
|
// as well as the new options object form.
|
||||||
|
let loaderArg: PluginLoader | undefined;
|
||||||
|
let workerManager: PluginWorkerManager | undefined;
|
||||||
|
|
||||||
|
if (options && typeof options === "object" && "discoverAll" in options) {
|
||||||
|
// Legacy: second arg is a PluginLoader directly
|
||||||
|
loaderArg = options as PluginLoader;
|
||||||
|
} else if (options && typeof options === "object") {
|
||||||
|
const opts = options as PluginLifecycleManagerOptions;
|
||||||
|
loaderArg = opts.loader;
|
||||||
|
workerManager = opts.workerManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = pluginRegistryService(db);
|
||||||
|
const pluginLoaderInstance = loaderArg ?? pluginLoader(db);
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
|
||||||
|
|
||||||
|
const log = logger.child({ service: "plugin-lifecycle" });
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function requirePlugin(pluginId: string): Promise<PluginRecord> {
|
||||||
|
const plugin = await registry.getById(pluginId);
|
||||||
|
if (!plugin) throw notFound(`Plugin not found: ${pluginId}`);
|
||||||
|
return plugin as PluginRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTransition(plugin: PluginRecord, to: PluginStatus): void {
|
||||||
|
if (!isValidTransition(plugin.status, to)) {
|
||||||
|
throw badRequest(
|
||||||
|
`Invalid lifecycle transition: ${plugin.status} → ${to} for plugin ${plugin.pluginKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(
|
||||||
|
pluginId: string,
|
||||||
|
to: PluginStatus,
|
||||||
|
lastError: string | null = null,
|
||||||
|
existingPlugin?: PluginRecord,
|
||||||
|
): Promise<PluginRecord> {
|
||||||
|
const plugin = existingPlugin ?? await requirePlugin(pluginId);
|
||||||
|
assertTransition(plugin, to);
|
||||||
|
|
||||||
|
const previousStatus = plugin.status;
|
||||||
|
|
||||||
|
const updated = await registry.updateStatus(pluginId, {
|
||||||
|
status: to,
|
||||||
|
lastError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) throw notFound(`Plugin not found after status update: ${pluginId}`);
|
||||||
|
const result = updated as PluginRecord;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: result.pluginKey, from: previousStatus, to },
|
||||||
|
`plugin lifecycle: ${previousStatus} → ${to}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit the generic status_changed event
|
||||||
|
emitter.emit("plugin.status_changed", {
|
||||||
|
pluginId,
|
||||||
|
pluginKey: result.pluginKey,
|
||||||
|
previousStatus,
|
||||||
|
newStatus: to,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitDomain(
|
||||||
|
event: LifecycleEventName,
|
||||||
|
payload: PluginLifecycleEvents[LifecycleEventName],
|
||||||
|
): void {
|
||||||
|
emitter.emit(event, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Worker management helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the worker for a plugin if one is running.
|
||||||
|
* This is a best-effort operation — if no worker manager is configured
|
||||||
|
* or no worker is running, it silently succeeds.
|
||||||
|
*/
|
||||||
|
async function stopWorkerIfRunning(
|
||||||
|
pluginId: string,
|
||||||
|
pluginKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!workerManager) return;
|
||||||
|
if (!workerManager.isRunning(pluginId) && !workerManager.getWorker(pluginId)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workerManager.stopWorker(pluginId);
|
||||||
|
log.info({ pluginId, pluginKey }, "plugin lifecycle: worker stopped");
|
||||||
|
emitDomain("plugin.worker_stopped", { pluginId, pluginKey });
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
{ pluginId, pluginKey, err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"plugin lifecycle: failed to stop worker (best-effort)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateReadyPlugin(pluginId: string): Promise<void> {
|
||||||
|
const supportsRuntimeActivation =
|
||||||
|
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||||
|
&& typeof pluginLoaderInstance.loadSingle === "function";
|
||||||
|
if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadResult = await pluginLoaderInstance.loadSingle(pluginId);
|
||||||
|
if (!loadResult.success) {
|
||||||
|
throw new Error(
|
||||||
|
loadResult.error
|
||||||
|
?? `Failed to activate plugin ${loadResult.plugin.pluginKey}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// -- load -------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* load — Transitions a plugin to 'ready' status and starts its worker.
|
||||||
|
*
|
||||||
|
* This method is called after a plugin has been successfully installed and
|
||||||
|
* validated. It marks the plugin as ready in the database and immediately
|
||||||
|
* triggers the plugin loader to start the worker process.
|
||||||
|
*
|
||||||
|
* @param pluginId - The UUID of the plugin to load.
|
||||||
|
* @returns The updated plugin record.
|
||||||
|
*/
|
||||||
|
async load(pluginId: string): Promise<PluginRecord> {
|
||||||
|
const result = await transition(pluginId, "ready");
|
||||||
|
await activateReadyPlugin(pluginId);
|
||||||
|
|
||||||
|
emitDomain("plugin.loaded", {
|
||||||
|
pluginId,
|
||||||
|
pluginKey: result.pluginKey,
|
||||||
|
});
|
||||||
|
emitDomain("plugin.enabled", {
|
||||||
|
pluginId,
|
||||||
|
pluginKey: result.pluginKey,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- enable -----------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* enable — Re-enables a plugin that was previously in an error or upgrade state.
|
||||||
|
*
|
||||||
|
* Similar to load(), this method transitions the plugin to 'ready' and starts
|
||||||
|
* its worker, but it specifically targets plugins that are currently disabled.
|
||||||
|
*
|
||||||
|
* @param pluginId - The UUID of the plugin to enable.
|
||||||
|
* @returns The updated plugin record.
|
||||||
|
*/
|
||||||
|
async enable(pluginId: string): Promise<PluginRecord> {
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
|
||||||
|
// Only allow enabling from disabled, error, or upgrade_pending states
|
||||||
|
if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot enable plugin in status '${plugin.status}'. ` +
|
||||||
|
`Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transition(pluginId, "ready", null, plugin);
|
||||||
|
await activateReadyPlugin(pluginId);
|
||||||
|
emitDomain("plugin.enabled", {
|
||||||
|
pluginId,
|
||||||
|
pluginKey: result.pluginKey,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- disable ----------------------------------------------------------
|
||||||
|
async disable(pluginId: string, reason?: string): Promise<PluginRecord> {
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
|
||||||
|
// Only allow disabling from ready state
|
||||||
|
if (plugin.status !== "ready") {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot disable plugin in status '${plugin.status}'. ` +
|
||||||
|
`Plugin must be in 'ready' status to be disabled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<PluginRecord | null> {
|
||||||
|
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<PluginRecord> {
|
||||||
|
// Stop the worker — the plugin is in an error state and should not
|
||||||
|
// continue running. The worker manager's auto-restart is disabled
|
||||||
|
// because we are intentionally taking the plugin offline.
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
await 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<PluginRecord> {
|
||||||
|
// 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<PluginRecord> {
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
|
||||||
|
// Can only upgrade plugins that are ready or already in upgrade_pending
|
||||||
|
if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot upgrade plugin in status '${plugin.status}'. ` +
|
||||||
|
`Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: plugin.pluginKey, targetVersion: version },
|
||||||
|
"plugin lifecycle: upgrade requested",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
if (!workerManager) {
|
||||||
|
throw badRequest(
|
||||||
|
"Cannot start worker: no PluginWorkerManager is configured. " +
|
||||||
|
"Provide a workerManager option when constructing the lifecycle manager.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
if (plugin.status !== "ready") {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot start worker for plugin in status '${plugin.status}'. ` +
|
||||||
|
`Plugin must be in 'ready' status.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: plugin.pluginKey },
|
||||||
|
"plugin lifecycle: starting worker",
|
||||||
|
);
|
||||||
|
|
||||||
|
await workerManager.startWorker(pluginId, options);
|
||||||
|
emitDomain("plugin.worker_started", {
|
||||||
|
pluginId,
|
||||||
|
pluginKey: plugin.pluginKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: plugin.pluginKey },
|
||||||
|
"plugin lifecycle: worker started",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- stopWorker -------------------------------------------------------
|
||||||
|
async stopWorker(pluginId: string): Promise<void> {
|
||||||
|
if (!workerManager) return; // No worker manager — nothing to stop
|
||||||
|
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- restartWorker ----------------------------------------------------
|
||||||
|
async restartWorker(pluginId: string): Promise<void> {
|
||||||
|
if (!workerManager) {
|
||||||
|
throw badRequest(
|
||||||
|
"Cannot restart worker: no PluginWorkerManager is configured.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await requirePlugin(pluginId);
|
||||||
|
if (plugin.status !== "ready") {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot restart worker for plugin in status '${plugin.status}'. ` +
|
||||||
|
`Plugin must be in 'ready' status.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = workerManager.getWorker(pluginId);
|
||||||
|
if (!handle) {
|
||||||
|
throw badRequest(
|
||||||
|
`Cannot restart worker for plugin "${plugin.pluginKey}": no worker is running.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: plugin.pluginKey },
|
||||||
|
"plugin lifecycle: restarting worker",
|
||||||
|
);
|
||||||
|
|
||||||
|
await handle.restart();
|
||||||
|
|
||||||
|
emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey });
|
||||||
|
emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey });
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pluginId, pluginKey: plugin.pluginKey },
|
||||||
|
"plugin lifecycle: worker restarted",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- getStatus --------------------------------------------------------
|
||||||
|
async getStatus(pluginId: string): Promise<PluginStatus | null> {
|
||||||
|
const plugin = await registry.getById(pluginId);
|
||||||
|
return plugin?.status ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- canTransition ----------------------------------------------------
|
||||||
|
async canTransition(pluginId: string, to: PluginStatus): Promise<boolean> {
|
||||||
|
const plugin = await registry.getById(pluginId);
|
||||||
|
if (!plugin) return false;
|
||||||
|
return isValidTransition(plugin.status, to);
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Event subscriptions ----------------------------------------------
|
||||||
|
on(event, listener) {
|
||||||
|
emitter.on(event, listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
off(event, listener) {
|
||||||
|
emitter.off(event, listener);
|
||||||
|
},
|
||||||
|
|
||||||
|
once(event, listener) {
|
||||||
|
emitter.once(event, listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1852
server/src/services/plugin-loader.ts
Normal file
1852
server/src/services/plugin-loader.ts
Normal file
File diff suppressed because it is too large
Load Diff
86
server/src/services/plugin-log-retention.ts
Normal file
86
server/src/services/plugin-log-retention.ts
Normal file
@@ -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<number> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
163
server/src/services/plugin-manifest-validator.ts
Normal file
163
server/src/services/plugin-manifest-validator.ts
Normal file
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
963
server/src/services/plugin-registry.ts
Normal file
963
server/src/services/plugin-registry.ts
Normal file
@@ -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<number> {
|
||||||
|
const result = await db
|
||||||
|
.select({ maxOrder: sql<number>`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<ReturnType<typeof getById>>,
|
||||||
|
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<typeof plugins.$inferInsert> & { 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<Set<string>> => {
|
||||||
|
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<string>();
|
||||||
|
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<typeof pluginEntities.$inferInsert, "id" | "pluginId" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
// 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<string, unknown>;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
221
server/src/services/plugin-runtime-sandbox.ts
Normal file
221
server/src/services/plugin-runtime-sandbox.ts
Normal file
@@ -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<string>;
|
||||||
|
allowedModules?: Readonly<Record<string, Record<string, unknown>>>;
|
||||||
|
allowedGlobals?: Record<string, unknown>;
|
||||||
|
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<T>(operation: string, fn: () => Promise<T> | T): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedModule {
|
||||||
|
namespace: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 2_000;
|
||||||
|
const MODULE_PATH_SUFFIXES = ["", ".js", ".mjs", ".cjs", "/index.js", "/index.mjs", "/index.cjs"];
|
||||||
|
const DEFAULT_GLOBALS: Record<string, unknown> = {
|
||||||
|
console,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
setInterval,
|
||||||
|
clearInterval,
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
TextEncoder,
|
||||||
|
TextDecoder,
|
||||||
|
AbortController,
|
||||||
|
AbortSignal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCapabilityScopedInvoker(
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
validator: PluginCapabilityValidator,
|
||||||
|
): CapabilityScopedInvoker {
|
||||||
|
return {
|
||||||
|
async invoke<T>(operation: string, fn: () => Promise<T> | T): Promise<T> {
|
||||||
|
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<LoadedModule> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
const allowedSpecifiers = options.allowedModuleSpecifiers ?? new Set<string>();
|
||||||
|
const entrypointPath = path.resolve(options.entrypointPath);
|
||||||
|
const pluginRoot = path.dirname(entrypointPath);
|
||||||
|
|
||||||
|
const context = vm.createContext({
|
||||||
|
...DEFAULT_GLOBALS,
|
||||||
|
...options.allowedGlobals,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||||
|
const allowedModules = options.allowedModules ?? {};
|
||||||
|
|
||||||
|
const realPluginRoot = realpathSync(pluginRoot);
|
||||||
|
|
||||||
|
const loadModuleSync = (modulePath: string): Record<string, unknown> => {
|
||||||
|
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<string, unknown> };
|
||||||
|
// Cache the module before execution to preserve CommonJS cycle semantics.
|
||||||
|
moduleCache.set(realPath, module.exports);
|
||||||
|
|
||||||
|
const requireInSandbox = (specifier: string): Record<string, unknown> => {
|
||||||
|
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<string, unknown>)[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<string, unknown> {
|
||||||
|
if (typeof exportsValue === "object" && exportsValue !== null) {
|
||||||
|
return exportsValue as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
367
server/src/services/plugin-secrets-handler.ts
Normal file
367
server/src/services/plugin-secrets-handler.ts
Normal file
@@ -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<string, unknown> | null | undefined,
|
||||||
|
): Set<string> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
if (!schema || typeof schema !== "object") return paths;
|
||||||
|
|
||||||
|
function walk(node: Record<string, unknown>, prefix: string): void {
|
||||||
|
const props = node.properties as Record<string, Record<string, unknown>> | 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<string, unknown> | null,
|
||||||
|
): Set<string> {
|
||||||
|
const refs = new Set<string>();
|
||||||
|
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<string, unknown>)[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<string, unknown>)) 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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, number[]>();
|
||||||
|
|
||||||
|
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<string> | null = null;
|
||||||
|
let cachedAllowedRefsExpiry = 0;
|
||||||
|
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
|
||||||
|
|
||||||
|
return {
|
||||||
|
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||||
|
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 ?? "<empty>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | null)
|
||||||
|
?.instanceConfigSchema as Record<string, unknown> | 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<string, unknown>,
|
||||||
|
externalRef: secret.externalRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
237
server/src/services/plugin-state-store.ts
Normal file
237
server/src/services/plugin-state-store.ts
Normal file
@@ -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<void> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: plugins.id })
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.id, pluginId));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw notFound(`Plugin not found: ${pluginId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 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<unknown> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<typeof pluginState.$inferSelect[]> => {
|
||||||
|
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<void> => {
|
||||||
|
await db
|
||||||
|
.delete(pluginState)
|
||||||
|
.where(eq(pluginState.pluginId, pluginId));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginStateStore = ReturnType<typeof pluginStateStore>;
|
||||||
81
server/src/services/plugin-stream-bus.ts
Normal file
81
server/src/services/plugin-stream-bus.ts
Normal file
@@ -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<string, Set<StreamSubscriber>>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
448
server/src/services/plugin-tool-dispatcher.ts
Normal file
448
server/src/services/plugin-tool-dispatcher.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
/** 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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ToolExecutionResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ToolExecutionResult> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
449
server/src/services/plugin-tool-registry.ts
Normal file
449
server/src/services/plugin-tool-registry.ts
Normal file
@@ -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: `"<pluginId>:<toolName>"`. */
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ToolExecutionResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, RegisteredTool>();
|
||||||
|
|
||||||
|
// Secondary index: pluginId → set of namespaced names (for bulk operations)
|
||||||
|
const byPlugin = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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<ToolExecutionResult> {
|
||||||
|
// 1. Resolve the namespaced name
|
||||||
|
const parsed = parseName(namespacedName);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid tool name "${namespacedName}". Expected format: "<pluginId>${TOOL_NAMESPACE_SEPARATOR}<toolName>"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
1342
server/src/services/plugin-worker-manager.ts
Normal file
1342
server/src/services/plugin-worker-manager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@ import { Inbox } from "./pages/Inbox";
|
|||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
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 { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||||
import { OrgChart } from "./pages/OrgChart";
|
import { OrgChart } from "./pages/OrgChart";
|
||||||
import { NewAgent } from "./pages/NewAgent";
|
import { NewAgent } from "./pages/NewAgent";
|
||||||
@@ -113,6 +116,7 @@ function boardRoutes() {
|
|||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
|
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||||
<Route path="org" element={<OrgChart />} />
|
<Route path="org" element={<OrgChart />} />
|
||||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||||
<Route path="agents/all" element={<Agents />} />
|
<Route path="agents/all" element={<Agents />} />
|
||||||
@@ -162,7 +166,7 @@ function InboxRootRedirect() {
|
|||||||
|
|
||||||
function LegacySettingsRedirect() {
|
function LegacySettingsRedirect() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
|
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OnboardingRoutePage() {
|
function OnboardingRoutePage() {
|
||||||
@@ -295,9 +299,12 @@ export function App() {
|
|||||||
<Route element={<CloudAccessGate />}>
|
<Route element={<CloudAccessGate />}>
|
||||||
<Route index element={<CompanyRootRedirect />} />
|
<Route index element={<CompanyRootRedirect />} />
|
||||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||||
<Route path="instance" element={<Navigate to="/instance/settings" replace />} />
|
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} />
|
||||||
<Route path="instance/settings" element={<Layout />}>
|
<Route path="instance/settings" element={<Layout />}>
|
||||||
<Route index element={<InstanceSettings />} />
|
<Route index element={<Navigate to="heartbeats" replace />} />
|
||||||
|
<Route path="heartbeats" element={<InstanceSettings />} />
|
||||||
|
<Route path="plugins" element={<PluginManager />} />
|
||||||
|
<Route path="plugins/:pluginId" element={<PluginSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const api = {
|
|||||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||||
postForm: <T>(path: string, body: FormData) =>
|
postForm: <T>(path: string, body: FormData) =>
|
||||||
request<T>(path, { method: "POST", body }),
|
request<T>(path, { method: "POST", body }),
|
||||||
|
put: <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
patch: <T>(path: string, body: unknown) =>
|
patch: <T>(path: string, body: unknown) =>
|
||||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
|
|||||||
469
ui/src/api/plugins.ts
Normal file
469
ui/src/api/plugins.ts
Normal file
@@ -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<PluginRecord[]>(`/plugins${status ? `?status=${status}` : ""}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List bundled example plugins available from the current repo checkout.
|
||||||
|
*/
|
||||||
|
listExamples: () =>
|
||||||
|
api.get<AvailablePluginExample[]>("/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<PluginRecord>(`/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<PluginRecord>("/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<PluginHealthCheckResult>(`/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<PluginDashboardData>(`/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<Array<{ id: string; pluginId: string; level: string; message: string; meta: Record<string, unknown> | 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<PluginUiContribution[]>(
|
||||||
|
`/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<CompanyPluginAvailability[]>(
|
||||||
|
`/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<CompanyPluginAvailability>(`/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<string, unknown>;
|
||||||
|
lastError?: string | null;
|
||||||
|
},
|
||||||
|
) =>
|
||||||
|
api.put<CompanyPluginAvailability>(`/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<PluginConfig | null>(`/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<string, unknown>) =>
|
||||||
|
api.post<PluginConfig>(`/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<string, unknown>) =>
|
||||||
|
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<T>`.
|
||||||
|
*
|
||||||
|
* 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<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
companyId?: string | null,
|
||||||
|
renderEnvironment?: PluginLauncherRenderContextSnapshot | null,
|
||||||
|
) =>
|
||||||
|
api.post<{ data: unknown }>(`/plugins/${pluginId}/actions/${encodeURIComponent(key)}`, {
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
params,
|
||||||
|
renderEnvironment: renderEnvironment ?? undefined,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Clock3, Settings } from "lucide-react";
|
import { Clock3, Puzzle, Settings } from "lucide-react";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
|
|
||||||
export function InstanceSidebar() {
|
export function InstanceSidebar() {
|
||||||
@@ -13,7 +13,8 @@ export function InstanceSidebar() {
|
|||||||
|
|
||||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarNavItem to="/instance/settings" label="Heartbeats" icon={Clock3} />
|
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
|
||||||
|
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
1048
ui/src/components/JsonSchemaForm.tsx
Normal file
1048
ui/src/components/JsonSchemaForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,37 @@ import { cn } from "../lib/utils";
|
|||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||||
|
const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
|
||||||
|
|
||||||
|
function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
|
||||||
|
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
|
||||||
|
const match = rawPath.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
||||||
|
const pathname = match?.[1] ?? rawPath;
|
||||||
|
const search = match?.[2] ?? "";
|
||||||
|
const hash = match?.[3] ?? "";
|
||||||
|
|
||||||
|
if (pathname === "/instance/settings/heartbeats" || pathname === "/instance/settings/plugins") {
|
||||||
|
return `${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/instance\/settings\/plugins\/[^/?#]+$/.test(pathname)) {
|
||||||
|
return `${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRememberedInstanceSettingsPath(): string {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
try {
|
||||||
|
return normalizeRememberedInstanceSettingsPath(window.localStorage.getItem(INSTANCE_SETTINGS_MEMORY_KEY));
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||||
const { openNewIssue, openOnboarding } = useDialog();
|
const { openNewIssue, openOnboarding } = useDialog();
|
||||||
@@ -49,6 +80,7 @@ export function Layout() {
|
|||||||
const onboardingTriggered = useRef(false);
|
const onboardingTriggered = useRef(false);
|
||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
|
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
const matchedCompany = useMemo(() => {
|
const matchedCompany = useMemo(() => {
|
||||||
if (!companyPrefix) return null;
|
if (!companyPrefix) return null;
|
||||||
@@ -220,6 +252,21 @@ export function Layout() {
|
|||||||
};
|
};
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.pathname.startsWith("/instance/settings/")) return;
|
||||||
|
|
||||||
|
const nextPath = normalizeRememberedInstanceSettingsPath(
|
||||||
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
);
|
||||||
|
setInstanceSettingsTarget(nextPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(INSTANCE_SETTINGS_MEMORY_KEY, nextPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures in restricted environments.
|
||||||
|
}
|
||||||
|
}, [location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -235,7 +282,6 @@ export function Layout() {
|
|||||||
</a>
|
</a>
|
||||||
<WorktreeBanner />
|
<WorktreeBanner />
|
||||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||||
{/* Mobile backdrop */}
|
|
||||||
{isMobile && sidebarOpen && (
|
{isMobile && sidebarOpen && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -245,7 +291,6 @@ export function Layout() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -270,7 +315,7 @@ export function Layout() {
|
|||||||
</a>
|
</a>
|
||||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
<Link
|
<Link
|
||||||
to="/instance/settings"
|
to={instanceSettingsTarget}
|
||||||
aria-label="Instance settings"
|
aria-label="Instance settings"
|
||||||
title="Instance settings"
|
title="Instance settings"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -320,7 +365,7 @@ export function Layout() {
|
|||||||
</a>
|
</a>
|
||||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||||
<Link
|
<Link
|
||||||
to="/instance/settings"
|
to={instanceSettingsTarget}
|
||||||
aria-label="Instance settings"
|
aria-label="Instance settings"
|
||||||
title="Instance settings"
|
title="Instance settings"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -346,7 +391,6 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -25,17 +25,26 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||||
import type { Project } from "@paperclipai/shared";
|
import type { Project } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
|
||||||
|
|
||||||
function SortableProjectItem({
|
function SortableProjectItem({
|
||||||
activeProjectRef,
|
activeProjectRef,
|
||||||
|
companyId,
|
||||||
|
companyPrefix,
|
||||||
isMobile,
|
isMobile,
|
||||||
project,
|
project,
|
||||||
|
projectSidebarSlots,
|
||||||
setSidebarOpen,
|
setSidebarOpen,
|
||||||
}: {
|
}: {
|
||||||
activeProjectRef: string | null;
|
activeProjectRef: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
companyPrefix: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
project: Project;
|
project: Project;
|
||||||
|
projectSidebarSlots: ProjectSidebarSlot[];
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -61,6 +70,7 @@ function SortableProjectItem({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/projects/${routeRef}/issues`}
|
to={`/projects/${routeRef}/issues`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -79,13 +89,33 @@ function SortableProjectItem({
|
|||||||
/>
|
/>
|
||||||
<span className="flex-1 truncate">{project.name}</span>
|
<span className="flex-1 truncate">{project.name}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{projectSidebarSlots.length > 0 && (
|
||||||
|
<div className="ml-5 flex flex-col gap-0.5">
|
||||||
|
{projectSidebarSlots.map((slot) => (
|
||||||
|
<PluginSlotMount
|
||||||
|
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
|
||||||
|
slot={slot}
|
||||||
|
context={{
|
||||||
|
companyId,
|
||||||
|
companyPrefix,
|
||||||
|
projectId: project.id,
|
||||||
|
projectRef: routeRef,
|
||||||
|
entityId: project.id,
|
||||||
|
entityType: "project",
|
||||||
|
}}
|
||||||
|
missingBehavior="placeholder"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarProjects() {
|
export function SidebarProjects() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
const { openNewProject } = useDialog();
|
const { openNewProject } = useDialog();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -99,6 +129,12 @@ export function SidebarProjects() {
|
|||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
});
|
});
|
||||||
|
const { slots: projectSidebarSlots } = usePluginSlots({
|
||||||
|
slotTypes: ["projectSidebarItem"],
|
||||||
|
entityType: "project",
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
@@ -178,8 +214,11 @@ export function SidebarProjects() {
|
|||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
activeProjectRef={activeProjectRef}
|
activeProjectRef={activeProjectRef}
|
||||||
|
companyId={selectedCompanyId}
|
||||||
|
companyPrefix={selectedCompany?.issuePrefix ?? null}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
project={project}
|
project={project}
|
||||||
|
projectSidebarSlots={projectSidebarSlots}
|
||||||
setSidebarOpen={setSidebarOpen}
|
setSidebarOpen={setSidebarOpen}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -75,4 +75,20 @@ export const queryKeys = {
|
|||||||
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||||
runIssues: (runId: string) => ["run-issues", runId] as const,
|
runIssues: (runId: string) => ["run-issues", runId] as const,
|
||||||
org: (companyId: string) => ["org", companyId] as const,
|
org: (companyId: string) => ["org", companyId] as const,
|
||||||
|
plugins: {
|
||||||
|
all: ["plugins"] as const,
|
||||||
|
examples: ["plugins", "examples"] as const,
|
||||||
|
detail: (pluginId: string) => ["plugins", pluginId] as const,
|
||||||
|
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
||||||
|
uiContributions: (companyId?: string | null) =>
|
||||||
|
["plugins", "ui-contributions", companyId ?? "global"] as const,
|
||||||
|
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
||||||
|
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||||
|
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
||||||
|
company: (companyId: string) => ["plugins", "company", companyId] as const,
|
||||||
|
companyList: (companyId: string, available?: boolean) =>
|
||||||
|
["plugins", "company", companyId, "list", available ?? "all"] as const,
|
||||||
|
companyDetail: (companyId: string, pluginId: string) =>
|
||||||
|
["plugins", "company", companyId, pluginId] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import * as React from "react";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "@/lib/router";
|
import { BrowserRouter } from "@/lib/router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -12,9 +14,12 @@ import { DialogProvider } from "./context/DialogContext";
|
|||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { initPluginBridge } from "./plugins/bridge-init";
|
||||||
import "@mdxeditor/editor/style.css";
|
import "@mdxeditor/editor/style.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
initPluginBridge(React, ReactDOM);
|
||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
navigator.serviceWorker.register("/sw.js");
|
navigator.serviceWorker.register("/sw.js");
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
|||||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import type { Agent, Issue } from "@paperclipai/shared";
|
import type { Agent, Issue } from "@paperclipai/shared";
|
||||||
|
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
|
|
||||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||||
return [...issues]
|
return [...issues]
|
||||||
@@ -276,6 +277,13 @@ export function Dashboard() {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PluginSlotOutlet
|
||||||
|
slotTypes={["dashboardWidget"]}
|
||||||
|
context={{ companyId: selectedCompanyId }}
|
||||||
|
className="grid gap-4 md:grid-cols-2"
|
||||||
|
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
{recentActivity.length > 0 && (
|
{recentActivity.length > 0 && (
|
||||||
|
|||||||
512
ui/src/pages/PluginManager.tsx
Normal file
512
ui/src/pages/PluginManager.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Plugin Manager page — admin UI for discovering,
|
||||||
|
* installing, enabling/disabling, and uninstalling plugins.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §9 — Plugin Marketplace / Manager
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { PluginRecord } from "@paperclipai/shared";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { AlertTriangle, FlaskConical, Plus, Power, Puzzle, Settings, Trash } from "lucide-react";
|
||||||
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||||
|
import { pluginsApi } from "@/api/plugins";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/context/ToastContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const line = value
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
return line ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPluginErrorSummary(plugin: PluginRecord): string {
|
||||||
|
return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginManager page component.
|
||||||
|
*
|
||||||
|
* Provides a management UI for the Paperclip plugin system:
|
||||||
|
* - Lists all installed plugins with their status, version, and category badges.
|
||||||
|
* - Allows installing new plugins by npm package name.
|
||||||
|
* - Provides per-plugin actions: enable, disable, navigate to settings.
|
||||||
|
* - Uninstall with a two-step confirmation dialog to prevent accidental removal.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* - Reads from `GET /api/plugins` via `pluginsApi.list()`.
|
||||||
|
* - Mutations (install / uninstall / enable / disable) invalidate
|
||||||
|
* `queryKeys.plugins.all` so the list refreshes automatically.
|
||||||
|
*
|
||||||
|
* @see PluginSettings — linked from the Settings icon on each plugin row.
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §3 — Plugin Lifecycle for status semantics.
|
||||||
|
*/
|
||||||
|
export function PluginManager() {
|
||||||
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { pushToast } = useToast();
|
||||||
|
|
||||||
|
const [installPackage, setInstallPackage] = useState("");
|
||||||
|
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||||
|
const [uninstallPluginId, setUninstallPluginId] = useState<string | null>(null);
|
||||||
|
const [uninstallPluginName, setUninstallPluginName] = useState<string>("");
|
||||||
|
const [errorDetailsPlugin, setErrorDetailsPlugin] = useState<PluginRecord | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||||
|
{ label: "Settings", href: "/instance/settings/heartbeats" },
|
||||||
|
{ label: "Plugins" },
|
||||||
|
]);
|
||||||
|
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||||
|
|
||||||
|
const { data: plugins, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.all,
|
||||||
|
queryFn: () => pluginsApi.list(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const examplesQuery = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.examples,
|
||||||
|
queryFn: () => pluginsApi.listExamples(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidatePluginQueries = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.examples });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["plugins", "ui-contributions"] });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.companyList(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const installMutation = useMutation({
|
||||||
|
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
|
||||||
|
pluginsApi.install(params),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidatePluginQueries();
|
||||||
|
setInstallDialogOpen(false);
|
||||||
|
setInstallPackage("");
|
||||||
|
pushToast({ title: "Plugin installed successfully", tone: "success" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
pushToast({ title: "Failed to install plugin", body: err.message, tone: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uninstallMutation = useMutation({
|
||||||
|
mutationFn: (pluginId: string) => pluginsApi.uninstall(pluginId),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidatePluginQueries();
|
||||||
|
pushToast({ title: "Plugin uninstalled successfully", tone: "success" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
pushToast({ title: "Failed to uninstall plugin", body: err.message, tone: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableMutation = useMutation({
|
||||||
|
mutationFn: (pluginId: string) => pluginsApi.enable(pluginId),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidatePluginQueries();
|
||||||
|
pushToast({ title: "Plugin enabled", tone: "success" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
pushToast({ title: "Failed to enable plugin", body: err.message, tone: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableMutation = useMutation({
|
||||||
|
mutationFn: (pluginId: string) => pluginsApi.disable(pluginId),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidatePluginQueries();
|
||||||
|
pushToast({ title: "Plugin disabled", tone: "info" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
pushToast({ title: "Failed to disable plugin", body: err.message, tone: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const installedPlugins = plugins ?? [];
|
||||||
|
const examples = examplesQuery.data ?? [];
|
||||||
|
const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin]));
|
||||||
|
const examplePackageNames = new Set(examples.map((example) => example.packageName));
|
||||||
|
const errorSummaryByPluginId = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
installedPlugins.map((plugin) => [plugin.id, getPluginErrorSummary(plugin)])
|
||||||
|
),
|
||||||
|
[installedPlugins]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading plugins...</div>;
|
||||||
|
if (error) return <div className="p-4 text-sm text-destructive">Failed to load plugins.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold">Plugin Manager</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Install Plugin
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Install Plugin</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the npm package name of the plugin you wish to install.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="packageName">npm Package Name</Label>
|
||||||
|
<Input
|
||||||
|
id="packageName"
|
||||||
|
placeholder="@paperclipai/plugin-example"
|
||||||
|
value={installPackage}
|
||||||
|
onChange={(e) => setInstallPackage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => installMutation.mutate({ packageName: installPackage })}
|
||||||
|
disabled={!installPackage || installMutation.isPending}
|
||||||
|
>
|
||||||
|
{installMutation.isPending ? "Installing..." : "Install"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-foreground">Plugins are alpha.</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The plugin runtime and API surface are still changing. Expect breaking changes while this feature settles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-base font-semibold">Available Plugins</h2>
|
||||||
|
<Badge variant="outline">Examples</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{examplesQuery.isLoading ? (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
|
||||||
|
) : examplesQuery.error ? (
|
||||||
|
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
|
||||||
|
) : examples.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
No bundled example plugins were found in this checkout.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border bg-card">
|
||||||
|
{examples.map((example) => {
|
||||||
|
const installedPlugin = installedByPackageName.get(example.packageName);
|
||||||
|
const installPending =
|
||||||
|
installMutation.isPending &&
|
||||||
|
installMutation.variables?.isLocalPath &&
|
||||||
|
installMutation.variables.packageName === example.localPath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={example.packageName}>
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{example.displayName}</span>
|
||||||
|
<Badge variant="outline">Example</Badge>
|
||||||
|
{installedPlugin ? (
|
||||||
|
<Badge
|
||||||
|
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
|
||||||
|
className={installedPlugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""}
|
||||||
|
>
|
||||||
|
{installedPlugin.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Not installed</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{example.description}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{example.packageName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{installedPlugin ? (
|
||||||
|
<>
|
||||||
|
{installedPlugin.status !== "ready" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={enableMutation.isPending}
|
||||||
|
onClick={() => enableMutation.mutate(installedPlugin.id)}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/instance/settings/plugins/${installedPlugin.id}`}>
|
||||||
|
{installedPlugin.status === "ready" ? "Open Settings" : "Review"}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={installPending || installMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
installMutation.mutate({
|
||||||
|
packageName: example.localPath,
|
||||||
|
isLocalPath: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{installPending ? "Installing..." : "Install Example"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Puzzle className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-base font-semibold">Installed Plugins</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!installedPlugins.length ? (
|
||||||
|
<Card className="bg-muted/30">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-10">
|
||||||
|
<Puzzle className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-sm font-medium">No plugins installed</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Install a plugin to extend functionality.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border bg-card">
|
||||||
|
{installedPlugins.map((plugin) => (
|
||||||
|
<li key={plugin.id}>
|
||||||
|
<div className="flex items-start gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/instance/settings/plugins/${plugin.id}`}
|
||||||
|
className="font-medium hover:underline truncate block"
|
||||||
|
title={plugin.manifestJson.displayName ?? plugin.packageName}
|
||||||
|
>
|
||||||
|
{plugin.manifestJson.displayName ?? plugin.packageName}
|
||||||
|
</Link>
|
||||||
|
{examplePackageNames.has(plugin.packageName) && (
|
||||||
|
<Badge variant="outline">Example</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate" title={plugin.packageName}>
|
||||||
|
{plugin.packageName} · v{plugin.manifestJson.version ?? plugin.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate mt-0.5" title={plugin.manifestJson.description}>
|
||||||
|
{plugin.manifestJson.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
{plugin.status === "error" && (
|
||||||
|
<div className="mt-3 rounded-md border border-red-500/25 bg-red-500/[0.06] px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-red-700 dark:text-red-300">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Plugin error</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="mt-1 text-sm text-red-700/90 dark:text-red-200/90 break-words"
|
||||||
|
title={plugin.lastError ?? undefined}
|
||||||
|
>
|
||||||
|
{errorSummaryByPluginId.get(plugin.id)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-red-500/30 bg-background/60 text-red-700 hover:bg-red-500/10 hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
||||||
|
onClick={() => setErrorDetailsPlugin(plugin)}
|
||||||
|
>
|
||||||
|
View full error
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 self-center">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
plugin.status === "ready"
|
||||||
|
? "default"
|
||||||
|
: plugin.status === "error"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
plugin.status === "ready" ? "bg-green-600 hover:bg-green-700" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plugin.status}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-8 w-8"
|
||||||
|
title={plugin.status === "ready" ? "Disable" : "Enable"}
|
||||||
|
onClick={() => {
|
||||||
|
if (plugin.status === "ready") {
|
||||||
|
disableMutation.mutate(plugin.id);
|
||||||
|
} else {
|
||||||
|
enableMutation.mutate(plugin.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={enableMutation.isPending || disableMutation.isPending}
|
||||||
|
>
|
||||||
|
<Power className={cn("h-4 w-4", plugin.status === "ready" ? "text-green-600" : "")} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
title="Uninstall"
|
||||||
|
onClick={() => {
|
||||||
|
setUninstallPluginId(plugin.id);
|
||||||
|
setUninstallPluginName(plugin.manifestJson.displayName ?? plugin.packageName);
|
||||||
|
}}
|
||||||
|
disabled={uninstallMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="mt-2 h-8" asChild>
|
||||||
|
<Link to={`/instance/settings/plugins/${plugin.id}`}>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Configure
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={uninstallPluginId !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setUninstallPluginId(null); }}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Uninstall Plugin</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to uninstall <strong>{uninstallPluginName}</strong>? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUninstallPluginId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={uninstallMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (uninstallPluginId) {
|
||||||
|
uninstallMutation.mutate(uninstallPluginId, {
|
||||||
|
onSettled: () => setUninstallPluginId(null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uninstallMutation.isPending ? "Uninstalling..." : "Uninstall"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={errorDetailsPlugin !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setErrorDetailsPlugin(null); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Error Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{errorDetailsPlugin?.manifestJson.displayName ?? errorDetailsPlugin?.packageName ?? "Plugin"} hit an error state.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-red-500/25 bg-red-500/[0.06] px-4 py-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="font-medium text-red-700 dark:text-red-300">
|
||||||
|
What errored
|
||||||
|
</p>
|
||||||
|
<p className="text-red-700/90 dark:text-red-200/90 break-words">
|
||||||
|
{errorDetailsPlugin ? getPluginErrorSummary(errorDetailsPlugin) : "No error summary available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">Full error output</p>
|
||||||
|
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||||
|
{errorDetailsPlugin?.lastError ?? "No stored error message."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setErrorDetailsPlugin(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
ui/src/pages/PluginPage.tsx
Normal file
113
ui/src/pages/PluginPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Link, Navigate, useParams } from "@/lib/router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||||
|
import { pluginsApi } from "@/api/plugins";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { PluginSlotMount } from "@/plugins/slots";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company-context plugin page. Renders a plugin's `page` slot at
|
||||||
|
* `/:companyPrefix/plugins/:pluginId` when the plugin declares a page slot
|
||||||
|
* and is enabled for that company.
|
||||||
|
*
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||||
|
*/
|
||||||
|
export function PluginPage() {
|
||||||
|
const { companyPrefix: routeCompanyPrefix, pluginId } = useParams<{
|
||||||
|
companyPrefix?: string;
|
||||||
|
pluginId: string;
|
||||||
|
}>();
|
||||||
|
const { companies, selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
||||||
|
const resolvedCompanyId = useMemo(() => {
|
||||||
|
if (!routeCompanyPrefix) return selectedCompanyId ?? null;
|
||||||
|
const requested = routeCompanyPrefix.toUpperCase();
|
||||||
|
return companies.find((c) => c.issuePrefix.toUpperCase() === requested)?.id ?? selectedCompanyId ?? null;
|
||||||
|
}, [companies, routeCompanyPrefix, selectedCompanyId]);
|
||||||
|
|
||||||
|
const companyPrefix = useMemo(
|
||||||
|
() => (resolvedCompanyId ? companies.find((c) => c.id === resolvedCompanyId)?.issuePrefix ?? null : null),
|
||||||
|
[companies, resolvedCompanyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: contributions } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.uiContributions(resolvedCompanyId ?? undefined),
|
||||||
|
queryFn: () => pluginsApi.listUiContributions(resolvedCompanyId ?? undefined),
|
||||||
|
enabled: !!resolvedCompanyId && !!pluginId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageSlot = useMemo(() => {
|
||||||
|
if (!pluginId || !contributions) return null;
|
||||||
|
const contribution = contributions.find((c) => c.pluginId === pluginId);
|
||||||
|
if (!contribution) return null;
|
||||||
|
const slot = contribution.slots.find((s) => s.type === "page");
|
||||||
|
if (!slot) return null;
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
pluginId: contribution.pluginId,
|
||||||
|
pluginKey: contribution.pluginKey,
|
||||||
|
pluginDisplayName: contribution.displayName,
|
||||||
|
pluginVersion: contribution.version,
|
||||||
|
};
|
||||||
|
}, [pluginId, contributions]);
|
||||||
|
|
||||||
|
const context = useMemo(
|
||||||
|
() => ({
|
||||||
|
companyId: resolvedCompanyId ?? null,
|
||||||
|
companyPrefix,
|
||||||
|
}),
|
||||||
|
[resolvedCompanyId, companyPrefix],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageSlot) {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||||
|
{ label: pageSlot.pluginDisplayName },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
||||||
|
|
||||||
|
if (!resolvedCompanyId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Select a company to view this page.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contributions) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageSlot) {
|
||||||
|
// No page slot: redirect to plugin settings where plugin info is always shown
|
||||||
|
const settingsPath = `/instance/settings/plugins/${pluginId}`;
|
||||||
|
return <Navigate to={settingsPath} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PluginSlotMount
|
||||||
|
slot={pageSlot}
|
||||||
|
context={context}
|
||||||
|
className="min-h-[200px]"
|
||||||
|
missingBehavior="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
836
ui/src/pages/PluginSettings.tsx
Normal file
836
ui/src/pages/PluginSettings.tsx
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
|
||||||
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||||
|
import { Link, Navigate, useParams } from "@/lib/router";
|
||||||
|
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||||
|
import { pluginsApi } from "@/api/plugins";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { PageTabBar } from "@/components/PageTabBar";
|
||||||
|
import {
|
||||||
|
JsonSchemaForm,
|
||||||
|
validateJsonSchemaForm,
|
||||||
|
getDefaultValues,
|
||||||
|
type JsonSchemaNode,
|
||||||
|
} from "@/components/JsonSchemaForm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PluginSettings page component.
|
||||||
|
*
|
||||||
|
* Detailed settings and diagnostics page for a single installed plugin.
|
||||||
|
* Navigated to from {@link PluginManager} via the Settings gear icon.
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Plugin identity: display name, id, version, description, categories.
|
||||||
|
* - Manifest-declared capabilities (what data and features the plugin can access).
|
||||||
|
* - Health check results (only for `ready` plugins; polled every 30 seconds).
|
||||||
|
* - Runtime dashboard: worker status/uptime, recent job runs, webhook deliveries.
|
||||||
|
* - Auto-generated config form from `instanceConfigSchema` (when no custom settings page).
|
||||||
|
* - Plugin-contributed settings UI via `<PluginSlotOutlet type="settingsPage" />`.
|
||||||
|
*
|
||||||
|
* Data flow:
|
||||||
|
* - `GET /api/plugins/:pluginId` — plugin record (refreshes on mount).
|
||||||
|
* - `GET /api/plugins/:pluginId/health` — health diagnostics (polling).
|
||||||
|
* Only fetched when `plugin.status === "ready"`.
|
||||||
|
* - `GET /api/plugins/:pluginId/dashboard` — aggregated runtime dashboard data (polling).
|
||||||
|
* - `GET /api/plugins/:pluginId/config` — current config values.
|
||||||
|
* - `POST /api/plugins/:pluginId/config` — save config values.
|
||||||
|
* - `POST /api/plugins/:pluginId/config/test` — test configuration.
|
||||||
|
*
|
||||||
|
* URL params:
|
||||||
|
* - `companyPrefix` — the company slug (for breadcrumb links).
|
||||||
|
* - `pluginId` — UUID of the plugin to display.
|
||||||
|
*
|
||||||
|
* @see PluginManager — parent list page.
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §13 — Plugin Health Checks.
|
||||||
|
* @see doc/plugins/PLUGIN_SPEC.md §19.8 — Plugin Settings UI.
|
||||||
|
*/
|
||||||
|
export function PluginSettings() {
|
||||||
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const { companyPrefix, pluginId } = useParams<{ companyPrefix?: string; pluginId: string }>();
|
||||||
|
const [activeTab, setActiveTab] = useState<"configuration" | "status">("configuration");
|
||||||
|
|
||||||
|
const { data: plugin, isLoading: pluginLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.detail(pluginId!),
|
||||||
|
queryFn: () => pluginsApi.get(pluginId!),
|
||||||
|
enabled: !!pluginId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: healthData, isLoading: healthLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.health(pluginId!),
|
||||||
|
queryFn: () => pluginsApi.health(pluginId!),
|
||||||
|
enabled: !!pluginId && plugin?.status === "ready",
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dashboardData } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.dashboard(pluginId!),
|
||||||
|
queryFn: () => pluginsApi.dashboard(pluginId!),
|
||||||
|
enabled: !!pluginId,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: recentLogs } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.logs(pluginId!),
|
||||||
|
queryFn: () => pluginsApi.logs(pluginId!, { limit: 50 }),
|
||||||
|
enabled: !!pluginId && plugin?.status === "ready",
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch existing config for the plugin
|
||||||
|
const configSchema = plugin?.manifestJson?.instanceConfigSchema as JsonSchemaNode | undefined;
|
||||||
|
const hasConfigSchema = configSchema && configSchema.properties && Object.keys(configSchema.properties).length > 0;
|
||||||
|
|
||||||
|
const { data: configData, isLoading: configLoading } = useQuery({
|
||||||
|
queryKey: queryKeys.plugins.config(pluginId!),
|
||||||
|
queryFn: () => pluginsApi.getConfig(pluginId!),
|
||||||
|
enabled: !!pluginId && !!hasConfigSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { slots } = usePluginSlots({
|
||||||
|
slotTypes: ["settingsPage"],
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter slots to only show settings pages for this specific plugin
|
||||||
|
const pluginSlots = slots.filter((slot) => slot.pluginId === pluginId);
|
||||||
|
|
||||||
|
// If the plugin has a custom settingsPage slot, prefer that over auto-generated form
|
||||||
|
const hasCustomSettingsPage = pluginSlots.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||||
|
{ label: "Settings", href: "/instance/settings/heartbeats" },
|
||||||
|
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||||
|
{ label: plugin?.manifestJson?.displayName ?? plugin?.packageName ?? "Plugin Details" },
|
||||||
|
]);
|
||||||
|
}, [selectedCompany?.name, setBreadcrumbs, companyPrefix, plugin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab("configuration");
|
||||||
|
}, [pluginId]);
|
||||||
|
|
||||||
|
if (pluginLoading) {
|
||||||
|
return <div className="p-4 text-sm text-muted-foreground">Loading plugin details...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return <Navigate to="/instance/settings/plugins" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayStatus = plugin.status;
|
||||||
|
const statusVariant =
|
||||||
|
plugin.status === "ready"
|
||||||
|
? "default"
|
||||||
|
: plugin.status === "error"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary";
|
||||||
|
const pluginDescription = plugin.manifestJson.description || "No description provided.";
|
||||||
|
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-5xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/instance/settings/plugins">
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Puzzle className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<h1 className="text-xl font-semibold">{plugin.manifestJson.displayName ?? plugin.packageName}</h1>
|
||||||
|
<Badge variant={statusVariant} className="ml-2">
|
||||||
|
{displayStatus}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="ml-1">
|
||||||
|
v{plugin.manifestJson.version ?? plugin.version}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "configuration" | "status")} className="space-y-6">
|
||||||
|
<PageTabBar
|
||||||
|
align="start"
|
||||||
|
items={[
|
||||||
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
{ value: "status", label: "Status" },
|
||||||
|
]}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as "configuration" | "status")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabsContent value="configuration" className="space-y-6">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-5">
|
||||||
|
<h2 className="text-base font-semibold">About</h2>
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1.4fr)_minmax(220px,0.8fr)]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
|
||||||
|
<p className="text-sm leading-6 text-foreground/90">{pluginDescription}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h3 className="font-medium text-muted-foreground">Author</h3>
|
||||||
|
<p className="text-foreground">{plugin.manifestJson.author}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-muted-foreground">Categories</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{plugin.categories.length > 0 ? (
|
||||||
|
plugin.categories.map((category) => (
|
||||||
|
<Badge key={category} variant="outline" className="capitalize">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-foreground">None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-base font-semibold">Settings</h2>
|
||||||
|
</div>
|
||||||
|
{hasCustomSettingsPage ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pluginSlots.map((slot) => (
|
||||||
|
<PluginSlotMount
|
||||||
|
key={`${slot.pluginKey}:${slot.id}`}
|
||||||
|
slot={slot}
|
||||||
|
context={{
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
companyPrefix: companyPrefix ?? null,
|
||||||
|
}}
|
||||||
|
missingBehavior="placeholder"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : hasConfigSchema ? (
|
||||||
|
<PluginConfigForm
|
||||||
|
pluginId={pluginId!}
|
||||||
|
schema={configSchema!}
|
||||||
|
initialValues={configData?.configJson}
|
||||||
|
isLoading={configLoading}
|
||||||
|
pluginStatus={plugin.status}
|
||||||
|
supportsConfigTest={(plugin as unknown as { supportsConfigTest?: boolean }).supportsConfigTest === true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This plugin does not require any settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="status" className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-1.5">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
Runtime Dashboard
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Worker process, scheduled jobs, and webhook deliveries
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{dashboardData ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Worker Process
|
||||||
|
</h3>
|
||||||
|
{dashboardData.worker ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Status</span>
|
||||||
|
<Badge variant={dashboardData.worker.status === "running" ? "default" : "secondary"}>
|
||||||
|
{dashboardData.worker.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">PID</span>
|
||||||
|
<span className="font-mono text-xs">{dashboardData.worker.pid ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Uptime</span>
|
||||||
|
<span className="text-xs">{formatUptime(dashboardData.worker.uptime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Pending RPCs</span>
|
||||||
|
<span className="text-xs">{dashboardData.worker.pendingRequests}</span>
|
||||||
|
</div>
|
||||||
|
{dashboardData.worker.totalCrashes > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between col-span-2">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-amber-500" />
|
||||||
|
Crashes
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{dashboardData.worker.consecutiveCrashes} consecutive / {dashboardData.worker.totalCrashes} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dashboardData.worker.lastCrashAt && (
|
||||||
|
<div className="flex justify-between col-span-2">
|
||||||
|
<span className="text-muted-foreground">Last Crash</span>
|
||||||
|
<span className="text-xs">{formatTimestamp(dashboardData.worker.lastCrashAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No worker process registered.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Recent Job Runs
|
||||||
|
</h3>
|
||||||
|
{dashboardData.recentJobRuns.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dashboardData.recentJobRuns.map((run) => (
|
||||||
|
<div
|
||||||
|
key={run.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<JobStatusDot status={run.status} />
|
||||||
|
<span className="truncate font-mono text-xs" title={run.jobKey ?? run.jobId}>
|
||||||
|
{run.jobKey ?? run.jobId.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="px-1 py-0 text-[10px]">
|
||||||
|
{run.trigger}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{run.durationMs != null ? <span>{formatDuration(run.durationMs)}</span> : null}
|
||||||
|
<span title={run.createdAt}>{formatRelativeTime(run.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No job runs recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Recent Webhook Deliveries
|
||||||
|
</h3>
|
||||||
|
{dashboardData.recentWebhookDeliveries.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dashboardData.recentWebhookDeliveries.map((delivery) => (
|
||||||
|
<div
|
||||||
|
key={delivery.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<DeliveryStatusDot status={delivery.status} />
|
||||||
|
<span className="truncate font-mono text-xs" title={delivery.webhookKey}>
|
||||||
|
{delivery.webhookKey}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{delivery.durationMs != null ? <span>{formatDuration(delivery.durationMs)}</span> : null}
|
||||||
|
<span title={delivery.createdAt}>{formatRelativeTime(delivery.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No webhook deliveries recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 border-t border-border/50 pt-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
Last checked: {new Date(dashboardData.checkedAt).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Runtime diagnostics are unavailable right now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{recentLogs && recentLogs.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-1.5">
|
||||||
|
<ActivitySquare className="h-4 w-4" />
|
||||||
|
Recent Logs
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Last {recentLogs.length} log entries</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="max-h-64 space-y-1 overflow-y-auto font-mono text-xs">
|
||||||
|
{recentLogs.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`flex gap-2 py-0.5 ${
|
||||||
|
entry.level === "error"
|
||||||
|
? "text-destructive"
|
||||||
|
: entry.level === "warn"
|
||||||
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
|
: entry.level === "debug"
|
||||||
|
? "text-muted-foreground/60"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-muted-foreground/50">{new Date(entry.createdAt).toLocaleTimeString()}</span>
|
||||||
|
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">{entry.level}</Badge>
|
||||||
|
<span className="truncate" title={entry.message}>{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-1.5">
|
||||||
|
<ActivitySquare className="h-4 w-4" />
|
||||||
|
Health Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{healthLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Checking health...</p>
|
||||||
|
) : healthData ? (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">Overall</span>
|
||||||
|
<Badge variant={healthData.healthy ? "default" : "destructive"}>
|
||||||
|
{healthData.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{healthData.checks.length > 0 ? (
|
||||||
|
<div className="space-y-2 border-t border-border/50 pt-2">
|
||||||
|
{healthData.checks.map((check, i) => (
|
||||||
|
<div key={i} className="flex items-start justify-between gap-2">
|
||||||
|
<span className="truncate text-muted-foreground" title={check.name}>
|
||||||
|
{check.name}
|
||||||
|
</span>
|
||||||
|
{check.passed ? (
|
||||||
|
<CheckCircle className="h-4 w-4 shrink-0 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{healthData.lastError ? (
|
||||||
|
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
||||||
|
{healthData.lastError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Lifecycle</span>
|
||||||
|
<Badge variant={statusVariant}>{displayStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<p>Health checks run once the plugin is ready.</p>
|
||||||
|
{plugin.lastError ? (
|
||||||
|
<div className="break-words rounded border border-destructive/20 bg-destructive/10 p-2 text-xs text-destructive">
|
||||||
|
{plugin.lastError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>Plugin ID</span>
|
||||||
|
<span className="font-mono text-xs text-right">{plugin.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>Plugin Key</span>
|
||||||
|
<span className="font-mono text-xs text-right">{plugin.pluginKey}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>NPM Package</span>
|
||||||
|
<span className="max-w-[170px] truncate text-right text-xs" title={plugin.packageName}>
|
||||||
|
{plugin.packageName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span>Version</span>
|
||||||
|
<span className="text-right text-foreground">v{plugin.manifestJson.version ?? plugin.version}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-1.5">
|
||||||
|
<ShieldAlert className="h-4 w-4" />
|
||||||
|
Permissions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{pluginCapabilities.length > 0 ? (
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
{pluginCapabilities.map((cap) => (
|
||||||
|
<li key={cap} className="rounded-md bg-muted/40 px-2.5 py-2 font-mono text-xs text-foreground/85">
|
||||||
|
{cap}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">No special permissions requested.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PluginConfigFormProps {
|
||||||
|
pluginId: string;
|
||||||
|
schema: JsonSchemaNode;
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Current plugin lifecycle status — "Test Configuration" only available when `ready`. */
|
||||||
|
pluginStatus?: string;
|
||||||
|
/** Whether the plugin worker implements `validateConfig`. */
|
||||||
|
supportsConfigTest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner component that manages form state, validation, save, and "Test Configuration"
|
||||||
|
* for the auto-generated plugin config form.
|
||||||
|
*
|
||||||
|
* Separated from PluginSettings to isolate re-render scope — only the form
|
||||||
|
* re-renders on field changes, not the entire page.
|
||||||
|
*/
|
||||||
|
function PluginConfigForm({ pluginId, schema, initialValues, isLoading, pluginStatus, supportsConfigTest }: PluginConfigFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Form values: start with saved values, fall back to schema defaults
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>(() => ({
|
||||||
|
...getDefaultValues(schema),
|
||||||
|
...(initialValues ?? {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sync when saved config loads asynchronously — only on first load so we
|
||||||
|
// don't overwrite in-progress user edits if the query refetches (e.g. on
|
||||||
|
// window focus).
|
||||||
|
const hasHydratedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValues && !hasHydratedRef.current) {
|
||||||
|
hasHydratedRef.current = true;
|
||||||
|
setValues({
|
||||||
|
...getDefaultValues(schema),
|
||||||
|
...initialValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialValues, schema]);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saveMessage, setSaveMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
const [testResult, setTestResult] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Dirty tracking: compare against initial values
|
||||||
|
const isDirty = JSON.stringify(values) !== JSON.stringify({
|
||||||
|
...getDefaultValues(schema),
|
||||||
|
...(initialValues ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save mutation
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (configJson: Record<string, unknown>) =>
|
||||||
|
pluginsApi.saveConfig(pluginId, configJson),
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaveMessage({ type: "success", text: "Configuration saved." });
|
||||||
|
setTestResult(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.config(pluginId) });
|
||||||
|
// Clear success message after 3s
|
||||||
|
setTimeout(() => setSaveMessage(null), 3000);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setSaveMessage({ type: "error", text: err.message || "Failed to save configuration." });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test configuration mutation
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (configJson: Record<string, unknown>) =>
|
||||||
|
pluginsApi.testConfig(pluginId, configJson),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.valid) {
|
||||||
|
setTestResult({ type: "success", text: "Configuration test passed." });
|
||||||
|
} else {
|
||||||
|
setTestResult({ type: "error", text: result.message || "Configuration test failed." });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setTestResult({ type: "error", text: err.message || "Configuration test failed." });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = useCallback((newValues: Record<string, unknown>) => {
|
||||||
|
setValues(newValues);
|
||||||
|
// Clear field-level errors as the user types
|
||||||
|
setErrors({});
|
||||||
|
setSaveMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
// Validate before saving
|
||||||
|
const validationErrors = validateJsonSchemaForm(schema, values);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
saveMutation.mutate(values);
|
||||||
|
}, [schema, values, saveMutation]);
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(() => {
|
||||||
|
// Validate before testing
|
||||||
|
const validationErrors = validateJsonSchemaForm(schema, values);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
setTestResult(null);
|
||||||
|
testMutation.mutate(values);
|
||||||
|
}, [schema, values, testMutation]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading configuration...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<JsonSchemaForm
|
||||||
|
schema={schema}
|
||||||
|
values={values}
|
||||||
|
onChange={handleChange}
|
||||||
|
errors={errors}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status messages */}
|
||||||
|
{saveMessage && (
|
||||||
|
<div
|
||||||
|
className={`text-sm p-2 rounded border ${
|
||||||
|
saveMessage.type === "success"
|
||||||
|
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
||||||
|
: "text-destructive bg-destructive/10 border-destructive/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saveMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`text-sm p-2 rounded border ${
|
||||||
|
testResult.type === "success"
|
||||||
|
? "text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/30 dark:border-green-900"
|
||||||
|
: "text-destructive bg-destructive/10 border-destructive/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending || !isDirty}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Configuration"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{pluginStatus === "ready" && supportsConfigTest && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{testMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Test Configuration"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard helper components and formatting utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an uptime value (in milliseconds) to a human-readable string.
|
||||||
|
*/
|
||||||
|
function formatUptime(uptimeMs: number | null): string {
|
||||||
|
if (uptimeMs == null) return "—";
|
||||||
|
const totalSeconds = Math.floor(uptimeMs / 1000);
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ${hours % 24}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration in milliseconds to a compact display string.
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${(ms / 60000).toFixed(1)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp to a relative time string (e.g., "2m ago").
|
||||||
|
*/
|
||||||
|
function formatRelativeTime(isoString: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(isoString).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
|
||||||
|
if (diffMs < 0) return "just now";
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a unix timestamp (ms since epoch) to a locale string.
|
||||||
|
*/
|
||||||
|
function formatTimestamp(epochMs: number): string {
|
||||||
|
return new Date(epochMs).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status indicator dot for job run statuses.
|
||||||
|
*/
|
||||||
|
function JobStatusDot({ status }: { status: string }) {
|
||||||
|
const colorClass =
|
||||||
|
status === "success" || status === "succeeded"
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "failed"
|
||||||
|
? "bg-red-500"
|
||||||
|
: status === "running"
|
||||||
|
? "bg-blue-500 animate-pulse"
|
||||||
|
: status === "cancelled"
|
||||||
|
? "bg-gray-400"
|
||||||
|
: "bg-amber-500"; // queued, pending
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
||||||
|
title={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status indicator dot for webhook delivery statuses.
|
||||||
|
*/
|
||||||
|
function DeliveryStatusDot({ status }: { status: string }) {
|
||||||
|
const colorClass =
|
||||||
|
status === "processed" || status === "success"
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "failed"
|
||||||
|
? "bg-red-500"
|
||||||
|
: status === "received"
|
||||||
|
? "bg-blue-500"
|
||||||
|
: "bg-amber-500"; // pending
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
||||||
|
title={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,17 @@ import { PageSkeleton } from "../components/PageSkeleton";
|
|||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { projectRouteRef, cn } from "../lib/utils";
|
import { projectRouteRef, cn } from "../lib/utils";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
type ProjectTab = "overview" | "list" | "configuration";
|
type ProjectBaseTab = "overview" | "list" | "configuration";
|
||||||
|
type ProjectPluginTab = `plugin:${string}`;
|
||||||
|
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||||
|
|
||||||
|
function isProjectPluginTab(value: string | null): value is ProjectPluginTab {
|
||||||
|
return typeof value === "string" && value.startsWith("plugin:");
|
||||||
|
}
|
||||||
|
|
||||||
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
function resolveProjectTab(pathname: string, projectId: string): ProjectTab | null {
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
@@ -213,8 +220,12 @@ export function ProjectDetail() {
|
|||||||
}, [companies, companyPrefix]);
|
}, [companies, companyPrefix]);
|
||||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||||
|
const activeRouteTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||||||
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
const pluginTabFromSearch = useMemo(() => {
|
||||||
|
const tab = new URLSearchParams(location.search).get("tab");
|
||||||
|
return isProjectPluginTab(tab) ? tab : null;
|
||||||
|
}, [location.search]);
|
||||||
|
const activeTab = activeRouteTab ?? pluginTabFromSearch;
|
||||||
|
|
||||||
const { data: project, isLoading, error } = useQuery({
|
const { data: project, isLoading, error } = useQuery({
|
||||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||||
@@ -224,6 +235,24 @@ export function ProjectDetail() {
|
|||||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||||
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
|
||||||
|
const {
|
||||||
|
slots: pluginDetailSlots,
|
||||||
|
isLoading: pluginDetailSlotsLoading,
|
||||||
|
} = usePluginSlots({
|
||||||
|
slotTypes: ["detailTab"],
|
||||||
|
entityType: "project",
|
||||||
|
companyId: resolvedCompanyId,
|
||||||
|
enabled: !!resolvedCompanyId,
|
||||||
|
});
|
||||||
|
const pluginTabItems = useMemo(
|
||||||
|
() => pluginDetailSlots.map((slot) => ({
|
||||||
|
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectPluginTab,
|
||||||
|
label: slot.displayName,
|
||||||
|
slot,
|
||||||
|
})),
|
||||||
|
[pluginDetailSlots],
|
||||||
|
);
|
||||||
|
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||||
@@ -261,6 +290,10 @@ export function ProjectDetail() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
if (routeProjectRef === canonicalProjectRef) return;
|
if (routeProjectRef === canonicalProjectRef) return;
|
||||||
|
if (isProjectPluginTab(activeTab)) {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTab === "overview") {
|
if (activeTab === "overview") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
||||||
return;
|
return;
|
||||||
@@ -328,6 +361,10 @@ export function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
}, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]);
|
||||||
|
|
||||||
|
if (pluginTabFromSearch && !pluginDetailSlotsLoading && !activePluginTab) {
|
||||||
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect bare /projects/:id to /projects/:id/issues
|
// Redirect bare /projects/:id to /projects/:id/issues
|
||||||
if (routeProjectRef && activeTab === null) {
|
if (routeProjectRef && activeTab === null) {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||||
@@ -338,6 +375,10 @@ export function ProjectDetail() {
|
|||||||
if (!project) return null;
|
if (!project) return null;
|
||||||
|
|
||||||
const handleTabChange = (tab: ProjectTab) => {
|
const handleTabChange = (tab: ProjectTab) => {
|
||||||
|
if (isProjectPluginTab(tab)) {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (tab === "overview") {
|
if (tab === "overview") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||||
} else if (tab === "configuration") {
|
} else if (tab === "configuration") {
|
||||||
@@ -370,6 +411,10 @@ export function ProjectDetail() {
|
|||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
{ value: "list", label: "List" },
|
{ value: "list", label: "List" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
|
...pluginTabItems.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
})),
|
||||||
]}
|
]}
|
||||||
align="start"
|
align="start"
|
||||||
value={activeTab ?? "list"}
|
value={activeTab ?? "list"}
|
||||||
@@ -402,6 +447,21 @@ export function ProjectDetail() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activePluginTab && (
|
||||||
|
<PluginSlotMount
|
||||||
|
slot={activePluginTab.slot}
|
||||||
|
context={{
|
||||||
|
companyId: resolvedCompanyId,
|
||||||
|
companyPrefix: companyPrefix ?? null,
|
||||||
|
projectId: project.id,
|
||||||
|
projectRef: canonicalProjectRef,
|
||||||
|
entityId: project.id,
|
||||||
|
entityType: "project",
|
||||||
|
}}
|
||||||
|
missingBehavior="placeholder"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
116
ui/src/plugins/bridge-init.ts
Normal file
116
ui/src/plugins/bridge-init.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Plugin bridge initialization.
|
||||||
|
*
|
||||||
|
* Registers the host's React instances and bridge hook implementations
|
||||||
|
* on a global object so that the plugin module loader can inject them
|
||||||
|
* into plugin UI bundles at load time.
|
||||||
|
*
|
||||||
|
* Call `initPluginBridge()` once during app startup (in `main.tsx`), before
|
||||||
|
* any plugin UI modules are loaded.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||||
|
* @see PLUGIN_SPEC.md §19.0.2 — Bundle Isolation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
usePluginData,
|
||||||
|
usePluginAction,
|
||||||
|
useHostContext,
|
||||||
|
} from "./bridge.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global bridge registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global bridge registry shape.
|
||||||
|
*
|
||||||
|
* This is placed on `globalThis.__paperclipPluginBridge__` and consumed by
|
||||||
|
* the plugin module loader to provide implementations for external imports.
|
||||||
|
*/
|
||||||
|
export interface PluginBridgeRegistry {
|
||||||
|
react: unknown;
|
||||||
|
reactDom: unknown;
|
||||||
|
sdkUi: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the plugin bridge global registry.
|
||||||
|
*
|
||||||
|
* Registers the host's React, ReactDOM, and SDK UI bridge implementations
|
||||||
|
* on `globalThis.__paperclipPluginBridge__` so the plugin module loader
|
||||||
|
* can provide them to plugin bundles.
|
||||||
|
*
|
||||||
|
* @param react - The host's React module
|
||||||
|
* @param reactDom - The host's ReactDOM module
|
||||||
|
*/
|
||||||
|
export function initPluginBridge(
|
||||||
|
react: typeof import("react"),
|
||||||
|
reactDom: typeof import("react-dom"),
|
||||||
|
): void {
|
||||||
|
globalThis.__paperclipPluginBridge__ = {
|
||||||
|
react,
|
||||||
|
reactDom,
|
||||||
|
sdkUi: {
|
||||||
|
// Bridge hooks
|
||||||
|
usePluginData,
|
||||||
|
usePluginAction,
|
||||||
|
useHostContext,
|
||||||
|
|
||||||
|
// Placeholder shared UI components — plugins that use these will get
|
||||||
|
// functional stubs. Full implementations matching the host's design
|
||||||
|
// system can be added later.
|
||||||
|
MetricCard: createStubComponent("MetricCard"),
|
||||||
|
StatusBadge: createStubComponent("StatusBadge"),
|
||||||
|
DataTable: createStubComponent("DataTable"),
|
||||||
|
TimeseriesChart: createStubComponent("TimeseriesChart"),
|
||||||
|
MarkdownBlock: createStubComponent("MarkdownBlock"),
|
||||||
|
KeyValueList: createStubComponent("KeyValueList"),
|
||||||
|
ActionBar: createStubComponent("ActionBar"),
|
||||||
|
LogView: createStubComponent("LogView"),
|
||||||
|
JsonTree: createStubComponent("JsonTree"),
|
||||||
|
Spinner: createStubComponent("Spinner"),
|
||||||
|
ErrorBoundary: createPassthroughComponent("ErrorBoundary"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub component helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStubComponent(name: string): unknown {
|
||||||
|
const fn = (props: Record<string, unknown>) => {
|
||||||
|
// Import React from the registry to avoid import issues
|
||||||
|
const React = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
|
||||||
|
if (!React) return null;
|
||||||
|
return React.createElement("div", {
|
||||||
|
"data-plugin-component": name,
|
||||||
|
style: {
|
||||||
|
padding: "8px",
|
||||||
|
border: "1px dashed #666",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#888",
|
||||||
|
},
|
||||||
|
}, `[${name}]`);
|
||||||
|
};
|
||||||
|
Object.defineProperty(fn, "name", { value: name });
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPassthroughComponent(name: string): unknown {
|
||||||
|
const fn = (props: { children?: ReactNode }) => {
|
||||||
|
const ReactLib = globalThis.__paperclipPluginBridge__?.react as typeof import("react") | undefined;
|
||||||
|
if (!ReactLib) return null;
|
||||||
|
return ReactLib.createElement(ReactLib.Fragment, null, props.children);
|
||||||
|
};
|
||||||
|
Object.defineProperty(fn, "name", { value: name });
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user