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).
|
||||
It is the full target architecture for the plugin system that should follow V1.
|
||||
|
||||
## Current implementation caveats
|
||||
|
||||
The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
|
||||
|
||||
Today, the practical deployment model is:
|
||||
|
||||
- single-tenant
|
||||
- self-hosted
|
||||
- single-node or otherwise filesystem-persistent
|
||||
|
||||
Current limitations to keep in mind:
|
||||
|
||||
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
|
||||
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This spec covers:
|
||||
@@ -212,6 +232,8 @@ Suggested layout:
|
||||
|
||||
The package install directory and the plugin data directory are separate.
|
||||
|
||||
This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
|
||||
|
||||
## 8.2 Operator Commands
|
||||
|
||||
Paperclip should add CLI commands:
|
||||
@@ -237,6 +259,8 @@ The install process is:
|
||||
7. Start plugin worker and run health/validation.
|
||||
8. Mark plugin `ready` or `error`.
|
||||
|
||||
For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
|
||||
|
||||
## 9. Load Order And Precedence
|
||||
|
||||
Load order must be deterministic.
|
||||
|
||||
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,
|
||||
"tag": "0027_tranquil_tenebrous",
|
||||
"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 { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
export { pluginState } from "./plugin_state.js";
|
||||
export { pluginEntities } from "./plugin_entities.js";
|
||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
|
||||
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||
* scoped to a specific company.
|
||||
*
|
||||
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||
* configuration. Each company can have at most one settings row per plugin.
|
||||
*
|
||||
* Rows represent explicit overrides from the default company behavior:
|
||||
* - no row => plugin is enabled for the company by default
|
||||
* - row with `enabled = false` => plugin is disabled for that company
|
||||
* - row with `enabled = true` => plugin remains enabled and stores company settings
|
||||
*/
|
||||
export const pluginCompanySettings = pgTable(
|
||||
"plugin_company_settings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
|
||||
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
|
||||
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
30
packages/db/src/schema/plugin_config.ts
Normal file
30
packages/db/src/schema/plugin_config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_config` table — stores operator-provided instance configuration
|
||||
* for each plugin (one row per plugin, enforced by a unique index on
|
||||
* `plugin_id`).
|
||||
*
|
||||
* The `config_json` column holds the values that the operator enters in the
|
||||
* plugin settings UI. These values are validated at runtime against the
|
||||
* plugin's `instanceConfigSchema` from the manifest.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginConfig = pgTable(
|
||||
"plugin_config",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||
}),
|
||||
);
|
||||
54
packages/db/src/schema/plugin_entities.ts
Normal file
54
packages/db/src/schema/plugin_entities.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_entities` table — persistent high-level mapping between Paperclip
|
||||
* objects and external plugin-defined entities.
|
||||
*
|
||||
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
|
||||
* to their respective external IDs for projects, issues, etc. and to store
|
||||
* their custom data.
|
||||
*
|
||||
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
|
||||
* is intended for structured object mappings that the host can understand
|
||||
* and query for cross-plugin UI integration.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginEntities = pgTable(
|
||||
"plugin_entities",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||
externalId: text("external_id"), // ID in the external system
|
||||
title: text("title"),
|
||||
status: text("status"),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
102
packages/db/src/schema/plugin_jobs.ts
Normal file
102
packages/db/src/schema/plugin_jobs.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_jobs` table — registration and runtime configuration for
|
||||
* scheduled jobs declared by plugins in their manifests.
|
||||
*
|
||||
* Each row represents one scheduled job entry for a plugin. The
|
||||
* `job_key` matches the key declared in the manifest's `jobs` array.
|
||||
* The `schedule` column stores the cron expression or interval string
|
||||
* used by the job scheduler to decide when to fire the job.
|
||||
*
|
||||
* Status values:
|
||||
* - `active` — job is enabled and will run on schedule
|
||||
* - `paused` — job is temporarily disabled by the operator
|
||||
* - `error` — job has been disabled due to repeated failures
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
|
||||
*/
|
||||
export const pluginJobs = pgTable(
|
||||
"plugin_jobs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `jobs` array. */
|
||||
jobKey: text("job_key").notNull(),
|
||||
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
|
||||
schedule: text("schedule").notNull(),
|
||||
/** Current scheduling state. */
|
||||
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
|
||||
/** Timestamp of the most recent successful execution. */
|
||||
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
|
||||
/** Pre-computed timestamp of the next scheduled execution. */
|
||||
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
|
||||
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
|
||||
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
|
||||
*
|
||||
* Each row is created when a job run begins and updated when it completes.
|
||||
* Rows are never modified after `status` reaches a terminal value
|
||||
* (`succeeded` | `failed` | `cancelled`).
|
||||
*
|
||||
* Trigger values:
|
||||
* - `scheduled` — fired automatically by the cron/interval scheduler
|
||||
* - `manual` — triggered by an operator via the admin UI or API
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
|
||||
*/
|
||||
export const pluginJobRuns = pgTable(
|
||||
"plugin_job_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the parent job definition. Cascades on delete. */
|
||||
jobId: uuid("job_id")
|
||||
.notNull()
|
||||
.references(() => pluginJobs.id, { onDelete: "cascade" }),
|
||||
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Current lifecycle state of this run. */
|
||||
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Ordered list of log lines emitted during this run. */
|
||||
logs: jsonb("logs").$type<string[]>().notNull().default([]),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/plugin_logs.ts
Normal file
43
packages/db/src/schema/plugin_logs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_logs` table — structured log storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single log entry emitted by a plugin worker via
|
||||
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
|
||||
* time range to support the operator logs panel and debugging workflows.
|
||||
*
|
||||
* Rows are inserted by the host when handling `log` notifications from
|
||||
* the worker process. A capped retention policy can be applied via
|
||||
* periodic cleanup (e.g. delete rows older than 7 days).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §26 — Observability
|
||||
*/
|
||||
export const pluginLogs = pgTable(
|
||||
"plugin_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
level: text("level").notNull().default("info"),
|
||||
message: text("message").notNull(),
|
||||
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
|
||||
table.pluginId,
|
||||
table.createdAt,
|
||||
),
|
||||
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||
}),
|
||||
);
|
||||
90
packages/db/src/schema/plugin_state.ts
Normal file
90
packages/db/src/schema/plugin_state.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_state` table — scoped key-value storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single JSON value identified by
|
||||
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
|
||||
* this table through `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` in the SDK.
|
||||
*
|
||||
* Scope kinds determine the granularity of isolation:
|
||||
* - `instance` — one value shared across the whole Paperclip instance
|
||||
* - `company` — one value per company
|
||||
* - `project` — one value per project
|
||||
* - `project_workspace` — one value per project workspace
|
||||
* - `agent` — one value per agent
|
||||
* - `issue` — one value per issue
|
||||
* - `goal` — one value per goal
|
||||
* - `run` — one value per agent run
|
||||
*
|
||||
* The `namespace` column defaults to `"default"` and can be used to
|
||||
* logically group keys without polluting the root namespace.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export const pluginState = pgTable(
|
||||
"plugin_state",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* Null for `instance` scope (which has no associated entity).
|
||||
*/
|
||||
scopeId: text("scope_id"),
|
||||
/**
|
||||
* Sub-namespace to avoid key collisions within a scope.
|
||||
* Defaults to `"default"` if the plugin does not specify one.
|
||||
*/
|
||||
namespace: text("namespace").notNull().default("default"),
|
||||
/** The key identifying this state entry within the namespace. */
|
||||
stateKey: text("state_key").notNull(),
|
||||
/** JSON-serializable value stored by the plugin. */
|
||||
valueJson: jsonb("value_json").notNull(),
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
/**
|
||||
* Unique constraint enforces that there is at most one value per
|
||||
* (plugin, scope kind, scope id, namespace, key) tuple.
|
||||
*
|
||||
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
|
||||
* (used by `instance` scope) are treated as equal by PostgreSQL rather
|
||||
* than as distinct nulls — otherwise the upsert target in `set()` would
|
||||
* fail to match existing rows and create duplicates.
|
||||
*
|
||||
* Requires PostgreSQL 15+.
|
||||
*/
|
||||
uniqueEntry: unique("plugin_state_unique_entry_idx")
|
||||
.on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
table.scopeId,
|
||||
table.namespace,
|
||||
table.stateKey,
|
||||
)
|
||||
.nullsNotDistinct(),
|
||||
/** Speed up lookups by plugin + scope kind (most common access pattern). */
|
||||
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
),
|
||||
}),
|
||||
);
|
||||
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
|
||||
*
|
||||
* When an external system sends an HTTP POST to a plugin's registered webhook
|
||||
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
|
||||
* creates a row in this table before dispatching the payload to the plugin
|
||||
* worker. This provides an auditable log of every delivery attempt.
|
||||
*
|
||||
* The `webhook_key` matches the key declared in the plugin manifest's
|
||||
* `webhooks` array. `external_id` is an optional identifier supplied by the
|
||||
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
|
||||
* and reject duplicate deliveries.
|
||||
*
|
||||
* Status values:
|
||||
* - `pending` — received but not yet dispatched to the worker
|
||||
* - `processing` — currently being handled by the plugin worker
|
||||
* - `succeeded` — worker processed the payload successfully
|
||||
* - `failed` — worker returned an error or timed out
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
|
||||
*/
|
||||
export const pluginWebhookDeliveries = pgTable(
|
||||
"plugin_webhook_deliveries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||
webhookKey: text("webhook_key").notNull(),
|
||||
/** Optional de-duplication ID provided by the external system. */
|
||||
externalId: text("external_id"),
|
||||
/** Current delivery state. */
|
||||
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
|
||||
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Raw JSON body of the inbound HTTP request. */
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||
}),
|
||||
);
|
||||
45
packages/db/src/schema/plugins.ts
Normal file
45
packages/db/src/schema/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugins` table — stores one row per installed plugin.
|
||||
*
|
||||
* Each plugin is uniquely identified by `plugin_key` (derived from
|
||||
* the manifest `id`). The full manifest is persisted as JSONB in
|
||||
* `manifest_json` so the host can reconstruct capability and UI
|
||||
* slot information without loading the plugin package.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const plugins = pgTable(
|
||||
"plugins",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
packageName: text("package_name").notNull(),
|
||||
version: text("version").notNull(),
|
||||
apiVersion: integer("api_version").notNull().default(1),
|
||||
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
|
||||
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
|
||||
status: text("status").$type<PluginStatus>().notNull().default("installed"),
|
||||
installOrder: integer("install_order"),
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: text("package_path"),
|
||||
lastError: text("last_error"),
|
||||
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
|
||||
statusIdx: index("plugins_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
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",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
@@ -213,6 +212,9 @@ export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.log",
|
||||
"agent.status",
|
||||
"activity.logged",
|
||||
"plugin.ui.updated",
|
||||
"plugin.worker.crashed",
|
||||
"plugin.worker.restarted",
|
||||
] as const;
|
||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||
|
||||
@@ -246,3 +248,311 @@ export const PERMISSION_KEYS = [
|
||||
"joins:approve",
|
||||
] as const;
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The current version of the Plugin API contract.
|
||||
*
|
||||
* Increment this value whenever a breaking change is made to the plugin API
|
||||
* so that the host can reject incompatible plugin manifests.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §4 — Versioning
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Lifecycle statuses for an installed plugin.
|
||||
*
|
||||
* State machine: installed → ready | error, ready → disabled | error | upgrade_pending | uninstalled,
|
||||
* disabled → ready | uninstalled, error → ready | uninstalled,
|
||||
* upgrade_pending → ready | error | uninstalled, uninstalled → installed (reinstall).
|
||||
*
|
||||
* @see {@link PluginStatus} — inferred union type
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugins.status`
|
||||
*/
|
||||
export const PLUGIN_STATUSES = [
|
||||
"installed",
|
||||
"ready",
|
||||
"disabled",
|
||||
"error",
|
||||
"upgrade_pending",
|
||||
"uninstalled",
|
||||
] as const;
|
||||
export type PluginStatus = (typeof PLUGIN_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Plugin classification categories. A plugin declares one or more categories
|
||||
* in its manifest to describe its primary purpose.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §6.2
|
||||
*/
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"connector",
|
||||
"workspace",
|
||||
"automation",
|
||||
"ui",
|
||||
] as const;
|
||||
export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Named permissions the host grants to a plugin. Plugins declare required
|
||||
* capabilities in their manifest; the host enforces them at runtime via the
|
||||
* plugin capability validator.
|
||||
*
|
||||
* Grouped into: Data Read, Data Write, Plugin State, Runtime/Integration,
|
||||
* Agent Tools, and UI.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
export const PLUGIN_CAPABILITIES = [
|
||||
// Data Read
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issue.comments.read",
|
||||
"agents.read",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.read",
|
||||
"costs.read",
|
||||
// Data Write
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.create",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"assets.write",
|
||||
"assets.read",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
// Runtime / Integration
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
] as const;
|
||||
export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number];
|
||||
|
||||
/**
|
||||
* UI extension slot types. Each slot type corresponds to a mount point in the
|
||||
* Paperclip UI where plugin components can be rendered.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Launcher placement zones describe where a plugin-owned launcher can appear
|
||||
* in the host UI. These are intentionally aligned with current slot surfaces
|
||||
* so manifest authors can describe launch intent without coupling to a single
|
||||
* component implementation detail.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_PLACEMENT_ZONES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginLauncherPlacementZone = (typeof PLUGIN_LAUNCHER_PLACEMENT_ZONES)[number];
|
||||
|
||||
/**
|
||||
* Launcher action kinds describe what the launcher does when activated.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_ACTIONS = [
|
||||
"navigate",
|
||||
"openModal",
|
||||
"openDrawer",
|
||||
"openPopover",
|
||||
"performAction",
|
||||
"deepLink",
|
||||
] as const;
|
||||
export type PluginLauncherAction = (typeof PLUGIN_LAUNCHER_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* Optional size hints the host can use when rendering plugin-owned launcher
|
||||
* destinations such as overlays, drawers, or full page handoffs.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_BOUNDS = [
|
||||
"inline",
|
||||
"compact",
|
||||
"default",
|
||||
"wide",
|
||||
"full",
|
||||
] as const;
|
||||
export type PluginLauncherBounds = (typeof PLUGIN_LAUNCHER_BOUNDS)[number];
|
||||
|
||||
/**
|
||||
* Render environments describe the container a launcher expects after it is
|
||||
* activated. The current host may map these to concrete UI primitives.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS = [
|
||||
"hostInline",
|
||||
"hostOverlay",
|
||||
"hostRoute",
|
||||
"external",
|
||||
"iframe",
|
||||
] as const;
|
||||
export type PluginLauncherRenderEnvironment =
|
||||
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number];
|
||||
|
||||
/**
|
||||
* Entity types that a `detailTab` UI slot can attach to.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
|
||||
"project",
|
||||
"issue",
|
||||
"agent",
|
||||
"goal",
|
||||
"run",
|
||||
"comment",
|
||||
] as const;
|
||||
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Scope kinds for plugin state storage. Determines the granularity at which
|
||||
* a plugin stores key-value state data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugin_state.scope_kind`
|
||||
*/
|
||||
export const PLUGIN_STATE_SCOPE_KINDS = [
|
||||
"instance",
|
||||
"company",
|
||||
"project",
|
||||
"project_workspace",
|
||||
"agent",
|
||||
"issue",
|
||||
"goal",
|
||||
"run",
|
||||
] as const;
|
||||
export type PluginStateScopeKind = (typeof PLUGIN_STATE_SCOPE_KINDS)[number];
|
||||
|
||||
/** Statuses for a plugin's scheduled job definition. */
|
||||
export const PLUGIN_JOB_STATUSES = [
|
||||
"active",
|
||||
"paused",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginJobStatus = (typeof PLUGIN_JOB_STATUSES)[number];
|
||||
|
||||
/** Statuses for individual job run executions. */
|
||||
export const PLUGIN_JOB_RUN_STATUSES = [
|
||||
"pending",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type PluginJobRunStatus = (typeof PLUGIN_JOB_RUN_STATUSES)[number];
|
||||
|
||||
/** What triggered a particular job run. */
|
||||
export const PLUGIN_JOB_RUN_TRIGGERS = [
|
||||
"schedule",
|
||||
"manual",
|
||||
"retry",
|
||||
] as const;
|
||||
export type PluginJobRunTrigger = (typeof PLUGIN_JOB_RUN_TRIGGERS)[number];
|
||||
|
||||
/** Statuses for inbound webhook deliveries. */
|
||||
export const PLUGIN_WEBHOOK_DELIVERY_STATUSES = [
|
||||
"pending",
|
||||
"success",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginWebhookDeliveryStatus = (typeof PLUGIN_WEBHOOK_DELIVERY_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Core domain event types that plugins can subscribe to via the
|
||||
* `events.subscribe` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §16 — Event System
|
||||
*/
|
||||
export const PLUGIN_EVENT_TYPES = [
|
||||
"company.created",
|
||||
"company.updated",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.workspace_created",
|
||||
"project.workspace_updated",
|
||||
"project.workspace_deleted",
|
||||
"issue.created",
|
||||
"issue.updated",
|
||||
"issue.comment.created",
|
||||
"agent.created",
|
||||
"agent.updated",
|
||||
"agent.status_changed",
|
||||
"agent.run.started",
|
||||
"agent.run.finished",
|
||||
"agent.run.failed",
|
||||
"agent.run.cancelled",
|
||||
"goal.created",
|
||||
"goal.updated",
|
||||
"approval.created",
|
||||
"approval.decided",
|
||||
"cost_event.created",
|
||||
"activity.logged",
|
||||
] as const;
|
||||
export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Error codes returned by the plugin bridge when a UI → worker call fails.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const PLUGIN_BRIDGE_ERROR_CODES = [
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CAPABILITY_DENIED",
|
||||
"WORKER_ERROR",
|
||||
"TIMEOUT",
|
||||
"UNKNOWN",
|
||||
] as const;
|
||||
export type PluginBridgeErrorCode = (typeof PLUGIN_BRIDGE_ERROR_CODES)[number];
|
||||
|
||||
@@ -31,6 +31,23 @@ export {
|
||||
JOIN_REQUEST_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
PERMISSION_KEYS,
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
PLUGIN_JOB_RUN_TRIGGERS,
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
type CompanyStatus,
|
||||
type DeploymentMode,
|
||||
type DeploymentExposure,
|
||||
@@ -61,6 +78,22 @@ export {
|
||||
type JoinRequestType,
|
||||
type JoinRequestStatus,
|
||||
type PermissionKey,
|
||||
type PluginStatus,
|
||||
type PluginCategory,
|
||||
type PluginCapability,
|
||||
type PluginUiSlotType,
|
||||
type PluginUiSlotEntityType,
|
||||
type PluginLauncherPlacementZone,
|
||||
type PluginLauncherAction,
|
||||
type PluginLauncherBounds,
|
||||
type PluginLauncherRenderEnvironment,
|
||||
type PluginStateScopeKind,
|
||||
type PluginJobStatus,
|
||||
type PluginJobRunStatus,
|
||||
type PluginJobRunTrigger,
|
||||
type PluginWebhookDeliveryStatus,
|
||||
type PluginEventType,
|
||||
type PluginBridgeErrorCode,
|
||||
} from "./constants.js";
|
||||
|
||||
export type {
|
||||
@@ -129,6 +162,28 @@ export type {
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
CompanyPluginAvailability,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./types/index.js";
|
||||
|
||||
export {
|
||||
@@ -238,6 +293,45 @@ export {
|
||||
type CompanyPortabilityExport,
|
||||
type CompanyPortabilityPreview,
|
||||
type CompanyPortabilityImport,
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
upsertPluginCompanySettingsSchema,
|
||||
updateCompanyPluginAvailabilitySchema,
|
||||
listCompanyPluginAvailabilitySchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpsertPluginCompanySettings,
|
||||
type UpdateCompanyPluginAvailability,
|
||||
type ListCompanyPluginAvailability,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
|
||||
@@ -79,3 +79,27 @@ export type {
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
} from "./company-portability.js";
|
||||
export type {
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
CompanyPluginAvailability,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./plugin.js";
|
||||
|
||||
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 UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
export {
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
upsertPluginCompanySettingsSchema,
|
||||
updateCompanyPluginAvailabilitySchema,
|
||||
listCompanyPluginAvailabilitySchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpsertPluginCompanySettings,
|
||||
type UpdateCompanyPluginAvailability,
|
||||
type ListCompanyPluginAvailability,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./plugin.js";
|
||||
|
||||
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/adapters/*
|
||||
- packages/plugins/*
|
||||
- packages/plugins/examples/*
|
||||
- server
|
||||
- ui
|
||||
- cli
|
||||
|
||||
46
scripts/ensure-plugin-build-deps.mjs
Normal file
46
scripts/ensure-plugin-build-deps.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, "..");
|
||||
const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc");
|
||||
|
||||
const buildTargets = [
|
||||
{
|
||||
name: "@paperclipai/shared",
|
||||
output: path.join(rootDir, "packages/shared/dist/index.js"),
|
||||
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
|
||||
},
|
||||
{
|
||||
name: "@paperclipai/plugin-sdk",
|
||||
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
|
||||
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
|
||||
},
|
||||
];
|
||||
|
||||
if (!fs.existsSync(tscCliPath)) {
|
||||
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
|
||||
}
|
||||
|
||||
for (const target of buildTargets) {
|
||||
if (fs.existsSync(target.output)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"postpack": "rm -rf ui-dist",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
@@ -43,7 +43,10 @@
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "1.4.18",
|
||||
"detect-port": "^2.1.0",
|
||||
"dotenv": "^17.0.1",
|
||||
|
||||
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
43
server/src/__tests__/plugin-worker-manager.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js";
|
||||
|
||||
describe("plugin-worker-manager stderr failure context", () => {
|
||||
it("appends worker stderr context to failure messages", () => {
|
||||
expect(
|
||||
formatWorkerFailureMessage(
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
),
|
||||
).toBe(
|
||||
"Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate stderr that is already present", () => {
|
||||
const message = [
|
||||
"Worker process exited (code=1, signal=null)",
|
||||
"",
|
||||
"Worker stderr:",
|
||||
"TypeError: Unknown file extension \".ts\"",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""),
|
||||
).toBe(message);
|
||||
});
|
||||
|
||||
it("keeps only the latest stderr excerpt", () => {
|
||||
let excerpt = "";
|
||||
excerpt = appendStderrExcerpt(excerpt, "first line");
|
||||
excerpt = appendStderrExcerpt(excerpt, "second line");
|
||||
|
||||
expect(excerpt).toContain("first line");
|
||||
expect(excerpt).toContain("second line");
|
||||
|
||||
excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000));
|
||||
|
||||
expect(excerpt).not.toContain("first line");
|
||||
expect(excerpt).not.toContain("second line");
|
||||
expect(excerpt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,23 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
||||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||
import { pluginLifecycleManager } from "./services/plugin-lifecycle.js";
|
||||
import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js";
|
||||
import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js";
|
||||
import { createPluginEventBus } from "./services/plugin-event-bus.js";
|
||||
import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js";
|
||||
import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js";
|
||||
import { pluginRegistryService } from "./services/plugin-registry.js";
|
||||
import { createHostClientHandlers } from "@paperclipai/plugin-sdk";
|
||||
import type { BetterAuthSessionResult } from "./auth/better-auth.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
@@ -41,13 +57,20 @@ export async function createApp(
|
||||
bindHost: string;
|
||||
authReady: boolean;
|
||||
companyDeletionEnabled: boolean;
|
||||
instanceId?: string;
|
||||
hostVersion?: string;
|
||||
localPluginDir?: string;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
},
|
||||
) {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.json({
|
||||
verify: (req, _res, buf) => {
|
||||
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
||||
},
|
||||
}));
|
||||
app.use(httpLogger);
|
||||
const privateHostnameGateEnabled =
|
||||
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
|
||||
@@ -114,6 +137,75 @@ export async function createApp(
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const eventBus = createPluginEventBus({
|
||||
async isPluginEnabledForCompany(pluginKey, companyId) {
|
||||
const plugin = await pluginRegistry.getByKey(pluginKey);
|
||||
if (!plugin) return false;
|
||||
const availability = await pluginRegistry.getCompanyAvailability(companyId, plugin.id);
|
||||
return availability?.available ?? true;
|
||||
},
|
||||
});
|
||||
const jobStore = pluginJobStore(db);
|
||||
const lifecycle = pluginLifecycleManager(db, { workerManager });
|
||||
const scheduler = createPluginJobScheduler({
|
||||
db,
|
||||
jobStore,
|
||||
workerManager,
|
||||
});
|
||||
const toolDispatcher = createPluginToolDispatcher({
|
||||
workerManager,
|
||||
lifecycleManager: lifecycle,
|
||||
db,
|
||||
});
|
||||
const jobCoordinator = createPluginJobCoordinator({
|
||||
db,
|
||||
lifecycle,
|
||||
scheduler,
|
||||
jobStore,
|
||||
});
|
||||
const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers);
|
||||
const loader = pluginLoader(
|
||||
db,
|
||||
{ localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR },
|
||||
{
|
||||
workerManager,
|
||||
eventBus,
|
||||
jobScheduler: scheduler,
|
||||
jobStore,
|
||||
toolDispatcher,
|
||||
lifecycleManager: lifecycle,
|
||||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
},
|
||||
buildHostHandlers: (pluginId, manifest) => {
|
||||
const notifyWorker = (method: string, params: unknown) => {
|
||||
const handle = workerManager.getWorker(pluginId);
|
||||
if (handle) handle.notify(method, params);
|
||||
};
|
||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker);
|
||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||
return createHostClientHandlers({
|
||||
pluginId,
|
||||
capabilities: manifest.capabilities,
|
||||
services,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
api.use(
|
||||
pluginRoutes(
|
||||
db,
|
||||
loader,
|
||||
{ scheduler, jobStore },
|
||||
{ workerManager },
|
||||
{ toolDispatcher },
|
||||
{ workerManager },
|
||||
),
|
||||
);
|
||||
api.use(
|
||||
accessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
@@ -126,6 +218,9 @@ export async function createApp(
|
||||
app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "API route not found" });
|
||||
});
|
||||
app.use(pluginUiStaticRoutes(db, {
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
}));
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (opts.uiMode === "static") {
|
||||
@@ -179,5 +274,33 @@ export async function createApp(
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
jobCoordinator.start();
|
||||
scheduler.start();
|
||||
void toolDispatcher.initialize().catch((err) => {
|
||||
logger.error({ err }, "Failed to initialize plugin tool dispatcher");
|
||||
});
|
||||
const devWatcher = createPluginDevWatcher(
|
||||
lifecycle,
|
||||
async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null,
|
||||
);
|
||||
void loader.loadAll().then((result) => {
|
||||
if (!result) return;
|
||||
for (const loaded of result.results) {
|
||||
if (loaded.success && loaded.plugin.packagePath) {
|
||||
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
process.once("exit", () => {
|
||||
devWatcher.close();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void flushPluginLogBuffer();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function publishGlobalLiveEvent(input: {
|
||||
type: LiveEventType;
|
||||
payload?: LiveEventPayload;
|
||||
}) {
|
||||
const event = toLiveEvent({ companyId: "*", type: input.type, payload: input.payload });
|
||||
emitter.emit("*", event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function subscribeCompanyLiveEvents(companyId: string, listener: LiveEventListener) {
|
||||
emitter.on(companyId, listener);
|
||||
return () => emitter.off(companyId, listener);
|
||||
}
|
||||
|
||||
export function subscribeGlobalLiveEvents(listener: LiveEventListener) {
|
||||
emitter.on("*", listener);
|
||||
return () => emitter.off("*", listener);
|
||||
}
|
||||
|
||||
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 { DesignGuide } from "./pages/DesignGuide";
|
||||
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||
import { PluginManager } from "./pages/PluginManager";
|
||||
import { PluginSettings } from "./pages/PluginSettings";
|
||||
import { PluginPage } from "./pages/PluginPage";
|
||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||
import { OrgChart } from "./pages/OrgChart";
|
||||
import { NewAgent } from "./pages/NewAgent";
|
||||
@@ -113,6 +116,7 @@ function boardRoutes() {
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="plugins/:pluginId" element={<PluginPage />} />
|
||||
<Route path="org" element={<OrgChart />} />
|
||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||
<Route path="agents/all" element={<Agents />} />
|
||||
@@ -162,7 +166,7 @@ function InboxRootRedirect() {
|
||||
|
||||
function LegacySettingsRedirect() {
|
||||
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() {
|
||||
@@ -295,9 +299,12 @@ export function App() {
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
<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 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 path="companies" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -41,6 +41,8 @@ export const api = {
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
postForm: <T>(path: string, body: FormData) =>
|
||||
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) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
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";
|
||||
|
||||
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">
|
||||
<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>
|
||||
</nav>
|
||||
</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 { 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() {
|
||||
const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar();
|
||||
const { openNewIssue, openOnboarding } = useDialog();
|
||||
@@ -49,6 +80,7 @@ export function Layout() {
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const matchedCompany = useMemo(() => {
|
||||
if (!companyPrefix) return null;
|
||||
@@ -220,6 +252,21 @@ export function Layout() {
|
||||
};
|
||||
}, [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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -235,7 +282,6 @@ export function Layout() {
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -245,7 +291,6 @@ export function Layout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Combined sidebar area: company rail + inner sidebar + docs bar */}
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -270,7 +315,7 @@ export function Layout() {
|
||||
</a>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
to={instanceSettingsTarget}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
@@ -320,7 +365,7 @@ export function Layout() {
|
||||
</a>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to="/instance/settings"
|
||||
to={instanceSettingsTarget}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
@@ -346,7 +391,6 @@ export function Layout() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -25,17 +25,26 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
|
||||
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
|
||||
|
||||
function SortableProjectItem({
|
||||
activeProjectRef,
|
||||
companyId,
|
||||
companyPrefix,
|
||||
isMobile,
|
||||
project,
|
||||
projectSidebarSlots,
|
||||
setSidebarOpen,
|
||||
}: {
|
||||
activeProjectRef: string | null;
|
||||
companyId: string | null;
|
||||
companyPrefix: string | null;
|
||||
isMobile: boolean;
|
||||
project: Project;
|
||||
projectSidebarSlots: ProjectSidebarSlot[];
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -61,6 +70,7 @@ function SortableProjectItem({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
onClick={() => {
|
||||
@@ -79,13 +89,33 @@ function SortableProjectItem({
|
||||
/>
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarProjects() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialog();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const location = useLocation();
|
||||
@@ -99,6 +129,12 @@ export function SidebarProjects() {
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const { slots: projectSidebarSlots } = usePluginSlots({
|
||||
slotTypes: ["projectSidebarItem"],
|
||||
entityType: "project",
|
||||
companyId: selectedCompanyId,
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
@@ -178,8 +214,11 @@ export function SidebarProjects() {
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
activeProjectRef={activeProjectRef}
|
||||
companyId={selectedCompanyId}
|
||||
companyPrefix={selectedCompany?.issuePrefix ?? null}
|
||||
isMobile={isMobile}
|
||||
project={project}
|
||||
projectSidebarSlots={projectSidebarSlots}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -75,4 +75,20 @@ export const queryKeys = {
|
||||
liveRuns: (companyId: string) => ["live-runs", companyId] as const,
|
||||
runIssues: (runId: string) => ["run-issues", runId] 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 * as ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "@/lib/router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -12,9 +14,12 @@ import { DialogProvider } from "./context/DialogContext";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { initPluginBridge } from "./plugins/bridge-init";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import "./index.css";
|
||||
|
||||
initPluginBridge(React, ReactDOM);
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js");
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
|
||||
function getRecentIssues(issues: Issue[]): Issue[] {
|
||||
return [...issues]
|
||||
@@ -276,6 +277,13 @@ export function Dashboard() {
|
||||
</ChartCard>
|
||||
</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">
|
||||
{/* Recent Activity */}
|
||||
{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 { projectRouteRef, cn } from "../lib/utils";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||
|
||||
/* ── 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 {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
@@ -213,8 +220,12 @@ export function ProjectDetail() {
|
||||
}, [companies, companyPrefix]);
|
||||
const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined;
|
||||
const canFetchProject = routeProjectRef.length > 0 && (isUuidLike(routeProjectRef) || Boolean(lookupCompanyId));
|
||||
|
||||
const activeTab = routeProjectRef ? resolveProjectTab(location.pathname, routeProjectRef) : null;
|
||||
const activeRouteTab = 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({
|
||||
queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null],
|
||||
@@ -224,6 +235,24 @@ export function ProjectDetail() {
|
||||
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
|
||||
const projectLookupRef = project?.id ?? routeProjectRef;
|
||||
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(() => {
|
||||
if (!project?.companyId || project.companyId === selectedCompanyId) return;
|
||||
@@ -261,6 +290,10 @@ export function ProjectDetail() {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (routeProjectRef === canonicalProjectRef) return;
|
||||
if (isProjectPluginTab(activeTab)) {
|
||||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (activeTab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
|
||||
return;
|
||||
@@ -328,6 +361,10 @@ export function ProjectDetail() {
|
||||
}
|
||||
}, [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
|
||||
if (routeProjectRef && activeTab === null) {
|
||||
return <Navigate to={`/projects/${canonicalProjectRef}/issues`} replace />;
|
||||
@@ -338,6 +375,10 @@ export function ProjectDetail() {
|
||||
if (!project) return null;
|
||||
|
||||
const handleTabChange = (tab: ProjectTab) => {
|
||||
if (isProjectPluginTab(tab)) {
|
||||
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
|
||||
return;
|
||||
}
|
||||
if (tab === "overview") {
|
||||
navigate(`/projects/${canonicalProjectRef}/overview`);
|
||||
} else if (tab === "configuration") {
|
||||
@@ -370,6 +411,10 @@ export function ProjectDetail() {
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
...pluginTabItems.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
})),
|
||||
]}
|
||||
align="start"
|
||||
value={activeTab ?? "list"}
|
||||
@@ -402,6 +447,21 @@ export function ProjectDetail() {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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