From b19d0b6f3b3295f2fc96b01208bd36db02e98411 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 16:39:35 -0500 Subject: [PATCH 001/422] Add support for company logos, including schema adjustments, validation, assets handling, and UI display enhancements. --- .gitignore | 4 +- cli/src/__tests__/company-delete.test.ts | 1 + docs/api/companies.md | 19 + .../src/migrations/0026_high_anita_blake.sql | 1 + .../db/src/migrations/meta/0026_snapshot.json | 5855 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/companies.ts | 1 + packages/shared/src/types/company.ts | 1 + packages/shared/src/validators/company.ts | 10 + pnpm-lock.yaml | 21 +- server/src/__tests__/assets.test.ts | 157 + server/src/routes/assets.ts | 9 +- ui/src/api/companies.ts | 9 +- ui/src/components/CompanyPatternIcon.tsx | 32 +- ui/src/components/CompanyRail.tsx | 1 + ui/src/context/CompanyContext.tsx | 15 +- ui/src/pages/CompanySettings.tsx | 94 +- 17 files changed, 6211 insertions(+), 26 deletions(-) create mode 100644 packages/db/src/migrations/0026_high_anita_blake.sql create mode 100644 packages/db/src/migrations/meta/0026_snapshot.json create mode 100644 server/src/__tests__/assets.test.ts diff --git a/.gitignore b/.gitignore index 9d9f5e35..6b68f737 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ tmp/ *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ +/.idea/ +/.agents/ diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts index 6858a3d1..65e5a021 100644 --- a/cli/src/__tests__/company-delete.test.ts +++ b/cli/src/__tests__/company-delete.test.ts @@ -14,6 +14,7 @@ function makeCompany(overrides: Partial): Company { spentMonthlyCents: 0, requireBoardApprovalForNewAgents: false, brandColor: null, + logoUrl: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/docs/api/companies.md b/docs/api/companies.md index a0aafae5..4b92fe1e 100644 --- a/docs/api/companies.md +++ b/docs/api/companies.md @@ -42,6 +42,24 @@ PATCH /api/companies/{companyId} } ``` +## Upload Company Logo + +Upload an image for a company icon and store it as that company’s logo. + +``` +POST /api/companies/{companyId}/assets/images +Content-Type: multipart/form-data +``` + +Valid image content types: + +- `image/png` +- `image/jpeg` +- `image/jpg` +- `image/webp` +- `image/gif` +- `image/svg+xml` (`.svg`) + ## Archive Company ``` @@ -58,6 +76,7 @@ Archives a company. Archived companies are hidden from default listings. | `name` | string | Company name | | `description` | string | Company description | | `status` | string | `active`, `paused`, `archived` | +| `logoUrl` | string | Optional path or URL for the logo image | | `budgetMonthlyCents` | number | Monthly budget limit | | `createdAt` | string | ISO timestamp | | `updatedAt` | string | ISO timestamp | diff --git a/packages/db/src/migrations/0026_high_anita_blake.sql b/packages/db/src/migrations/0026_high_anita_blake.sql new file mode 100644 index 00000000..17be3222 --- /dev/null +++ b/packages/db/src/migrations/0026_high_anita_blake.sql @@ -0,0 +1 @@ +ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "logo_url" text; diff --git a/packages/db/src/migrations/meta/0026_snapshot.json b/packages/db/src/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..400b7eb5 --- /dev/null +++ b/packages/db/src/migrations/meta/0026_snapshot.json @@ -0,0 +1,5855 @@ +{ + "id": "ada32d9a-1735-4149-91c7-83ae8f5dd482", + "prevId": "bd8d9b8d-3012-4c58-bcfd-b3215c164f82", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_log_company_created_idx": { + "name": "activity_log_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_api_keys_key_hash_idx": { + "name": "agent_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_api_keys_company_agent_idx": { + "name": "agent_api_keys_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_api_keys_agent_id_agents_id_fk": { + "name": "agent_api_keys_agent_id_agents_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_api_keys_company_id_companies_id_fk": { + "name": "agent_api_keys_company_id_companies_id_fk", + "tableFrom": "agent_api_keys", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config_revisions": { + "name": "agent_config_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'patch'" + }, + "rolled_back_from_revision_id": { + "name": "rolled_back_from_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "changed_keys": { + "name": "changed_keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "before_config": { + "name": "before_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "after_config": { + "name": "after_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_config_revisions_company_agent_created_idx": { + "name": "agent_config_revisions_company_agent_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_config_revisions_agent_created_idx": { + "name": "agent_config_revisions_agent_created_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_revisions_company_id_companies_id_fk": { + "name": "agent_config_revisions_company_id_companies_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_config_revisions_agent_id_agents_id_fk": { + "name": "agent_config_revisions_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_config_revisions_created_by_agent_id_agents_id_fk": { + "name": "agent_config_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "agent_config_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runtime_state": { + "name": "agent_runtime_state", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_json": { + "name": "state_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_run_status": { + "name": "last_run_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cached_input_tokens": { + "name": "total_cached_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost_cents": { + "name": "total_cost_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_runtime_state_company_agent_idx": { + "name": "agent_runtime_state_company_agent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runtime_state_company_updated_idx": { + "name": "agent_runtime_state_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runtime_state_agent_id_agents_id_fk": { + "name": "agent_runtime_state_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_runtime_state_company_id_companies_id_fk": { + "name": "agent_runtime_state_company_id_companies_id_fk", + "tableFrom": "agent_runtime_state", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task_sessions": { + "name": "agent_task_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_params_json": { + "name": "session_params_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_display_id": { + "name": "session_display_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_task_sessions_company_agent_adapter_task_uniq": { + "name": "agent_task_sessions_company_agent_adapter_task_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_agent_updated_idx": { + "name": "agent_task_sessions_company_agent_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_task_sessions_company_task_updated_idx": { + "name": "agent_task_sessions_company_task_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task_sessions_company_id_companies_id_fk": { + "name": "agent_task_sessions_company_id_companies_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_agent_id_agents_id_fk": { + "name": "agent_task_sessions_agent_id_agents_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": { + "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk", + "tableFrom": "agent_task_sessions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "last_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_wakeup_requests": { + "name": "agent_wakeup_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "requested_by_actor_type": { + "name": "requested_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_actor_id": { + "name": "requested_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_wakeup_requests_company_agent_status_idx": { + "name": "agent_wakeup_requests_company_agent_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_company_requested_idx": { + "name": "agent_wakeup_requests_company_requested_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_wakeup_requests_agent_requested_idx": { + "name": "agent_wakeup_requests_agent_requested_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_wakeup_requests_company_id_companies_id_fk": { + "name": "agent_wakeup_requests_company_id_companies_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_wakeup_requests_agent_id_agents_id_fk": { + "name": "agent_wakeup_requests_agent_id_agents_id_fk", + "tableFrom": "agent_wakeup_requests", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "reports_to": { + "name": "reports_to", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'process'" + }, + "adapter_config": { + "name": "adapter_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "runtime_config": { + "name": "runtime_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_company_id_companies_id_fk": { + "name": "agents_company_id_companies_id_fk", + "tableFrom": "agents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_reports_to_agents_id_fk": { + "name": "agents_reports_to_agents_id_fk", + "tableFrom": "agents", + "tableTo": "agents", + "columnsFrom": [ + "reports_to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_comments": { + "name": "approval_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_comments_company_idx": { + "name": "approval_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_idx": { + "name": "approval_comments_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_comments_approval_created_idx": { + "name": "approval_comments_approval_created_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_comments_company_id_companies_id_fk": { + "name": "approval_comments_company_id_companies_id_fk", + "tableFrom": "approval_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_approval_id_approvals_id_fk": { + "name": "approval_comments_approval_id_approvals_id_fk", + "tableFrom": "approval_comments", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_comments_author_agent_id_agents_id_fk": { + "name": "approval_comments_author_agent_id_agents_id_fk", + "tableFrom": "approval_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_agent_id": { + "name": "requested_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decision_note": { + "name": "decision_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approvals_company_status_type_idx": { + "name": "approvals_company_status_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_company_id_companies_id_fk": { + "name": "approvals_company_id_companies_id_fk", + "tableFrom": "approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approvals_requested_by_agent_id_agents_id_fk": { + "name": "approvals_requested_by_agent_id_agents_id_fk", + "tableFrom": "approvals", + "tableTo": "agents", + "columnsFrom": [ + "requested_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_company_created_idx": { + "name": "assets_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_provider_idx": { + "name": "assets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_company_object_key_uq": { + "name": "assets_company_object_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_company_id_companies_id_fk": { + "name": "assets_company_id_companies_id_fk", + "tableFrom": "assets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "assets_created_by_agent_id_agents_id_fk": { + "name": "assets_created_by_agent_id_agents_id_fk", + "tableFrom": "assets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_snapshot": { + "name": "context_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_runs_company_agent_started_idx": { + "name": "heartbeat_runs_company_agent_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_runs_company_id_companies_id_fk": { + "name": "heartbeat_runs_company_id_companies_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_agent_id_agents_id_fk": { + "name": "heartbeat_runs_agent_id_agents_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": { + "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "agent_wakeup_requests", + "columnsFrom": [ + "wakeup_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_depth": { + "name": "request_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_adapter_overrides": { + "name": "assignee_adapter_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_permission_grants": { + "name": "principal_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_key": { + "name": "permission_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_permission_grants_unique_idx": { + "name": "principal_permission_grants_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "principal_permission_grants_company_permission_idx": { + "name": "principal_permission_grants_company_permission_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_permission_grants_company_id_companies_id_fk": { + "name": "principal_permission_grants_company_id_companies_id_fk", + "tableFrom": "principal_permission_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_goals": { + "name": "project_goals", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_goals_project_idx": { + "name": "project_goals_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_goal_idx": { + "name": "project_goals_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_goals_company_idx": { + "name": "project_goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_goals_project_id_projects_id_fk": { + "name": "project_goals_project_id_projects_id_fk", + "tableFrom": "project_goals", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_goal_id_goals_id_fk": { + "name": "project_goals_goal_id_goals_id_fk", + "tableFrom": "project_goals", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_goals_company_id_companies_id_fk": { + "name": "project_goals_company_id_companies_id_fk", + "tableFrom": "project_goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_goals_project_id_goal_id_pk": { + "name": "project_goals_project_id_goal_id_pk", + "columns": [ + "project_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_workspaces": { + "name": "project_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c3e25050..22a695b4 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1772807461603, "tag": "0025_nasty_salo", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1772823634634, + "tag": "0026_high_anita_blake", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/companies.ts b/packages/db/src/schema/companies.ts index 29c82b71..3e75d4e6 100644 --- a/packages/db/src/schema/companies.ts +++ b/packages/db/src/schema/companies.ts @@ -15,6 +15,7 @@ export const companies = pgTable( .notNull() .default(true), brandColor: text("brand_color"), + logoUrl: text("logo_url"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/types/company.ts b/packages/shared/src/types/company.ts index 435be80d..3002c7d3 100644 --- a/packages/shared/src/types/company.ts +++ b/packages/shared/src/types/company.ts @@ -11,6 +11,7 @@ export interface Company { spentMonthlyCents: number; requireBoardApprovalForNewAgents: boolean; brandColor: string | null; + logoUrl: string | null; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/company.ts b/packages/shared/src/validators/company.ts index 407d2ae4..65083c91 100644 --- a/packages/shared/src/validators/company.ts +++ b/packages/shared/src/validators/company.ts @@ -1,10 +1,19 @@ import { z } from "zod"; import { COMPANY_STATUSES } from "../constants.js"; +const logoUrlSchema = z + .string() + .trim() + .max(2048) + .regex(/^\/api\/assets\/[^\s]+$|^https?:\/\/[^\s]+$/) + .nullable() + .optional(); + export const createCompanySchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), + logoUrl: logoUrlSchema, }); export type CreateCompany = z.infer; @@ -16,6 +25,7 @@ export const updateCompanySchema = createCompanySchema spentMonthlyCents: z.number().int().nonnegative().optional(), requireBoardApprovalForNewAgents: z.boolean().optional(), brandColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(), + logoUrl: logoUrlSchema, }); export type UpdateCompany = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492cd35a..b9b5f3e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -156,8 +159,8 @@ importers: version: 1.1.1 devDependencies: '@types/node': - specifier: ^22.12.0 - version: 22.19.11 + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -8162,7 +8165,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/chai@5.2.3': dependencies: @@ -8171,7 +8174,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/cookiejar@2.1.5': {} @@ -8189,7 +8192,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -8246,18 +8249,18 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 24.12.0 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -8271,7 +8274,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.12.0 + '@types/node': 25.2.3 '@ungap/structured-clone@1.3.0': {} diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts new file mode 100644 index 00000000..eb58e24a --- /dev/null +++ b/server/src/__tests__/assets.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { assetRoutes } from "../routes/assets.js"; +import type { StorageService } from "../storage/types.js"; + +const { createAssetMock, getAssetByIdMock, logActivityMock } = vi.hoisted(() => ({ + createAssetMock: vi.fn(), + getAssetByIdMock: vi.fn(), + logActivityMock: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + assetService: vi.fn(() => ({ + create: createAssetMock, + getById: getAssetByIdMock, + })), + logActivity: logActivityMock, +})); + +function createAsset() { + const now = new Date("2026-01-01T00:00:00.000Z"); + return { + id: "asset-1", + companyId: "company-1", + provider: "local", + objectKey: "assets/abc", + contentType: "image/svg+xml", + byteSize: 40, + sha256: "sha256-sample", + originalFilename: "logo.svg", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: now, + updatedAt: now, + }; +} + +function createStorageService(contentType = "image/svg+xml"): StorageService { + const putFile: StorageService["putFile"] = vi.fn(async (input: { + companyId: string; + namespace: string; + originalFilename: string | null; + contentType: string; + body: Buffer; + }) => { + return { + provider: "local_disk" as const, + objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, + contentType: contentType || input.contentType, + byteSize: input.body.length, + sha256: "sha256-sample", + originalFilename: input.originalFilename, + }; + }); + + return { + provider: "local_disk" as const, + putFile, + getObject: vi.fn(), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +function createApp(storage: ReturnType) { + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "board", + source: "local_implicit", + userId: "user-1", + }; + next(); + }); + app.use("/api", assetRoutes({} as any, storage)); + return app; +} + +describe("POST /api/companies/:companyId/assets/images", () => { + afterEach(() => { + createAssetMock.mockReset(); + getAssetByIdMock.mockReset(); + logActivityMock.mockReset(); + }); + + it("accepts SVG image uploads and returns an asset path", async () => { + const svg = createStorageService("image/svg+xml"); + const app = createApp(svg); + + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from(""), "logo.svg"); + + expect(res.status).toBe(201); + expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); + expect(createAssetMock).toHaveBeenCalledTimes(1); + expect(svg.putFile).toHaveBeenCalledWith({ + companyId: "company-1", + namespace: "assets/companies", + originalFilename: "logo.svg", + contentType: "image/svg+xml", + body: expect.any(Buffer), + }); + }); + + it("rejects files larger than 100 KB", async () => { + const app = createApp(createStorageService()); + createAssetMock.mockResolvedValue(createAsset()); + + const file = Buffer.alloc(100 * 1024 + 1, "a"); + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", file, "too-large.png"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Image exceeds 102400 bytes"); + }); + + it("allows larger non-logo images within the general asset limit", async () => { + const png = createStorageService("image/png"); + const app = createApp(png); + + createAssetMock.mockResolvedValue({ + ...createAsset(), + contentType: "image/png", + originalFilename: "goal.png", + }); + + const file = Buffer.alloc(150 * 1024, "a"); + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "goals") + .attach("file", file, "goal.png"); + + expect(res.status).toBe(201); + expect(createAssetMock).toHaveBeenCalledTimes(1); + }); + + it("rejects unsupported image types", async () => { + const app = createApp(createStorageService("text/plain")); + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from("not an image"), "note.txt"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Unsupported image type: text/plain"); + expect(createAssetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..0b1b81cb 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -7,12 +7,14 @@ import { assetService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; +const MAX_COMPANY_LOGO_BYTES = 100 * 1024; const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", + "image/svg+xml", ]); export function assetRoutes(db: Db, storage: StorageService) { @@ -73,6 +75,12 @@ export function assetRoutes(db: Db, storage: StorageService) { } const namespaceSuffix = parsedMeta.data.namespace ?? "general"; + const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/"); + if (isCompanyLogoNamespace && file.buffer.length > MAX_COMPANY_LOGO_BYTES) { + res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` }); + return; + } + const actor = getActorInfo(req); const stored = await storage.putFile({ companyId, @@ -150,4 +158,3 @@ export function assetRoutes(db: Db, storage: StorageService) { return router; } - diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index 583d9e69..eb53524b 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -14,14 +14,19 @@ export const companiesApi = { list: () => api.get("/companies"), get: (companyId: string) => api.get(`/companies/${companyId}`), stats: () => api.get("/companies/stats"), - create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => + create: (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => api.post("/companies", data), update: ( companyId: string, data: Partial< Pick< Company, - "name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" + "name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoUrl" > >, ) => api.patch(`/companies/${companyId}`, data), diff --git a/ui/src/components/CompanyPatternIcon.tsx b/ui/src/components/CompanyPatternIcon.tsx index c7e5acc3..6ea40788 100644 --- a/ui/src/components/CompanyPatternIcon.tsx +++ b/ui/src/components/CompanyPatternIcon.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { cn } from "../lib/utils"; const BAYER_4X4 = [ @@ -10,6 +10,7 @@ const BAYER_4X4 = [ interface CompanyPatternIconProps { companyName: string; + logoUrl?: string | null; brandColor?: string | null; className?: string; } @@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log return canvas.toDataURL("image/png"); } -export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) { +export function CompanyPatternIcon({ + companyName, + logoUrl, + brandColor, + className, +}: CompanyPatternIconProps) { const initial = companyName.trim().charAt(0).toUpperCase() || "?"; + const [imageError, setImageError] = useState(false); + const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null; + useEffect(() => { + setImageError(false); + }, [logoUrl]); const patternDataUrl = useMemo( () => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor), [companyName, brandColor], @@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa className, )} > - {patternDataUrl ? ( + {logo ? ( + {`${companyName} setImageError(true)} + className="absolute inset-0 h-full w-full object-cover" + /> + ) : patternDataUrl ? ( )} - - {initial} - + {!logo && ( + + {initial} + + )} ); } diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 62a8bf3e..46a6d155 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -121,6 +121,7 @@ function SortableCompanyItem({ > Promise; } @@ -86,7 +87,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) { }, [queryClient]); const createMutation = useMutation({ - mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => + mutationFn: (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => companiesApi.create(data), onSuccess: (company) => { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); @@ -95,7 +101,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) { }); const createCompany = useCallback( - async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => { + async (data: { + name: string; + description?: string | null; + budgetMonthlyCents?: number; + logoUrl?: string | null; + }) => { return createMutation.mutateAsync(data); }, [createMutation], diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 0b9cd255..7dd51da0 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from "react"; +import { ChangeEvent, useEffect, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; +import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { Settings, Check } from "lucide-react"; @@ -34,6 +35,8 @@ export function CompanySettings() { const [companyName, setCompanyName] = useState(""); const [description, setDescription] = useState(""); const [brandColor, setBrandColor] = useState(""); + const [logoUrl, setLogoUrl] = useState(""); + const [logoUploadError, setLogoUploadError] = useState(null); // Sync local state from selected company useEffect(() => { @@ -41,6 +44,7 @@ export function CompanySettings() { setCompanyName(selectedCompany.name); setDescription(selectedCompany.description ?? ""); setBrandColor(selectedCompany.brandColor ?? ""); + setLogoUrl(selectedCompany.logoUrl ?? ""); }, [selectedCompany]); const [inviteError, setInviteError] = useState(null); @@ -130,6 +134,46 @@ export function CompanySettings() { } }); + const syncLogoState = (nextLogoUrl: string | null) => { + setLogoUrl(nextLogoUrl ?? ""); + void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + }; + + const logoUploadMutation = useMutation({ + mutationFn: (file: File) => + assetsApi + .uploadImage(selectedCompanyId!, file, "companies") + .then((asset) => companiesApi.update(selectedCompanyId!, { logoUrl: asset.contentPath })), + onSuccess: (company) => { + syncLogoState(company.logoUrl); + setLogoUploadError(null); + } + }); + + const clearLogoMutation = useMutation({ + mutationFn: () => companiesApi.update(selectedCompanyId!, { logoUrl: null }), + onSuccess: (company) => { + setLogoUploadError(null); + syncLogoState(company.logoUrl); + } + }); + + function handleLogoFileChange(event: ChangeEvent) { + const file = event.target.files?.[0] ?? null; + event.currentTarget.value = ""; + if (!file) return; + if (file.size >= 100 * 1024) { + setLogoUploadError("Logo image must be smaller than 100 KB."); + return; + } + setLogoUploadError(null); + logoUploadMutation.mutate(file); + } + + function handleClearLogo() { + clearLogoMutation.mutate(); + } + useEffect(() => { setInviteError(null); setInviteSnippet(null); @@ -226,11 +270,53 @@ export function CompanySettings() {
-
+
+ +
+ + {logoUrl && ( +
+ +
+ )} + {(logoUploadMutation.isError || logoUploadError) && ( + + {logoUploadError ?? + (logoUploadMutation.error instanceof Error + ? logoUploadMutation.error.message + : "Logo upload failed")} + + )} + {clearLogoMutation.isError && ( + + {clearLogoMutation.error.message} + + )} + {logoUploadMutation.isPending && ( + Uploading logo... + )} +
+
- {generalMutation.error instanceof Error - ? generalMutation.error.message - : "Failed to save"} + {generalMutation.error.message} )}
From 1448b55ca42509c186f38b942994bb3e4337c4e7 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 16:47:04 -0500 Subject: [PATCH 002/422] Improve error handling in CompanySettings for mutation failure messages. --- ui/src/pages/CompanySettings.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 7dd51da0..8d68fcff 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -372,7 +372,9 @@ export function CompanySettings() { )} {generalMutation.isError && ( - {generalMutation.error.message} + {generalMutation.error instanceof Error + ? generalMutation.error.message + : "Failed to save"} )}
From a4702e48f976351f8ca0f3d29a41bba9564cf011 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 17:18:43 -0500 Subject: [PATCH 003/422] Add sanitization for SVG uploads and enhance security headers for asset responses - Introduced SVG sanitization using `dompurify` to prevent malicious content. - Updated tests to validate SVG sanitization with various scenarios. - Enhanced response headers for assets, adding CSP and nosniff for SVGs. - Adjusted UI to better clarify supported file types for logo uploads. - Updated dependencies to include `jsdom` and `dompurify`. --- docs/api/companies.md | 2 +- pnpm-lock.yaml | 429 +++++++++++++++++++++++++++- server/package.json | 5 +- server/src/__tests__/assets.test.ts | 68 ++++- server/src/routes/assets.ts | 93 +++++- ui/src/pages/CompanySettings.tsx | 4 +- 6 files changed, 569 insertions(+), 32 deletions(-) diff --git a/docs/api/companies.md b/docs/api/companies.md index 4b92fe1e..efcac8c2 100644 --- a/docs/api/companies.md +++ b/docs/api/companies.md @@ -58,7 +58,7 @@ Valid image content types: - `image/jpg` - `image/webp` - `image/gif` -- `image/svg+xml` (`.svg`) +- `image/svg+xml` ## Archive Company diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9b5f3e0..053c76c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -126,9 +126,6 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -191,7 +188,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -234,10 +231,13 @@ importers: version: link:../packages/shared better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 + dompurify: + specifier: ^3.3.2 + version: 3.3.2 dotenv: specifier: ^17.0.1 version: 17.3.1 @@ -250,6 +250,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@2.0.1) multer: specifier: ^2.0.2 version: 2.0.2 @@ -278,6 +281,9 @@ importers: '@types/express-serve-static-core': specifier: ^5.0.0 version: 5.1.1 + '@types/jsdom': + specifier: ^28.0.0 + version: 28.0.0 '@types/multer': specifier: ^2.0.0 version: 2.0.0 @@ -304,7 +310,7 @@ importers: version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -410,10 +416,23 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -689,6 +708,10 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -855,6 +878,37 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1383,6 +1437,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2825,6 +2888,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsdom@28.0.0': + resolution: {integrity: sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2875,6 +2941,12 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2940,6 +3012,10 @@ packages: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} @@ -3070,6 +3146,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3232,11 +3311,19 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3244,6 +3331,10 @@ packages: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -3256,6 +3347,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3320,6 +3414,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3458,6 +3556,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3700,6 +3802,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3707,6 +3813,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -3778,6 +3892,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3824,6 +3941,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3940,6 +4066,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4019,6 +4149,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -4270,6 +4403,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4422,6 +4561,10 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4567,6 +4710,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -4611,6 +4758,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4766,6 +4917,9 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -4808,6 +4962,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.25: + resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==} + + tldts@7.0.25: + resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4816,6 +4977,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4855,6 +5024,13 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.22.0: + resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==} + + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unidiff@1.0.4: resolution: {integrity: sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==} @@ -5052,6 +5228,22 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5081,6 +5273,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5103,6 +5302,26 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -5731,6 +5950,10 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -6179,6 +6402,28 @@ snapshots: react-dom: 19.2.4(react@19.2.4) react-is: 17.0.2 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -6465,6 +6710,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -8209,6 +8458,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsdom@28.0.0': + dependencies: + '@types/node': 25.2.3 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.22.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -8268,6 +8524,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8353,6 +8614,8 @@ snapshots: address@2.0.3: {} + agent-base@7.1.4: {} + anser@2.3.5: {} ansi-colors@4.1.3: {} @@ -8389,7 +8652,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8409,7 +8672,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -8424,6 +8687,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -8587,8 +8854,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} d@1.0.2: @@ -8596,12 +8875,21 @@ snapshots: es5-ext: 0.10.64 type: 2.7.3 + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -8650,6 +8938,10 @@ snapshots: dependencies: path-type: 4.0.0 + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -8723,6 +9015,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9092,6 +9386,12 @@ snapshots: help-me@5.0.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} http-errors@2.0.1: @@ -9102,6 +9402,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.7.2: @@ -9149,6 +9463,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-subdir@1.2.0: @@ -9184,6 +9500,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0(@noble/hashes@2.0.1): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + cssstyle: 6.2.0 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json5@2.2.3: {} @@ -9265,6 +9608,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9475,6 +9820,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -9881,6 +10228,14 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -10034,6 +10389,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -10250,6 +10607,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10315,6 +10674,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -10490,6 +10853,8 @@ snapshots: transitivePeerDependencies: - supports-color + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tailwind-merge@3.4.1: {} @@ -10519,12 +10884,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.25: {} + + tldts@7.0.25: + dependencies: + tldts-core: 7.0.25 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.25 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -10559,6 +10938,10 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.22.0: {} + + undici@7.22.0: {} + unidiff@1.0.4: dependencies: diff: 5.2.2 @@ -10752,7 +11135,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10780,6 +11163,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.12.0 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -10794,7 +11178,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10822,6 +11206,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -10838,6 +11223,22 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -10856,6 +11257,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/server/package.json b/server/package.json index 2e470111..cf68566b 100644 --- a/server/package.json +++ b/server/package.json @@ -34,17 +34,19 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", - "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", "better-auth": "1.4.18", "detect-port": "^2.1.0", + "dompurify": "^3.3.2", "dotenv": "^17.0.1", "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", + "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", "pino": "^9.6.0", @@ -56,6 +58,7 @@ "devDependencies": { "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", + "@types/jsdom": "^28.0.0", "@types/multer": "^2.0.0", "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", diff --git a/server/src/__tests__/assets.test.ts b/server/src/__tests__/assets.test.ts index eb58e24a..85ddf56a 100644 --- a/server/src/__tests__/assets.test.ts +++ b/server/src/__tests__/assets.test.ts @@ -25,10 +25,10 @@ function createAsset() { companyId: "company-1", provider: "local", objectKey: "assets/abc", - contentType: "image/svg+xml", + contentType: "image/png", byteSize: 40, sha256: "sha256-sample", - originalFilename: "logo.svg", + originalFilename: "logo.png", createdByAgentId: null, createdByUserId: "user-1", createdAt: now, @@ -36,7 +36,7 @@ function createAsset() { }; } -function createStorageService(contentType = "image/svg+xml"): StorageService { +function createStorageService(contentType = "image/png"): StorageService { const putFile: StorageService["putFile"] = vi.fn(async (input: { companyId: string; namespace: string; @@ -84,29 +84,63 @@ describe("POST /api/companies/:companyId/assets/images", () => { logActivityMock.mockReset(); }); - it("accepts SVG image uploads and returns an asset path", async () => { - const svg = createStorageService("image/svg+xml"); - const app = createApp(svg); + it("accepts PNG image uploads and returns an asset path", async () => { + const png = createStorageService("image/png"); + const app = createApp(png); createAssetMock.mockResolvedValue(createAsset()); const res = await request(app) .post("/api/companies/company-1/assets/images") .field("namespace", "companies") - .attach("file", Buffer.from(""), "logo.svg"); + .attach("file", Buffer.from("png"), "logo.png"); expect(res.status).toBe(201); expect(res.body.contentPath).toBe("/api/assets/asset-1/content"); expect(createAssetMock).toHaveBeenCalledTimes(1); - expect(svg.putFile).toHaveBeenCalledWith({ + expect(png.putFile).toHaveBeenCalledWith({ companyId: "company-1", namespace: "assets/companies", - originalFilename: "logo.svg", - contentType: "image/svg+xml", + originalFilename: "logo.png", + contentType: "image/png", body: expect.any(Buffer), }); }); + it("sanitizes SVG image uploads before storing them", async () => { + const svg = createStorageService("image/svg+xml"); + const app = createApp(svg); + + createAssetMock.mockResolvedValue({ + ...createAsset(), + contentType: "image/svg+xml", + originalFilename: "logo.svg", + }); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach( + "file", + Buffer.from( + "", + ), + "logo.svg", + ); + + expect(res.status).toBe(201); + expect(svg.putFile).toHaveBeenCalledTimes(1); + const stored = (svg.putFile as ReturnType).mock.calls[0]?.[0]; + expect(stored.contentType).toBe("image/svg+xml"); + expect(stored.originalFilename).toBe("logo.svg"); + const body = stored.body.toString("utf8"); + expect(body).toContain(" { const app = createApp(createStorageService()); createAssetMock.mockResolvedValue(createAsset()); @@ -154,4 +188,18 @@ describe("POST /api/companies/:companyId/assets/images", () => { expect(res.body.error).toBe("Unsupported image type: text/plain"); expect(createAssetMock).not.toHaveBeenCalled(); }); + + it("rejects SVG image uploads that cannot be sanitized", async () => { + const app = createApp(createStorageService("image/svg+xml")); + createAssetMock.mockResolvedValue(createAsset()); + + const res = await request(app) + .post("/api/companies/company-1/assets/images") + .field("namespace", "companies") + .attach("file", Buffer.from("not actually svg"), "logo.svg"); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("SVG could not be sanitized"); + expect(createAssetMock).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index 0b1b81cb..7af319e0 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -1,5 +1,7 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import createDOMPurify from "dompurify"; +import { JSDOM } from "jsdom"; import type { Db } from "@paperclipai/db"; import { createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; @@ -8,15 +10,80 @@ import { assertCompanyAccess, getActorInfo } from "./authz.js"; const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; const MAX_COMPANY_LOGO_BYTES = 100 * 1024; +const SVG_CONTENT_TYPE = "image/svg+xml"; const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", - "image/svg+xml", + SVG_CONTENT_TYPE, ]); +function sanitizeSvgBuffer(input: Buffer): Buffer | null { + const raw = input.toString("utf8").trim(); + if (!raw) return null; + + const baseDom = new JSDOM(""); + const domPurify = createDOMPurify( + baseDom.window as unknown as Parameters[0], + ); + domPurify.addHook("uponSanitizeAttribute", (_node, data) => { + const attrName = data.attrName.toLowerCase(); + const attrValue = (data.attrValue ?? "").trim(); + + if (attrName.startsWith("on")) { + data.keepAttr = false; + return; + } + + if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) { + data.keepAttr = false; + } + }); + + let parsedDom: JSDOM | null = null; + try { + const sanitized = domPurify.sanitize(raw, { + USE_PROFILES: { svg: true, svgFilters: true, html: false }, + FORBID_TAGS: ["script", "foreignObject"], + FORBID_CONTENTS: ["script", "foreignObject"], + RETURN_TRUSTED_TYPE: false, + }); + + parsedDom = new JSDOM(sanitized, { contentType: SVG_CONTENT_TYPE }); + const document = parsedDom.window.document; + const root = document.documentElement; + if (!root || root.tagName.toLowerCase() !== "svg") return null; + + for (const el of Array.from(root.querySelectorAll("script, foreignObject"))) { + el.remove(); + } + for (const el of Array.from(root.querySelectorAll("*"))) { + for (const attr of Array.from(el.attributes)) { + const attrName = attr.name.toLowerCase(); + const attrValue = attr.value.trim(); + if (attrName.startsWith("on")) { + el.removeAttribute(attr.name); + continue; + } + if ((attrName === "href" || attrName === "xlink:href") && attrValue && !attrValue.startsWith("#")) { + el.removeAttribute(attr.name); + } + } + } + + const output = root.outerHTML.trim(); + if (!output || !/^]/i.test(output)) return null; + return Buffer.from(output, "utf8"); + } catch { + return null; + } finally { + parsedDom?.window.close(); + baseDom.window.close(); + } +} + export function assetRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = assetService(db); @@ -58,12 +125,21 @@ export function assetRoutes(db: Db, storage: StorageService) { return; } - const contentType = (file.mimetype || "").toLowerCase(); + let contentType = (file.mimetype || "").toLowerCase(); if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) { res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); return; } - if (file.buffer.length <= 0) { + let fileBody = file.buffer; + if (contentType === SVG_CONTENT_TYPE) { + const sanitized = sanitizeSvgBuffer(file.buffer); + if (!sanitized || sanitized.length <= 0) { + res.status(422).json({ error: "SVG could not be sanitized" }); + return; + } + fileBody = sanitized; + } + if (fileBody.length <= 0) { res.status(422).json({ error: "Image is empty" }); return; } @@ -76,7 +152,7 @@ export function assetRoutes(db: Db, storage: StorageService) { const namespaceSuffix = parsedMeta.data.namespace ?? "general"; const isCompanyLogoNamespace = namespaceSuffix === "companies" || namespaceSuffix.startsWith("companies/"); - if (isCompanyLogoNamespace && file.buffer.length > MAX_COMPANY_LOGO_BYTES) { + if (isCompanyLogoNamespace && fileBody.length > MAX_COMPANY_LOGO_BYTES) { res.status(422).json({ error: `Image exceeds ${MAX_COMPANY_LOGO_BYTES} bytes` }); return; } @@ -87,7 +163,7 @@ export function assetRoutes(db: Db, storage: StorageService) { namespace: `assets/${namespaceSuffix}`, originalFilename: file.originalname || null, contentType, - body: file.buffer, + body: fileBody, }); const asset = await svc.create(companyId, { @@ -144,9 +220,14 @@ export function assetRoutes(db: Db, storage: StorageService) { assertCompanyAccess(req, asset.companyId); const object = await storage.getObject(asset.companyId, asset.objectKey); - res.setHeader("Content-Type", asset.contentType || object.contentType || "application/octet-stream"); + const responseContentType = asset.contentType || object.contentType || "application/octet-stream"; + res.setHeader("Content-Type", responseContentType); res.setHeader("Content-Length", String(asset.byteSize || object.contentLength || 0)); res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === SVG_CONTENT_TYPE) { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } const filename = asset.originalFilename ?? "asset"; res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 8d68fcff..cf174c9d 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -278,12 +278,12 @@ export function CompanySettings() {
From f44efce2658484829ae96b60a38df01b946f8dd2 Mon Sep 17 00:00:00 2001 From: JonCSykes Date: Fri, 6 Mar 2026 23:15:45 -0500 Subject: [PATCH 004/422] Add `@types/node` as a devDependency in cursor-local package --- packages/adapters/cursor-local/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 575f9e1b..4ef66052 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053c76c5..2a6c5003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 typescript: specifier: ^5.7.3 version: 5.9.3 From 5114c32810c7957e5126e073dc7c5e6dcf6820a7 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 19:01:04 +0000 Subject: [PATCH 005/422] Fix opencode-local adapter: parser, UI, CLI, and environment tests - Move costUsd to top-level return field in parseOpenCodeJsonl (out of usage) - Fix session-not-found regex to match "Session not found" pattern - Use callID for toolUseId in UI stdout parser, add status/metadata header - Fix CLI formatter: separate tool_call/tool_result lines, split step_finish - Enable createIfMissing for cwd validation in environment tests - Add empty OPENAI_API_KEY override detection - Classify ProviderModelNotFoundError as warning during model discovery - Make model discovery best-effort when no model is configured Co-Authored-By: Claude Opus 4.6 --- .../opencode-local/src/cli/format-event.ts | 26 +++--- .../opencode-local/src/server/execute.ts | 2 +- .../opencode-local/src/server/parse.test.ts | 2 +- .../opencode-local/src/server/parse.ts | 7 +- .../opencode-local/src/server/test.ts | 83 +++++++++++++++---- .../opencode-local/src/ui/parse-stdout.ts | 15 +++- 6 files changed, 100 insertions(+), 35 deletions(-) diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts index 00d0ec76..58d2038d 100644 --- a/packages/adapters/opencode-local/src/cli/format-event.ts +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -74,20 +74,25 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { if (type === "tool_use") { const part = asRecord(parsed.part); const tool = asString(part?.tool, "tool"); + const callID = asString(part?.callID); const state = asRecord(part?.state); const status = asString(state?.status); - const summary = `tool_${status || "event"}: ${tool}`; const isError = status === "error"; - console.log((isError ? pc.red : pc.yellow)(summary)); - const input = state?.input; - if (input !== undefined) { - try { - console.log(pc.gray(JSON.stringify(input, null, 2))); - } catch { - console.log(pc.gray(String(input))); + const metadata = asRecord(state?.metadata); + + console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`)); + + if (status) { + const metaParts = [`status=${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`); + } } + console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`)); } - const output = asString(state?.output) || asString(state?.error); + + const output = (asString(state?.output) || asString(state?.error)).trim(); if (output) console.log((isError ? pc.red : pc.gray)(output)); return; } @@ -101,7 +106,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { const cached = asNumber(cache?.read, 0); const cost = asNumber(part?.cost, 0); const reason = asString(part?.reason, "step"); - console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + console.log(pc.blue(`step finished: reason=${reason}`)); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); return; } diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 338646b3..970896af 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -340,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { inputTokens: 120, cachedInputTokens: 20, outputTokens: 50, - costUsd: 0.0025, }); + expect(parsed.costUsd).toBeCloseTo(0.0025, 6); expect(parsed.errorMessage).toContain("model unavailable"); }); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index 5cbfa46c..96af0ed1 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -27,8 +27,8 @@ export function parseOpenCodeJsonl(stdout: string) { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, - costUsd: 0, }; + let costUsd = 0; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); @@ -56,7 +56,7 @@ export function parseOpenCodeJsonl(stdout: string) { usage.inputTokens += asNumber(tokens.input, 0); usage.cachedInputTokens += asNumber(cache.read, 0); usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); - usage.costUsd += asNumber(part.cost, 0); + costUsd += asNumber(part.cost, 0); continue; } @@ -81,6 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) { sessionId, summary: messages.join("\n\n").trim(), usage, + costUsd, errorMessage: errors.length > 0 ? errors.join("\n") : null, }; } @@ -92,7 +93,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b .filter(Boolean) .join("\n"); - return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( + return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test( haystack, ); } diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 569f0d75..ac40d456 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -59,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -79,6 +79,17 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } + + const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null; + if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") { + checks.push({ + code: "opencode_openai_api_key_missing", + level: "warn", + message: "OPENAI_API_KEY override is empty.", + hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.", + }); + } + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); @@ -111,7 +122,9 @@ export async function testEnvironment( checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); let modelValidationPassed = false; - if (canRunProbe) { + const configuredModel = asString(config.model, "").trim(); + + if (canRunProbe && configuredModel) { try { const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); if (discovered.length > 0) { @@ -129,24 +142,52 @@ export async function testEnvironment( }); } } catch (err) { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: err instanceof Error ? err.message : "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: errMsg || "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } + } + } else if (canRunProbe && !configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } } } - const configuredModel = asString(config.model, "").trim(); - if (!configuredModel) { - checks.push({ - code: "opencode_model_required", - level: "error", - message: "OpenCode requires a configured model in provider/model format.", - hint: "Set adapterConfig.model using an ID from `opencode models`.", - }); - } else if (canRunProbe) { + const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { try { await ensureOpenCodeModelConfiguredAndAvailable({ model: configuredModel, @@ -226,6 +267,14 @@ export async function testEnvironment( hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", }), }); + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + ...(detail ? { detail } : {}), + hint: "Run `opencode models` and choose an available provider/model ID.", + }); } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_auth_required", diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts index dc48e5d1..2060125a 100644 --- a/packages/adapters/opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -56,19 +56,28 @@ function parseToolUse(parsed: Record, ts: string): TranscriptEn const status = asString(state?.status); if (status !== "completed" && status !== "error") return [callEntry]; - const output = + const rawOutput = asString(state?.output) || asString(state?.error) || asString(part.title) || `${toolName} ${status}`; + const metadata = asRecord(state?.metadata); + const headerParts: string[] = [`status: ${status}`]; + if (metadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`); + } + } + const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim(); + return [ callEntry, { kind: "tool_result", ts, - toolUseId: asString(part.id, toolName), - content: output, + toolUseId: asString(part.callID) || asString(part.id, toolName), + content, isError: status === "error", }, ]; From fa7acd2482631a417a0a74f189737eb89482790e Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 14:12:22 -0500 Subject: [PATCH 006/422] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- packages/adapters/opencode-local/src/server/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index ac40d456..8c203266 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -59,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); checks.push({ code: "opencode_cwd_valid", level: "info", From fb684f25e93be97d5dc10b90a6a1fe3fb25336d2 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 7 Mar 2026 19:15:10 +0000 Subject: [PATCH 007/422] Address PR feedback: keep testEnvironment non-destructive, warn on swallowed errors - Update cwd test to expect an error for missing directories (matches createIfMissing: false accepted from review) - Add warn-level check for non-ProviderModelNotFoundError failures during best-effort model discovery when no model is configured Co-Authored-By: Claude Opus 4.6 --- packages/adapters/opencode-local/src/server/test.ts | 7 +++++++ .../opencode-local-adapter-environment.test.ts | 10 ++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 8c203266..5bb7aa36 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -180,6 +180,13 @@ export async function testEnvironment( detail: errMsg, hint: "Run `opencode models` and choose an available provider/model ID.", }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "warn", + message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); } } } diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts index c539d771..736dd9f8 100644 --- a/server/src/__tests__/opencode-local-adapter-environment.test.ts +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { testEnvironment } from "@paperclipai/adapter-opencode-local/server"; describe("opencode_local environment diagnostics", () => { - it("creates a missing working directory when cwd is absolute", async () => { + it("reports a missing working directory as an error when cwd is absolute", async () => { const cwd = path.join( os.tmpdir(), `paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => { }, }); - expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true); - expect(result.checks.some((check) => check.level === "error")).toBe(false); - const stats = await fs.stat(cwd); - expect(stats.isDirectory()).toBe(true); - await fs.rm(path.dirname(cwd), { recursive: true, force: true }); + expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true); + expect(result.checks.some((check) => check.level === "error")).toBe(true); + expect(result.status).toBe("fail"); }); it("treats an empty OPENAI_API_KEY override as missing", async () => { From 97d628d7847d78557605951f32141ac8ef3e6868 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 10 Mar 2026 23:12:13 -0700 Subject: [PATCH 008/422] feat: add Hermes Agent adapter (hermes_local) Adds support for Hermes Agent (https://github.com/NousResearch/hermes-agent) as a managed employee in Paperclip companies. Hermes Agent is a full-featured AI agent by Nous Research with 30+ native tools, persistent memory, session persistence, 80+ skills, MCP support, and multi-provider model access. Changes: - Add 'hermes_local' to AGENT_ADAPTER_TYPES (packages/shared) - Add @nousresearch/paperclip-adapter-hermes dependency (server) - Register hermesLocalAdapter in the adapter registry (server) The adapter package is maintained at: https://github.com/NousResearch/hermes-paperclip-adapter --- packages/shared/src/constants.ts | 1 + server/package.json | 1 + server/src/adapters/registry.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ba75dc8e..c78d1dd0 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [ "pi_local", "cursor", "openclaw_gateway", + "hermes_local", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; diff --git a/server/package.json b/server/package.json index aeb09944..63585fae 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", + "@nousresearch/paperclip-adapter-hermes": "github:NousResearch/hermes-paperclip-adapter", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 9fe536a0..571d8131 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -45,6 +45,14 @@ import { import { agentConfigurationDoc as piAgentConfigurationDoc, } from "@paperclipai/adapter-pi-local"; +import { + execute as hermesExecute, + testEnvironment as hermesTestEnvironment, +} from "@nousresearch/paperclip-adapter-hermes/server"; +import { + agentConfigurationDoc as hermesAgentConfigurationDoc, + models as hermesModels, +} from "@nousresearch/paperclip-adapter-hermes"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -111,6 +119,15 @@ const piLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: piAgentConfigurationDoc, }; +const hermesLocalAdapter: ServerAdapterModule = { + type: "hermes_local", + execute: hermesExecute, + testEnvironment: hermesTestEnvironment, + models: hermesModels, + supportsLocalAgentJwt: false, + agentConfigurationDoc: hermesAgentConfigurationDoc, +}; + const adaptersByType = new Map( [ claudeLocalAdapter, @@ -119,6 +136,7 @@ const adaptersByType = new Map( piLocalAdapter, cursorLocalAdapter, openclawGatewayAdapter, + hermesLocalAdapter, processAdapter, httpAdapter, ].map((a) => [a.type, a]), From 87c0bf9cdfa482889d18914e9d3b32b230779492 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 11:05:31 -0500 Subject: [PATCH 009/422] added v0.3.1.md changelog --- releases/v0.3.1.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 releases/v0.3.1.md diff --git a/releases/v0.3.1.md b/releases/v0.3.1.md new file mode 100644 index 00000000..7aa55bf6 --- /dev/null +++ b/releases/v0.3.1.md @@ -0,0 +1,54 @@ +# v0.3.1 + +> Released: 2026-03-12 + +## Highlights + +- **Gemini CLI adapter** — Full local adapter support for Google's Gemini CLI. Includes API-key detection, turn-limit handling, sandbox and approval modes, skill injection into `~/.gemini/`, and yolo-mode default. ([#452](https://github.com/paperclipai/paperclip/pull/452), [#656](https://github.com/paperclipai/paperclip/pull/656), @aaaaron) +- **Run transcript polish** — Run transcripts render markdown, fold command stdout, redact home paths and user identities, and display humanized event labels across both detail and live surfaces. ([#648](https://github.com/paperclipai/paperclip/pull/648), [#695](https://github.com/paperclipai/paperclip/pull/695)) +- **Inbox refinements** — Improved tab behavior, badge counts aligned with visible unread items, better mobile layout, and smoother new-issue submit state. ([#613](https://github.com/paperclipai/paperclip/pull/613)) +- **Improved onboarding wizard** — Onboarding now shows Claude Code and Codex as recommended adapters, collapses other types, and features animated step transitions with clickable tabs. Adapter environment checks animate on success and show debug output only on failure. ([#700](https://github.com/paperclipai/paperclip/pull/700)) + +## Improvements + +- **Instance heartbeat settings sidebar** — View and manage heartbeat configuration directly from the instance settings page with compact grouped run lists. ([#697](https://github.com/paperclipai/paperclip/pull/697)) +- **Project and agent configuration tabs** — New tabbed configuration UI for projects and agents, including execution workspace policy settings. ([#613](https://github.com/paperclipai/paperclip/pull/613)) +- **Agent runs tab** — Agent detail pages now include a dedicated runs tab. +- **Configurable attachment content types** — The `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` env var lets operators control which file types can be uploaded. ([#495](https://github.com/paperclipai/paperclip/pull/495), @subhendukundu) +- **Default max turns raised to 300** — Agents now default to 300 max turns instead of the previous limit. ([#701](https://github.com/paperclipai/paperclip/pull/701)) +- **Issue creator shown in sidebar** — The issue properties pane now displays who created each issue. ([#145](https://github.com/paperclipai/paperclip/pull/145), @cschneid) +- **Company-aware 404 handling** — The UI now shows company-scoped not-found pages instead of a generic error. +- **Tools for Worktree workflow for developers** — New `paperclipai worktree:make` command provisions isolated development instances with their own database, secrets, favicon branding, and git hooks. Worktrees support minimal seed mode, start-point selection, and automatic workspace rebinding. ([#496](https://github.com/paperclipai/paperclip/pull/496), [#530](https://github.com/paperclipai/paperclip/pull/530), [#545](https://github.com/paperclipai/paperclip/pull/545)) + +## Fixes + +- **Gemini Docker build** — Include the Gemini adapter manifest in the Docker deps stage so production builds succeed. ([#706](https://github.com/paperclipai/paperclip/pull/706), @zvictor) +- **Approval retries made idempotent** — Duplicate approval submissions no longer create duplicate records. ([#502](https://github.com/paperclipai/paperclip/pull/502), @davidahmann) +- **Heartbeat cost recording** — Costs are now routed through `costService` instead of being recorded inline, fixing missing cost attribution. ([#386](https://github.com/paperclipai/paperclip/pull/386), @domocarroll) +- **Claude Code env var leak** — Child adapter processes no longer inherit Claude Code's internal environment variables. ([#485](https://github.com/paperclipai/paperclip/pull/485), @jknair) +- **`parentId` query filter** — The issues list endpoint now correctly applies the `parentId` filter. ([#491](https://github.com/paperclipai/paperclip/pull/491), @lazmo88) +- **Remove `Cmd+1..9` shortcut** — The company-switch keyboard shortcut conflicted with browser tab switching and has been removed. ([#628](https://github.com/paperclipai/paperclip/pull/628), @STRML) +- **IME composition Enter** — Pressing Enter during IME composition in the new-issue title no longer moves focus prematurely. ([#578](https://github.com/paperclipai/paperclip/pull/578), @kaonash) +- **Restart hint after hostname change** — The CLI now reminds users to restart the server after changing allowed hostnames. ([#549](https://github.com/paperclipai/paperclip/pull/549), @mvanhorn) +- **Default `dangerouslySkipPermissions` for unattended agents** — Agents running without a terminal now default to skipping permission prompts instead of hanging. ([#388](https://github.com/paperclipai/paperclip/pull/388), @ohld) +- **Remove stale `paperclip` property from OpenClaw Gateway** — Cleaned up an invalid agent parameter that caused warnings. ([#626](https://github.com/paperclipai/paperclip/pull/626), @openagen) +- **Issue description overflow** — Long descriptions no longer break the layout. +- **Worktree JWT persistence** — Environment-sensitive JWT config is now correctly carried into worktree instances. +- **Dev migration prompt** — Fixed embedded `db:migrate` flow for local development. +- **Markdown link dialog positioning** — The link insertion dialog no longer renders off-screen. +- **Pretty logger metadata** — Server log metadata stays on one line instead of wrapping. + +## Upgrade Guide + +Two new database migrations (`0026`, `0027`) will run automatically on startup: + +- **Migration 0026** adds the `workspace_runtime_services` table for worktree-aware runtime support. +- **Migration 0027** adds `execution_workspace_settings` to issues and `execution_workspace_policy` to projects. + +Both are additive (new table and new columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically. + +## Contributors + +Thank you to everyone who contributed to this release! + +@aaaaron, @adamrobbie-nudge, @cschneid, @davidahmann, @domocarroll, @jknair, @kaonash, @lazmo88, @mvanhorn, @ohld, @openagen, @STRML, @subhendukundu, @zvictor From 873535fbf0b4b133e2bb9c987c889763f8ee9b4b Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 12:42:00 -0500 Subject: [PATCH 010/422] verify the packages actually make it to npm --- scripts/release-lib.sh | 30 ++++++++++++++++++++++++++++++ scripts/release.sh | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index d2a33526..7a4df5f0 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -196,6 +196,36 @@ npm_version_exists() { [ "$resolved" = "$version" ] } +npm_package_version_exists() { + local package_name="$1" + local version="$2" + local resolved + + resolved="$(npm view "${package_name}@${version}" version 2>/dev/null || true)" + [ "$resolved" = "$version" ] +} + +wait_for_npm_package_version() { + local package_name="$1" + local version="$2" + local attempts="${3:-12}" + local delay_seconds="${4:-5}" + local attempt=1 + + while [ "$attempt" -le "$attempts" ]; do + if npm_package_version_exists "$package_name" "$version"; then + return 0 + fi + + if [ "$attempt" -lt "$attempts" ]; then + sleep "$delay_seconds" + fi + attempt=$((attempt + 1)) + done + + return 1 +} + require_clean_worktree() { if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then release_fail "working tree is not clean. Commit, stash, or remove changes before releasing." diff --git a/scripts/release.sh b/scripts/release.sh index 555a674c..34eb336d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -181,10 +181,12 @@ for (const rel of roots) { rows.sort((a, b) => a[0].localeCompare(b[0])); for (const [dir, name] of rows) { - const key = `${dir}\t${name}`; + const pkgPath = path.join(root, dir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + const key = `${dir}\t${name}\t${pkg.version}`; if (seen.has(key)) continue; seen.add(key); - process.stdout.write(`${dir}\t${name}\n`); + process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`); } NODE } @@ -348,6 +350,7 @@ if [ "$canary" = true ]; then npx changeset pre enter canary fi npx changeset version +VERSIONED_PACKAGE_INFO="$(list_public_package_info)" if [ "$canary" = true ]; then BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0" @@ -403,6 +406,31 @@ else npx changeset publish release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi + + release_info "" + release_info "==> Post-publish verification: Confirming npm package availability..." + VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}" + VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" + MISSING_PUBLISHED_PACKAGES="" + while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do + [ -z "$pkg_name" ] && continue + release_info " Checking $pkg_name@$pkg_version" + if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then + release_info " ✓ Found on npm" + continue + fi + + if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then + MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, " + fi + MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}" + done <<< "$VERSIONED_PACKAGE_INFO" + + if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then + release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good." + fi + + release_info " ✓ Verified all versioned packages are available on npm" fi release_info "" From 964e04369ac3505579388078cebf6f7632d5916f Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 12:55:26 -0500 Subject: [PATCH 011/422] fixes verification --- scripts/release.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 34eb336d..5e64fa97 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -350,7 +350,6 @@ if [ "$canary" = true ]; then npx changeset pre enter canary fi npx changeset version -VERSIONED_PACKAGE_INFO="$(list_public_package_info)" if [ "$canary" = true ]; then BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0" @@ -359,6 +358,8 @@ if [ "$canary" = true ]; then fi fi +VERSIONED_PACKAGE_INFO="$(list_public_package_info)" + VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." From 63c62e3ada077bac45292f3d2779bd48a8831cda Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 13:09:22 -0500 Subject: [PATCH 012/422] chore: release v0.3.1 --- cli/CHANGELOG.md | 18 ++++++++++++++++++ cli/package.json | 2 +- packages/adapter-utils/CHANGELOG.md | 6 ++++++ packages/adapter-utils/package.json | 2 +- packages/adapters/claude-local/CHANGELOG.md | 8 ++++++++ packages/adapters/claude-local/package.json | 2 +- packages/adapters/codex-local/CHANGELOG.md | 8 ++++++++ packages/adapters/codex-local/package.json | 2 +- packages/adapters/cursor-local/CHANGELOG.md | 8 ++++++++ packages/adapters/cursor-local/package.json | 2 +- packages/adapters/gemini-local/package.json | 2 +- .../adapters/openclaw-gateway/CHANGELOG.md | 8 ++++++++ .../adapters/openclaw-gateway/package.json | 2 +- packages/adapters/opencode-local/CHANGELOG.md | 8 ++++++++ packages/adapters/opencode-local/package.json | 2 +- packages/adapters/pi-local/CHANGELOG.md | 8 ++++++++ packages/adapters/pi-local/package.json | 2 +- packages/db/CHANGELOG.md | 8 ++++++++ packages/db/package.json | 2 +- packages/shared/CHANGELOG.md | 6 ++++++ packages/shared/package.json | 2 +- server/CHANGELOG.md | 17 +++++++++++++++++ server/package.json | 2 +- 23 files changed, 115 insertions(+), 12 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6bae020a..d261b8a8 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # paperclipai +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + - @paperclipai/adapter-claude-local@0.3.1 + - @paperclipai/adapter-codex-local@0.3.1 + - @paperclipai/adapter-cursor-local@0.3.1 + - @paperclipai/adapter-gemini-local@0.3.1 + - @paperclipai/adapter-openclaw-gateway@0.3.1 + - @paperclipai/adapter-opencode-local@0.3.1 + - @paperclipai/adapter-pi-local@0.3.1 + - @paperclipai/db@0.3.1 + - @paperclipai/shared@0.3.1 + - @paperclipai/server@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/cli/package.json b/cli/package.json index 089f5a59..4bda09ed 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "paperclipai", - "version": "0.3.0", + "version": "0.3.1", "description": "Paperclip CLI — orchestrate AI agent teams to run a business", "type": "module", "bin": { diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index dd4c015b..76cabbd7 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @paperclipai/adapter-utils +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 4b264bf4..3a908ee5 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-utils", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index ac3bcac5..b9035585 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-claude-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index f73390b7..35a6d9ed 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-claude-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/codex-local/CHANGELOG.md b/packages/adapters/codex-local/CHANGELOG.md index 8a4e2d11..45c143e7 100644 --- a/packages/adapters/codex-local/CHANGELOG.md +++ b/packages/adapters/codex-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-codex-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index 81801045..4b28c729 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-codex-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/cursor-local/CHANGELOG.md b/packages/adapters/cursor-local/CHANGELOG.md index ae97efac..df26ccde 100644 --- a/packages/adapters/cursor-local/CHANGELOG.md +++ b/packages/adapters/cursor-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-cursor-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 67434641..3561f0ff 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-cursor-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json index 6b214f7e..1d482fb1 100644 --- a/packages/adapters/gemini-local/package.json +++ b/packages/adapters/gemini-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-gemini-local", - "version": "0.2.7", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/openclaw-gateway/CHANGELOG.md b/packages/adapters/openclaw-gateway/CHANGELOG.md index 8b6357e3..f78f5181 100644 --- a/packages/adapters/openclaw-gateway/CHANGELOG.md +++ b/packages/adapters/openclaw-gateway/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-openclaw-gateway +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index c81ee740..323d09a2 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-openclaw-gateway", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index 904b21de..9ccc9e8d 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-opencode-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index cf2d078a..e2816953 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-opencode-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/pi-local/CHANGELOG.md b/packages/adapters/pi-local/CHANGELOG.md index f7297faa..fb3c93a4 100644 --- a/packages/adapters/pi-local/CHANGELOG.md +++ b/packages/adapters/pi-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-pi-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index 442d83d2..c286f84e 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-pi-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/db/CHANGELOG.md b/packages/db/CHANGELOG.md index 077cb652..03d37638 100644 --- a/packages/db/CHANGELOG.md +++ b/packages/db/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/db +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/shared@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/db/package.json b/packages/db/package.json index 1dae4bde..f22d1e9e 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/db", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/shared/CHANGELOG.md b/packages/shared/CHANGELOG.md index 492cee6f..6ae4a3fe 100644 --- a/packages/shared/CHANGELOG.md +++ b/packages/shared/CHANGELOG.md @@ -1,5 +1,11 @@ # @paperclipai/shared +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/shared/package.json b/packages/shared/package.json index 33452f67..3a844f11 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/shared", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 7749b094..56110f9e 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,5 +1,22 @@ # @paperclipai/server +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + - @paperclipai/adapter-claude-local@0.3.1 + - @paperclipai/adapter-codex-local@0.3.1 + - @paperclipai/adapter-cursor-local@0.3.1 + - @paperclipai/adapter-gemini-local@0.3.1 + - @paperclipai/adapter-openclaw-gateway@0.3.1 + - @paperclipai/adapter-opencode-local@0.3.1 + - @paperclipai/adapter-pi-local@0.3.1 + - @paperclipai/db@0.3.1 + - @paperclipai/shared@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/server/package.json b/server/package.json index 1dd9b073..1672307d 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/server", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts" From a2b7611d8d2392094553d25a59643d68ad17e2ac Mon Sep 17 00:00:00 2001 From: Paperclip Date: Thu, 12 Mar 2026 14:33:11 -0500 Subject: [PATCH 013/422] Fix local-cli skill install for moved .agents skills Co-Authored-By: Paperclip --- cli/src/commands/client/agent.ts | 49 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 36eb04e6..a6e86277 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -40,7 +40,9 @@ interface SkillsInstallSummary { const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const PAPERCLIP_SKILLS_CANDIDATES = [ + path.resolve(__moduleDir, "../../../../../.agents/skills"), // dev: cli/src/commands/client -> repo root/.agents/skills path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills + path.resolve(process.cwd(), ".agents/skills"), path.resolve(process.cwd(), "skills"), ]; @@ -85,8 +87,48 @@ async function installSkillsForTarget( const target = path.join(targetSkillsDir, entry.name); const existing = await fs.lstat(target).catch(() => null); if (existing) { - summary.skipped.push(entry.name); - continue; + if (existing.isSymbolicLink()) { + let linkedPath: string | null = null; + try { + linkedPath = await fs.readlink(target); + } catch (err) { + await fs.unlink(target); + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + continue; + } catch (linkErr) { + summary.failed.push({ + name: entry.name, + error: + err instanceof Error && linkErr instanceof Error + ? `${err.message}; then ${linkErr.message}` + : err instanceof Error + ? err.message + : `Failed to recover broken symlink: ${String(err)}`, + }); + continue; + } + } + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + const linkedTargetExists = await fs + .stat(resolvedLinkedPath) + .then(() => true) + .catch(() => false); + + if (!linkedTargetExists) { + await fs.unlink(target); + } else { + summary.skipped.push(entry.name); + continue; + } + } else { + summary.skipped.push(entry.name); + continue; + } } try { @@ -98,6 +140,7 @@ async function installSkillsForTarget( error: err instanceof Error ? err.message : String(err), }); } + } } return summary; @@ -213,7 +256,7 @@ export function registerAgentCommands(program: Command): void { const skillsDir = await resolvePaperclipSkillsDir(); if (!skillsDir) { throw new Error( - "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", + "Could not locate local Paperclip skills directory. Expected ./skills or ./.agents/skills in the repo checkout.", ); } From 13c2ecd1d086dbbea2f7441374f6f6ceb8493b29 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:37:30 -0500 Subject: [PATCH 014/422] Delay onboarding starter task creation until launch Co-Authored-By: Paperclip --- ui/src/components/OnboardingWizard.tsx | 64 ++++++++++++++------------ 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 5d166929..88e16d09 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -494,23 +494,41 @@ export function OnboardingWizard() { } async function handleStep3Next() { + if (!createdCompanyId || !createdAgentId) return; + setError(null); + setStep(4); + } + + async function handleLaunch() { if (!createdCompanyId || !createdAgentId) return; setLoading(true); setError(null); try { - const issue = await issuesApi.create(createdCompanyId, { - title: taskTitle.trim(), - ...(taskDescription.trim() - ? { description: taskDescription.trim() } - : {}), - assigneeAgentId: createdAgentId, - status: "todo" - }); - setCreatedIssueRef(issue.identifier ?? issue.id); - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.list(createdCompanyId) - }); - setStep(4); + let issueRef = createdIssueRef; + if (!issueRef) { + const issue = await issuesApi.create(createdCompanyId, { + title: taskTitle.trim(), + ...(taskDescription.trim() + ? { description: taskDescription.trim() } + : {}), + assigneeAgentId: createdAgentId, + status: "todo" + }); + issueRef = issue.identifier ?? issue.id; + setCreatedIssueRef(issueRef); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.list(createdCompanyId) + }); + } + + setSelectedCompanyId(createdCompanyId); + reset(); + closeOnboarding(); + navigate( + createdCompanyPrefix + ? `/${createdCompanyPrefix}/issues/${issueRef}` + : `/issues/${issueRef}` + ); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create task"); } finally { @@ -518,20 +536,6 @@ export function OnboardingWizard() { } } - async function handleLaunch() { - if (!createdAgentId) return; - setLoading(true); - setError(null); - setLoading(false); - reset(); - closeOnboarding(); - if (createdCompanyPrefix) { - navigate(`/${createdCompanyPrefix}/dashboard`); - return; - } - navigate("/dashboard"); - } - function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -1175,8 +1179,8 @@ export function OnboardingWizard() {

Ready to launch

- Everything is set up. Your assigned task already woke - the agent, so you can jump straight to the issue. + Everything is set up. Launching now will create the + starter task, wake the agent, and open the issue.

@@ -1291,7 +1295,7 @@ export function OnboardingWizard() { ) : ( )} - {loading ? "Opening..." : "Open Issue"} + {loading ? "Creating..." : "Create & Open Issue"} )}
From 402cef66e9c0933ed0f126041a64e423d1e9fa86 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 14:39:50 -0500 Subject: [PATCH 015/422] Change sidebar Documentation link to external docs.paperclip.ing The sidebar Documentation links were pointing to an internal /docs route. Updated both mobile and desktop sidebar instances to link to https://docs.paperclip.ing/ in a new tab instead. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 12cc6f88..a90efa9a 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -5,7 +5,6 @@ import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router" import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; -import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; @@ -248,12 +247,15 @@ export function Layout() {
- + + + Documentation + - {issue.createdByUserId && ( + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6899bd5c..442f8ae4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; +import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; @@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } -function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { +function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { let result = issues; if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId)); + if (state.assignees.length > 0) { + result = result.filter((issue) => { + for (const assignee of state.assignees) { + if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; + if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; + if (issue.assigneeAgentId === assignee) return true; + } + return false; + }); + } if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); return result; } @@ -165,6 +176,11 @@ export function IssuesList({ }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -224,9 +240,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState); + const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -253,13 +269,21 @@ export function IssuesList({ .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } // assignee - const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned"); + const groups = groupBy( + filtered, + (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), + ); return Object.keys(groups).map((key) => ({ key, - label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)), + label: + key === "__unassigned" + ? "Unassigned" + : key.startsWith("__user:") + ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") + : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); const newIssueDefaults = (groupKey?: string) => { const defaults: Record = {}; @@ -267,13 +291,16 @@ export function IssuesList({ if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; - else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey; + else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") { + if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); + else defaults.assigneeAgentId = groupKey; + } } return defaults; }; - const assignIssue = (issueId: string, assigneeAgentId: string | null) => { - onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); + const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }; @@ -419,22 +446,37 @@ export function IssuesList({
{/* Assignee */} - {agents && agents.length > 0 && ( -
- Assignee -
- {agents.map((agent) => ( - - ))} -
+
+ Assignee +
+ + {currentUserId && ( + + )} + {(agents ?? []).map((agent) => ( + + ))}
- )} +
{labels && labels.length > 0 && (
@@ -683,6 +725,13 @@ export function IssuesList({ > {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( + ) : issue.assigneeUserId ? ( + + + + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} + ) : ( @@ -701,7 +750,7 @@ export function IssuesList({ > setAssigneeSearch(e.target.value)} autoFocus @@ -710,16 +759,32 @@ export function IssuesList({ + {currentUserId && ( + + )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; @@ -737,7 +802,7 @@ export function IssuesList({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - assignIssue(issue.id, agent.id); + assignIssue(issue.id, agent.id, null); }} > diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index ef7b12b8..904ceb88 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,7 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + assigneeUserId?: string; title?: string; description?: string; } diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts new file mode 100644 index 00000000..1ce22ef7 --- /dev/null +++ b/ui/src/lib/assignees.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + formatAssigneeUserLabel, + parseAssigneeValue, +} from "./assignees"; + +describe("assignee selection helpers", () => { + it("encodes and parses agent assignees", () => { + const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" }); + + expect(value).toBe("agent:agent-123"); + expect(parseAssigneeValue(value)).toEqual({ + assigneeAgentId: "agent-123", + assigneeUserId: null, + }); + }); + + it("encodes and parses current-user assignees", () => { + const [option] = currentUserAssigneeOption("local-board"); + + expect(option).toEqual({ + id: "user:local-board", + label: "Me", + searchText: "me board human local-board", + }); + expect(parseAssigneeValue(option.id)).toEqual({ + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + }); + + it("treats an empty selection as no assignee", () => { + expect(parseAssigneeValue("")).toEqual({ + assigneeAgentId: null, + assigneeUserId: null, + }); + }); + + it("keeps backward compatibility for raw agent ids in saved drafts", () => { + expect(parseAssigneeValue("legacy-agent-id")).toEqual({ + assigneeAgentId: "legacy-agent-id", + assigneeUserId: null, + }); + }); + + it("formats current and board user labels consistently", () => { + expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me"); + expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); + expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts new file mode 100644 index 00000000..274bcd40 --- /dev/null +++ b/ui/src/lib/assignees.ts @@ -0,0 +1,51 @@ +export interface AssigneeSelection { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AssigneeOption { + id: string; + label: string; + searchText?: string; +} + +export function assigneeValueFromSelection(selection: Partial): string { + if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; + if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; + return ""; +} + +export function parseAssigneeValue(value: string): AssigneeSelection { + if (!value) { + return { assigneeAgentId: null, assigneeUserId: null }; + } + if (value.startsWith("agent:")) { + const assigneeAgentId = value.slice("agent:".length); + return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null }; + } + if (value.startsWith("user:")) { + const assigneeUserId = value.slice("user:".length); + return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null }; + } + // Backward compatibility for older drafts/defaults that stored a raw agent id. + return { assigneeAgentId: value, assigneeUserId: null }; +} + +export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] { + if (!currentUserId) return []; + return [{ + id: assigneeValueFromSelection({ assigneeUserId: currentUserId }), + label: "Me", + searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`, + }]; +} + +export function formatAssigneeUserLabel( + userId: string | null | undefined, + currentUserId: string | null | undefined, +): string | null { + if (!userId) return null; + if (currentUserId && userId === currentUserId) return "Me"; + if (userId === "local-board") return "Board"; + return userId.slice(0, 5); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index bb152e17..9a43f26a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -304,8 +304,7 @@ export function IssueDetail() { options.push({ id: `agent:${agent.id}`, label: agent.name }); } if (currentUserId) { - const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; - options.push({ id: `user:${currentUserId}`, label }); + options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; }, [agents, currentUserId]); From ff022208904750ba2e07908b54453dd151f8b118 Mon Sep 17 00:00:00 2001 From: Alaa Alghazouli Date: Thu, 12 Mar 2026 23:03:44 +0100 Subject: [PATCH 021/422] fix: add initdbFlags to embedded postgres ctor types --- cli/src/commands/worktree.ts | 1 + packages/db/src/migration-runtime.ts | 1 + server/src/index.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 7311793b..e807dafb 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -83,6 +83,7 @@ type EmbeddedPostgresCtor = new (opts: { password: string; port: number; persistent: boolean; + initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts index 10b7b9b1..e07bdf04 100644 --- a/packages/db/src/migration-runtime.ts +++ b/packages/db/src/migration-runtime.ts @@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: { password: string; port: number; persistent: boolean; + initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; diff --git a/server/src/index.ts b/server/src/index.ts index 50c6a7b2..0479f878 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -53,6 +53,7 @@ type EmbeddedPostgresCtor = new (opts: { password: string; port: number; persistent: boolean; + initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; From 4e354ad00d7a1592e5e8d800dababc0bc3db73af Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 12 Mar 2026 17:03:49 -0700 Subject: [PATCH 022/422] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20pin=20dependency=20and=20add=20sessionCodec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin @nousresearch/paperclip-adapter-hermes to v0.1.0 tag for reproducible builds and supply-chain safety - Import and wire hermesSessionCodec into the adapter registration for structured session parameter validation (matching claude_local, codex_local, and other adapters that support session persistence) --- server/package.json | 2 +- server/src/adapters/registry.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index 63585fae..e3a9b821 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", - "@nousresearch/paperclip-adapter-hermes": "github:NousResearch/hermes-paperclip-adapter", + "@nousresearch/paperclip-adapter-hermes": "github:NousResearch/hermes-paperclip-adapter#v0.1.0", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 571d8131..f112f788 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -48,6 +48,7 @@ import { import { execute as hermesExecute, testEnvironment as hermesTestEnvironment, + sessionCodec as hermesSessionCodec, } from "@nousresearch/paperclip-adapter-hermes/server"; import { agentConfigurationDoc as hermesAgentConfigurationDoc, @@ -123,6 +124,7 @@ const hermesLocalAdapter: ServerAdapterModule = { type: "hermes_local", execute: hermesExecute, testEnvironment: hermesTestEnvironment, + sessionCodec: hermesSessionCodec, models: hermesModels, supportsLocalAgentJwt: false, agentConfigurationDoc: hermesAgentConfigurationDoc, From e84c0e8df2f76f3d843d7bd4824bfbc195ae1142 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 12 Mar 2026 17:23:24 -0700 Subject: [PATCH 023/422] fix: use npm package instead of GitHub URL dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Published hermes-paperclip-adapter@0.1.0 to npm registry - Replaced github:NousResearch/hermes-paperclip-adapter with hermes-paperclip-adapter ^0.1.0 (proper semver, reproducible builds) - Updated imports from @nousresearch/paperclip-adapter-hermes to hermes-paperclip-adapter - Wired in hermesSessionCodec for structured session validation Addresses both review items from greptile-apps: 1. Unpinned GitHub dependency → now a proper npm package with semver 2. Missing sessionCodec → now imported and registered --- server/package.json | 2 +- server/src/adapters/registry.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/package.json b/server/package.json index e3a9b821..dfdb46fb 100644 --- a/server/package.json +++ b/server/package.json @@ -40,7 +40,7 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", - "@nousresearch/paperclip-adapter-hermes": "github:NousResearch/hermes-paperclip-adapter#v0.1.0", + "hermes-paperclip-adapter": "^0.1.0", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f112f788..35d407d3 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -49,11 +49,11 @@ import { execute as hermesExecute, testEnvironment as hermesTestEnvironment, sessionCodec as hermesSessionCodec, -} from "@nousresearch/paperclip-adapter-hermes/server"; +} from "hermes-paperclip-adapter/server"; import { agentConfigurationDoc as hermesAgentConfigurationDoc, models as hermesModels, -} from "@nousresearch/paperclip-adapter-hermes"; +} from "hermes-paperclip-adapter"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; From 3d2abbde7223801edea65c15799c6f060a04012b Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Fri, 13 Mar 2026 00:42:28 +0000 Subject: [PATCH 024/422] fix(openclaw-gateway): catch challengePromise rejection to prevent unhandled rejection process crash Resolves #727 Signed-off-by: Sigmabrogz --- packages/adapters/openclaw-gateway/src/server/execute.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index eaacbd33..f1c85c11 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -605,6 +605,7 @@ class GatewayWsClient { this.resolveChallenge = resolve; this.rejectChallenge = reject; }); + this.challengePromise.catch(() => {}); } async connect( From 284bd733b9b610381c4759b7adde7d21ece34969 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 08:41:01 -0500 Subject: [PATCH 025/422] Add workspace product model plan --- ...orkspace-product-model-and-work-product.md | 959 ++++++++++++++++++ 1 file changed, 959 insertions(+) create mode 100644 doc/plans/workspace-product-model-and-work-product.md diff --git a/doc/plans/workspace-product-model-and-work-product.md b/doc/plans/workspace-product-model-and-work-product.md new file mode 100644 index 00000000..3b201e7d --- /dev/null +++ b/doc/plans/workspace-product-model-and-work-product.md @@ -0,0 +1,959 @@ +# Workspace Product Model, Work Product, and PR Flow + +## Context + +Paperclip needs to support two very different but equally valid ways of working: + +- a solo developer working directly on `master`, or in a folder that is not even a git repo +- a larger engineering workflow with isolated branches, previews, pull requests, and cleanup automation + +Today, Paperclip already has the beginnings of this model: + +- `projects` can carry execution workspace policy +- `project_workspaces` already exist as a durable project-scoped object +- issues can carry execution workspace settings +- runtime services can be attached to a workspace or issue + +What is missing is a clear product model and UI that make these capabilities understandable and operable. + +The main product risk is overloading one concept to do too much: + +- making subissues do the job of branches or PRs +- making projects too infrastructure-heavy +- making workspaces so hidden that users cannot form a mental model +- making Paperclip feel like a code review tool instead of a control plane + +## Goals + +1. Keep `project` lightweight enough to remain a planning container. +2. Make workspace behavior understandable for both git and non-git projects. +3. Support three real workflows without forcing one: + - shared workspace / direct-edit workflows + - isolated issue workspace workflows + - long-lived branch or operator integration workflows +4. Provide a first-class place to see the outputs of work: + - previews + - PRs + - branches + - commits + - documents and artifacts +5. Keep the main navigation and task board simple. + +## Non-Goals + +- Turning Paperclip into a full code review product +- Requiring every issue to have its own branch or PR +- Requiring every project to configure code/workspace automation +- Making workspaces a top-level global navigation primitive in V1 + +## Core Product Decisions + +### 1. Project stays the planning object + +A `project` remains the thing that groups work around a deliverable or initiative. + +It may have: + +- no code at all +- one default codebase/workspace +- several codebases/workspaces + +Projects are not required to become heavyweight. + +### 2. Project workspace is a first-class object, but scoped under project + +A `project workspace` is the durable codebase or root environment for a project. + +Examples: + +- a local folder on disk +- a git repo checkout +- a monorepo package root +- a non-git design/doc folder +- a remote adapter-managed codebase reference + +This is the stable anchor that operators configure once. + +It should not be a top-level sidebar item in the main app. It should live under the project experience. + +### 3. Execution workspace is a first-class runtime object + +An `execution workspace` is where a specific run or issue actually executes. + +Examples: + +- the shared project workspace itself +- an isolated git worktree +- a long-lived operator branch checkout +- an adapter-managed remote sandbox + +This object must be recorded explicitly so that Paperclip can: + +- show where work happened +- attach previews and runtime services +- link PRs and branches +- decide cleanup behavior +- support reuse across multiple related issues + +### 4. PRs are work product, not the core issue model + +A PR is an output of work, not the planning unit. + +Paperclip should treat PRs as a type of work product linked back to: + +- the issue +- the execution workspace +- optionally the project workspace + +Git-specific automation should live under workspace policy, not under the core issue abstraction. + +### 5. Subissues remain planning and ownership structure + +Subissues are for decomposition and parallel ownership. + +They are not the same thing as: + +- a branch +- a worktree +- a PR +- a preview + +They may correlate with those things, but they should not be overloaded to mean them. + +## Terminology + +Use these terms consistently in product copy: + +- `Project`: planning container +- `Project workspace`: durable configured codebase/root +- `Execution workspace`: actual runtime workspace used for issue execution +- `Isolated issue workspace`: user-facing term for an issue-specific derived workspace +- `Work product`: previews, PRs, branches, commits, artifacts, docs +- `Runtime service`: a process or service Paperclip owns or tracks for a workspace + +Avoid teaching users that "workspace" always means "git worktree on my machine". + +## Product Object Model + +## 1. Project + +Existing object. No fundamental change in role. + +### Required behavior + +- can exist without code/workspace configuration +- can have zero or more project workspaces +- can define execution defaults that new issues inherit + +### Proposed fields + +- `id` +- `companyId` +- `name` +- `description` +- `status` +- `goalIds` +- `leadAgentId` +- `targetDate` +- `executionWorkspacePolicy` +- `workspaces[]` +- `primaryWorkspace` + +## 2. Project Workspace + +Durable, configured, project-scoped codebase/root object. + +This should evolve from the current `project_workspaces` table into a more explicit product object. + +### Motivation + +This separates: + +- "what codebase/root does this project use?" + +from: + +- "what temporary execution environment did this issue run in?" + +That keeps the model simple for solo users while still supporting advanced automation. + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `name` +- `sourceType` + - `local_path` + - `git_repo` + - `remote_managed` + - `non_git_path` +- `cwd` +- `repoUrl` +- `defaultRef` +- `isPrimary` +- `visibility` + - `default` + - `advanced` +- `setupCommand` +- `cleanupCommand` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceType=non_git_path` is important so non-git projects are first-class. +- `setupCommand` and `cleanupCommand` should be allowed here for workspace-root bootstrap, even when isolated execution is not used. +- For a monorepo, multiple project workspaces may point at different roots or packages under one repo. + +## 3. Project Execution Workspace Policy + +Project-level defaults for how issues execute. + +This is the main operator-facing configuration surface. + +### Motivation + +This lets Paperclip support: + +- direct editing in a shared workspace +- isolated workspaces for issue parallelism +- long-lived integration branch workflows + +without forcing every issue or agent to expose low-level runtime configuration. + +### Proposed fields + +- `enabled: boolean` +- `defaultMode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_default` +- `allowIssueOverride: boolean` +- `defaultProjectWorkspaceId: uuid | null` +- `workspaceStrategy` + - `type` + - `project_primary` + - `git_worktree` + - `adapter_managed` + - `baseRef` + - `branchTemplate` + - `worktreeParentDir` + - `provisionCommand` + - `teardownCommand` +- `branchPolicy` + - `namingTemplate` + - `allowReuseExisting` + - `preferredOperatorBranch` +- `pullRequestPolicy` + - `mode` + - `disabled` + - `manual` + - `agent_may_open_draft` + - `approval_required_to_open` + - `approval_required_to_mark_ready` + - `baseBranch` + - `titleTemplate` + - `bodyTemplate` +- `runtimePolicy` + - `allowWorkspaceServices` + - `defaultServicesProfile` + - `autoHarvestOwnedUrls` +- `cleanupPolicy` + - `mode` + - `manual` + - `when_issue_terminal` + - `when_pr_closed` + - `retention_window` + - `retentionHours` + - `keepWhilePreviewHealthy` + - `keepWhileOpenPrExists` + +## 4. Issue Workspace Binding + +Issue-level selection of execution behavior. + +This should remain lightweight in the normal case and only surface richer controls when relevant. + +### Motivation + +Not every issue in a code project should create a new derived workspace. + +Examples: + +- a tiny fix can run in the shared workspace +- three related issues may intentionally share one integration branch +- a solo operator may be working directly on `master` + +### Proposed fields on `issues` + +- `projectWorkspaceId: uuid | null` +- `executionWorkspacePreference` + - `inherit` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `reuse_existing` +- `preferredExecutionWorkspaceId: uuid | null` +- `executionWorkspaceSettings` + - keep advanced per-issue override fields here + +### Rules + +- if the project has no workspace automation, these fields may all be null +- if the project has one primary workspace, issue creation should default to it silently +- `reuse_existing` is advanced-only and should target active execution workspaces, not the whole workspace universe + +## 5. Execution Workspace + +A durable record for a shared or derived runtime workspace. + +This is the missing object that makes cleanup, previews, PRs, and branch reuse tractable. + +### Motivation + +Without an explicit `execution workspace` record, Paperclip has nowhere stable to attach: + +- derived branch/worktree identity +- active preview ownership +- PR linkage +- cleanup state +- "reuse this existing integration branch" behavior + +### Proposed new object + +`execution_workspaces` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `projectWorkspaceId` +- `sourceIssueId` +- `mode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_managed` +- `strategyType` + - `project_primary` + - `git_worktree` + - `adapter_managed` +- `name` +- `status` + - `active` + - `idle` + - `in_review` + - `archived` + - `cleanup_failed` +- `cwd` +- `repoUrl` +- `baseRef` +- `branchName` +- `providerRef` +- `derivedFromExecutionWorkspaceId` +- `lastUsedAt` +- `openedAt` +- `closedAt` +- `cleanupEligibleAt` +- `cleanupReason` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceIssueId` is the issue that originally caused the workspace to be created, not necessarily the only issue linked to it later. +- multiple issues may link to the same execution workspace in a long-lived branch workflow. + +## 6. Issue-to-Execution Workspace Link + +An issue may need to link to one or more execution workspaces over time. + +Examples: + +- an issue begins in a shared workspace and later moves to an isolated one +- a failed attempt is archived and a new workspace is created +- several issues intentionally share one operator branch workspace + +### Proposed object + +`issue_execution_workspaces` + +### Proposed fields + +- `issueId` +- `executionWorkspaceId` +- `relationType` + - `current` + - `historical` + - `preferred` +- `createdAt` +- `updatedAt` + +### UI simplification + +Most issues should only show one current workspace in the main UI. Historical links belong in advanced/history views. + +## 7. Work Product + +User-facing umbrella concept for outputs of work. + +### Motivation + +Paperclip needs a single place to show: + +- "here is the preview" +- "here is the PR" +- "here is the branch" +- "here is the commit" +- "here is the artifact/report/doc" + +without turning issues into a raw dump of adapter details. + +### Proposed new object + +`issue_work_products` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `issueId` +- `executionWorkspaceId` +- `runtimeServiceId` +- `type` + - `preview_url` + - `runtime_service` + - `pull_request` + - `branch` + - `commit` + - `artifact` + - `document` +- `provider` + - `paperclip` + - `github` + - `gitlab` + - `vercel` + - `netlify` + - `custom` +- `externalId` +- `title` +- `url` +- `status` + - `active` + - `ready_for_review` + - `merged` + - `closed` + - `failed` + - `archived` +- `reviewState` + - `none` + - `needs_board_review` + - `approved` + - `changes_requested` +- `isPrimary` +- `healthStatus` + - `unknown` + - `healthy` + - `unhealthy` +- `summary` +- `metadata` +- `createdByRunId` +- `createdAt` +- `updatedAt` + +### Behavior + +- PRs are stored here as `type=pull_request` +- previews are stored here as `type=preview_url` or `runtime_service` +- Paperclip-owned processes should update health/status automatically +- external providers should at least store link, provider, external id, and latest known state + +## Page and UI Model + +## 1. Global Navigation + +Do not add `Workspaces` as a top-level sidebar item in V1. + +### Motivation + +That would make the whole product feel infra-heavy, even for companies that do not use code automation. + +### Global nav remains + +- Dashboard +- Inbox +- Companies +- Agents +- Goals +- Projects +- Issues +- Approvals + +Workspaces and work product should be surfaced through project and issue detail views. + +## 2. Project Detail + +Add a project sub-navigation that keeps planning first and code second. + +### Tabs + +- `Overview` +- `Issues` +- `Code` +- `Activity` + +Optional future: + +- `Outputs` + +### `Overview` tab + +Planning-first summary: + +- project status +- goals +- lead +- issue counts +- top-level progress +- latest major work product summaries + +### `Issues` tab + +- default to top-level issues only +- show parent issue rollups: + - child count + - `x/y` done + - active preview/PR badges +- optional toggle: `Show subissues` + +### `Code` tab + +This is the main workspace configuration and visibility surface. + +#### Section: `Project Workspaces` + +List durable project workspaces for the project. + +Card/list columns: + +- workspace name +- source type +- path or repo +- default ref +- primary/default badge +- active execution workspaces count +- active issue count +- active preview count + +Actions: + +- `Add workspace` +- `Edit` +- `Set default` +- `Archive` + +#### Section: `Execution Defaults` + +Fields: + +- `Enable workspace automation` +- `Default issue execution mode` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + - `Adapter default` +- `Default codebase` +- `Allow issue override` + +#### Section: `Provisioning` + +Fields: + +- `Setup command` +- `Cleanup command` +- `Implementation` + - `Shared workspace` + - `Git worktree` + - `Adapter-managed` +- `Base ref` +- `Branch naming template` +- `Derived workspace parent directory` + +Hide git-specific fields when the selected workspace is not git-backed. + +#### Section: `Pull Requests` + +Fields: + +- `PR workflow` + - `Disabled` + - `Manual` + - `Agent may open draft PR` + - `Approval required to open PR` + - `Approval required to mark ready` +- `Default base branch` +- `PR title template` +- `PR body template` + +#### Section: `Previews and Runtime` + +Fields: + +- `Allow workspace runtime services` +- `Default services profile` +- `Harvest owned preview URLs` +- `Track external preview URLs` + +#### Section: `Cleanup` + +Fields: + +- `Cleanup mode` + - `Manual` + - `When issue is terminal` + - `When PR closes` + - `After retention window` +- `Retention window` +- `Keep while preview is active` +- `Keep while PR is open` + +## 3. Add Project Workspace Flow + +Entry point: `Project > Code > Add workspace` + +### Form fields + +- `Name` +- `Source type` + - `Local folder` + - `Git repo` + - `Non-git folder` + - `Remote managed` +- `Local path` +- `Repository URL` +- `Default ref` +- `Set as default workspace` +- `Setup command` +- `Cleanup command` + +### Behavior + +- if source type is non-git, hide branch/PR-specific setup +- if source type is git, show ref and optional advanced branch fields +- for simple solo users, this can be one path field and one save button + +## 4. Issue Create Flow + +Issue creation should stay simple by default. + +### Default behavior + +If the selected project: + +- has no workspace automation: show no workspace UI +- has one default project workspace and default execution mode: inherit silently + +### Show a `Workspace` section only when relevant + +#### Basic fields + +- `Codebase` + - default selected project workspace +- `Execution mode` + - `Project default` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + +#### Advanced-only field + +- `Reuse existing execution workspace` + +This dropdown should show only active execution workspaces for the selected project workspace, with labels like: + +- `dotta/integration-branch` +- `PAP-447-add-worktree-support` +- `shared primary workspace` + +### Important rule + +Do not show a picker containing every possible workspace object by default. + +The normal flow should feel like: + +- choose project +- optionally choose codebase +- optionally choose execution mode + +not: + +- choose from a long mixed list of roots, derived worktrees, previews, and branch names + +## 5. Issue Detail + +Issue detail should expose workspace and work product clearly, but without becoming a code host UI. + +### Header chips + +Show compact summary chips near the title/status area: + +- `Codebase: Web App` +- `Workspace: Shared` +- `Workspace: PAP-447-add-worktree-support` +- `PR: Open` +- `Preview: Healthy` + +### Tabs + +- `Comments` +- `Subissues` +- `Work Product` +- `Activity` + +### `Work Product` tab + +Sections: + +- `Current workspace` +- `Previews` +- `Pull requests` +- `Branches and commits` +- `Artifacts and documents` + +#### Current workspace panel + +Fields: + +- workspace name +- mode +- branch +- base ref +- last used +- linked issues count +- cleanup status + +Actions: + +- `Open workspace details` +- `Mark in review` +- `Request cleanup` + +#### Pull request cards + +Fields: + +- title +- provider +- status +- review state +- linked branch +- open/ready/merged timestamps + +Actions: + +- `Open PR` +- `Refresh status` +- `Request board review` + +#### Preview cards + +Fields: + +- title +- URL +- provider +- health +- ownership +- updated at + +Actions: + +- `Open preview` +- `Refresh` +- `Archive` + +## 6. Execution Workspace Detail + +This can be reached from a project code tab or an issue work product tab. + +It does not need to be in the main sidebar. + +### Sections + +- identity +- source issue +- linked issues +- branch/ref +- active runtime services +- previews +- PRs +- cleanup state +- event/activity history + +### Motivation + +This is where advanced users go when they need to inspect the mechanics. Most users should not need it in normal flow. + +## 7. Inbox Behavior + +Inbox should surface actionable work product events, not every implementation detail. + +### Show inbox items for + +- issue assigned or updated +- PR needs board review +- PR opened or marked ready +- preview unhealthy +- workspace cleanup failed +- runtime service failed + +### Do not show by default + +- every workspace heartbeat +- every branch update +- every derived workspace creation + +### Display style + +If the inbox item is about a preview or PR, show issue context with it: + +- issue identifier and title +- parent issue if this is a subissue +- workspace name if relevant + +## 8. Issues List and Kanban + +Keep list and board planning-first. + +### Default behavior + +- show top-level issues by default +- show parent rollups for subissues +- do not flatten every child execution detail into the main board + +### Row/card adornments + +For issues with linked work product, show compact badges: + +- `1 PR` +- `2 previews` +- `shared workspace` +- `isolated workspace` + +### Optional advanced filters + +- `Has PR` +- `Has preview` +- `Workspace mode` +- `Codebase` + +## Behavior Rules + +## 1. Cleanup must not depend on agents remembering `in_review` + +Agents may still use `in_review`, but cleanup behavior must be governed by policy and observed state. + +### Keep an execution workspace alive while any of these are true + +- a linked issue is non-terminal +- a linked PR is open +- a linked preview/runtime service is active +- the workspace is still within retention window + +### Hide instead of deleting aggressively + +Archived or idle workspaces should be hidden from default lists before they are hard-cleaned up. + +## 2. Multiple issues may intentionally share one execution workspace + +This is how Paperclip supports: + +- solo dev on a shared branch +- operator integration branches +- related features batched into one PR + +This is the key reason not to force 1 issue = 1 workspace = 1 PR. + +## 3. Isolated issue workspaces remain opt-in + +Even in a git-heavy project, isolated workspaces should be optional. + +Examples where shared mode is valid: + +- tiny bug fixes +- branchless prototyping +- non-git projects +- single-user local workflows + +## 4. PR policy belongs to git-backed workspace policy + +PR automation decisions should be made at the project/workspace policy layer. + +The issue should only: + +- surface the resulting PR +- route approvals/review requests +- show status and review state + +## 5. Work product is the user-facing unifier + +Previews, PRs, commits, and artifacts should all be discoverable through one consistent issue-level affordance. + +That keeps Paperclip focused on coordination and visibility instead of splitting outputs across many hidden subsystems. + +## Recommended Implementation Order + +## Phase 1: Clarify current objects in UI + +1. Surface `Project > Code` tab +2. Show existing project workspaces there +3. Re-enable project-level execution workspace policy with revised copy +4. Keep issue creation simple with inherited defaults + +## Phase 2: Add explicit execution workspace record + +1. Add `execution_workspaces` +2. Link runs, issues, previews, and PRs to it +3. Add simple execution workspace detail page + +## Phase 3: Add work product model + +1. Add `issue_work_products` +2. Ingest PRs, previews, branches, commits +3. Add issue `Work Product` tab +4. Add inbox items for actionable work product state changes + +## Phase 4: Add advanced reuse and cleanup workflows + +1. Add `reuse existing execution workspace` +2. Add cleanup lifecycle UI +3. Add operator branch workflow shortcuts +4. Add richer external preview harvesting + +## Why This Model Is Right + +This model keeps the product balanced: + +- simple enough for solo users +- strong enough for real engineering teams +- flexible for non-git projects +- explicit enough to govern PRs and previews + +Most importantly, it keeps the abstractions clean: + +- projects plan the work +- project workspaces define the durable codebases +- execution workspaces define where work ran +- work product defines what came out of the work +- PRs remain outputs, not the core task model + +That is a better fit for Paperclip than either extreme: + +- hiding workspace behavior until nobody understands it +- or making the whole app revolve around code-host mechanics From 752a53e38e25e41b52d569ae295831e12287b1ea Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 09:06:49 -0500 Subject: [PATCH 026/422] Expand workspace plan for migration and cloud execution --- ...orkspace-product-model-and-work-product.md | 169 +++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/doc/plans/workspace-product-model-and-work-product.md b/doc/plans/workspace-product-model-and-work-product.md index 3b201e7d..ae5b8e79 100644 --- a/doc/plans/workspace-product-model-and-work-product.md +++ b/doc/plans/workspace-product-model-and-work-product.md @@ -38,6 +38,8 @@ The main product risk is overloading one concept to do too much: - commits - documents and artifacts 5. Keep the main navigation and task board simple. +6. Seamlessly upgrade existing Paperclip users to the new model without forcing disruptive reconfiguration. +7. Support cloud-hosted Paperclip deployments where execution happens in remote or adapter-managed environments rather than local workers. ## Non-Goals @@ -45,6 +47,7 @@ The main product risk is overloading one concept to do too much: - Requiring every issue to have its own branch or PR - Requiring every project to configure code/workspace automation - Making workspaces a top-level global navigation primitive in V1 +- Requiring a local filesystem path or local git checkout to use workspace-aware execution ## Core Product Decisions @@ -86,6 +89,7 @@ Examples: - an isolated git worktree - a long-lived operator branch checkout - an adapter-managed remote sandbox +- a cloud agent provider's isolated branch/session environment This object must be recorded explicitly so that Paperclip can: @@ -107,7 +111,38 @@ Paperclip should treat PRs as a type of work product linked back to: Git-specific automation should live under workspace policy, not under the core issue abstraction. -### 5. Subissues remain planning and ownership structure +### 5. Existing users must upgrade automatically + +Paperclip already has users and existing project/task data. Any new model must preserve continuity. + +The product should default existing installs into a sensible compatibility mode: + +- existing projects without workspace configuration continue to work unchanged +- existing `project_workspaces` become the durable `project workspace` objects +- existing project execution workspace policy is mapped forward rather than discarded +- issues without explicit workspace fields continue to inherit current behavior + +This migration should feel additive, not like a mandatory re-onboarding flow. + +### 6. Cloud-hosted Paperclip must be a first-class deployment mode + +Paperclip cannot assume that it is running on the same machine as the code. + +In cloud deployments, Paperclip may: + +- run on Vercel or another serverless host +- have no long-lived local worker process +- delegate execution to a remote coding agent or provider-managed sandbox +- receive back a branch, PR, preview URL, or artifact from that remote environment + +The model therefore must be portable: + +- `project workspace` may be remote-managed, not local +- `execution workspace` may have no local `cwd` +- `runtime services` may be tracked by provider reference and URL rather than a host process +- work product harvesting must handle externally owned previews and PRs + +### 7. Subissues remain planning and ownership structure Subissues are for decomposition and parallel ownership. @@ -131,6 +166,11 @@ Use these terms consistently in product copy: - `Work product`: previews, PRs, branches, commits, artifacts, docs - `Runtime service`: a process or service Paperclip owns or tracks for a workspace +Use these terms consistently in migration and deployment messaging: + +- `Compatible mode`: existing behavior preserved without new workspace automation +- `Adapter-managed workspace`: workspace realized by a remote or cloud execution provider + Avoid teaching users that "workspace" always means "git worktree on my machine". ## Product Object Model @@ -176,6 +216,7 @@ from: - "what temporary execution environment did this issue run in?" That keeps the model simple for solo users while still supporting advanced automation. +It also lets cloud-hosted Paperclip deployments point at codebases and remotes without pretending the Paperclip host has direct filesystem access. ### Proposed fields @@ -206,6 +247,7 @@ That keeps the model simple for solo users while still supporting advanced autom - `sourceType=non_git_path` is important so non-git projects are first-class. - `setupCommand` and `cleanupCommand` should be allowed here for workspace-root bootstrap, even when isolated execution is not used. - For a monorepo, multiple project workspaces may point at different roots or packages under one repo. +- `sourceType=remote_managed` is important for cloud deployments where the durable codebase is defined by provider/repo metadata rather than a local checkout path. ## 3. Project Execution Workspace Policy @@ -220,6 +262,7 @@ This lets Paperclip support: - direct editing in a shared workspace - isolated workspaces for issue parallelism - long-lived integration branch workflows +- remote cloud-agent execution that returns a branch or PR without forcing every issue or agent to expose low-level runtime configuration. @@ -305,6 +348,7 @@ Examples: - if the project has no workspace automation, these fields may all be null - if the project has one primary workspace, issue creation should default to it silently - `reuse_existing` is advanced-only and should target active execution workspaces, not the whole workspace universe +- existing issues without these fields should behave as `inherit` during migration ## 5. Execution Workspace @@ -321,6 +365,7 @@ Without an explicit `execution workspace` record, Paperclip has nowhere stable t - PR linkage - cleanup state - "reuse this existing integration branch" behavior +- remote provider session identity ### Proposed new object @@ -354,6 +399,11 @@ Without an explicit `execution workspace` record, Paperclip has nowhere stable t - `baseRef` - `branchName` - `providerRef` +- `providerType` + - `local_fs` + - `git_worktree` + - `adapter_managed` + - `cloud_sandbox` - `derivedFromExecutionWorkspaceId` - `lastUsedAt` - `openedAt` @@ -368,6 +418,7 @@ Without an explicit `execution workspace` record, Paperclip has nowhere stable t - `sourceIssueId` is the issue that originally caused the workspace to be created, not necessarily the only issue linked to it later. - multiple issues may link to the same execution workspace in a long-lived branch workflow. +- `cwd` may be null for remote execution workspaces; provider identity and work product links still make the object useful. ## 6. Issue-to-Execution Workspace Link @@ -473,6 +524,7 @@ without turning issues into a raw dump of adapter details. - previews are stored here as `type=preview_url` or `runtime_service` - Paperclip-owned processes should update health/status automatically - external providers should at least store link, provider, external id, and latest known state +- cloud agents should be able to create work product records without Paperclip owning the execution host ## Page and UI Model @@ -550,6 +602,7 @@ Card/list columns: - active execution workspaces count - active issue count - active preview count +- hosting type / provider when remote-managed Actions: @@ -586,6 +639,7 @@ Fields: - `Derived workspace parent directory` Hide git-specific fields when the selected workspace is not git-backed. +Hide local-path-specific fields when the selected workspace is remote-managed. #### Section: `Pull Requests` @@ -637,6 +691,8 @@ Entry point: `Project > Code > Add workspace` - `Remote managed` - `Local path` - `Repository URL` +- `Remote provider` +- `Remote workspace reference` - `Default ref` - `Set as default workspace` - `Setup command` @@ -646,6 +702,7 @@ Entry point: `Project > Code > Add workspace` - if source type is non-git, hide branch/PR-specific setup - if source type is git, show ref and optional advanced branch fields +- if source type is remote-managed, show provider/reference fields and hide local-path-only configuration - for simple solo users, this can be one path field and one save button ## 4. Issue Create Flow @@ -695,6 +752,10 @@ not: - choose from a long mixed list of roots, derived worktrees, previews, and branch names +### Migration rule + +For existing users, issue creation should continue to look the same until a project explicitly enables richer workspace behavior. + ## 5. Issue Detail Issue detail should expose workspace and work product clearly, but without becoming a code host UI. @@ -790,6 +851,7 @@ It does not need to be in the main sidebar. - source issue - linked issues - branch/ref +- provider/session identity - active runtime services - previews - PRs @@ -812,6 +874,7 @@ Inbox should surface actionable work product events, not every implementation de - preview unhealthy - workspace cleanup failed - runtime service failed +- remote cloud-agent run returned PR or preview that needs review ### Do not show by default @@ -853,6 +916,101 @@ For issues with linked work product, show compact badges: - `Workspace mode` - `Codebase` +## Upgrade and Migration Plan + +## 1. Product-level migration stance + +Migration must be silent-by-default and compatibility-preserving. + +Existing users should not be forced to: + +- create new workspace objects by hand before they can keep working +- re-tag old issues +- learn new workspace concepts before basic issue flows continue to function + +## 2. Existing project migration + +On upgrade: + +- existing `project_workspaces` records are retained and shown as `Project Workspaces` +- the current primary workspace remains the default codebase +- existing project execution workspace policy is mapped into the new `Project Execution Workspace Policy` surface +- projects with no execution workspace policy stay in compatible/shared mode + +## 3. Existing issue migration + +On upgrade: + +- existing issues default to `executionWorkspacePreference=inherit` +- if an issue already has execution workspace settings, map them forward directly +- if an issue has no explicit workspace data, preserve existing behavior and do not force a user-visible choice + +## 4. Existing run/runtime migration + +On upgrade: + +- active or recent runtime services can be backfilled into execution workspace history where feasible +- missing history should not block rollout; forward correctness matters more than perfect historical reconstruction + +## 5. Rollout UX + +Use additive language in the UI: + +- `Code` +- `Workspace automation` +- `Optional` +- `Advanced` + +Avoid migration copy that implies users were previously using the product "wrong". + +## Cloud Deployment Requirements + +## 1. Paperclip host and execution host must be decoupled + +Paperclip may run: + +- locally with direct filesystem access +- in a cloud app host such as Vercel +- in a hybrid setup with external job runners + +The workspace model must work in all three. + +## 2. Remote execution must support first-class work product reporting + +A cloud agent should be able to: + +- resolve a project workspace +- realize an adapter-managed execution workspace remotely +- produce a branch +- open or update a PR +- emit preview URLs +- register artifacts + +without the Paperclip host itself running local git or local preview processes. + +## 3. Local-only assumptions must be optional + +The following must be optional, not required: + +- local `cwd` +- local git CLI +- host-managed worktree directories +- host-owned long-lived preview processes + +## 4. Same product surface, different provider behavior + +The UI should not split into "local mode" and "cloud mode" products. + +Instead: + +- local projects show path/git implementation details +- cloud projects show provider/reference details +- both surface the same high-level objects: + - project workspace + - execution workspace + - work product + - runtime service or preview + ## Behavior Rules ## 1. Cleanup must not depend on agents remembering `in_review` @@ -921,6 +1079,7 @@ That keeps Paperclip focused on coordination and visibility instead of splitting 1. Add `execution_workspaces` 2. Link runs, issues, previews, and PRs to it 3. Add simple execution workspace detail page +4. Make `cwd` optional and ensure provider-managed remote workspaces are supported from day one ## Phase 3: Add work product model @@ -928,6 +1087,7 @@ That keeps Paperclip focused on coordination and visibility instead of splitting 2. Ingest PRs, previews, branches, commits 3. Add issue `Work Product` tab 4. Add inbox items for actionable work product state changes +5. Support remote agent-created PR/preview reporting without local ownership ## Phase 4: Add advanced reuse and cleanup workflows @@ -935,6 +1095,7 @@ That keeps Paperclip focused on coordination and visibility instead of splitting 2. Add cleanup lifecycle UI 3. Add operator branch workflow shortcuts 4. Add richer external preview harvesting +5. Add migration tooling/backfill where it improves continuity for existing users ## Why This Model Is Right @@ -953,6 +1114,12 @@ Most importantly, it keeps the abstractions clean: - work product defines what came out of the work - PRs remain outputs, not the core task model +It also keeps the rollout practical: + +- existing users can upgrade without workflow breakage +- local-first installs stay simple +- cloud-hosted Paperclip deployments remain first-class + That is a better fit for Paperclip than either extreme: - hiding workspace behavior until nobody understands it From 25d3bf2c64eaad86f537379c9af2becf9832c42e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 09:41:12 -0500 Subject: [PATCH 027/422] Incorporate Worktrunk patterns into workspace plan --- ...orkspace-product-model-and-work-product.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/doc/plans/workspace-product-model-and-work-product.md b/doc/plans/workspace-product-model-and-work-product.md index ae5b8e79..25c8c464 100644 --- a/doc/plans/workspace-product-model-and-work-product.md +++ b/doc/plans/workspace-product-model-and-work-product.md @@ -1011,6 +1011,143 @@ Instead: - work product - runtime service or preview +## Patterns Learned from Worktrunk + +Worktrunk is a useful reference point because it is unapologetically focused on git-worktree-based developer workflows. + +Paperclip should not copy its product framing wholesale, but there are several good patterns worth applying. + +References: + +- `https://worktrunk.dev/tips-patterns/` +- `https://github.com/max-sixty/worktrunk` + +## 1. Deterministic per-workspace resources + +Worktrunk treats a derived workspace as something that can deterministically own: + +- ports +- local URLs +- databases +- runtime process identity + +This is a strong pattern for Paperclip. + +### Recommendation + +Execution workspaces should be able to deterministically derive and expose: + +- preview URLs +- port allocations +- database/schema names +- runtime service reuse keys + +This makes previews and local runtime services more predictable and easier to manage across many parallel workspaces. + +## 2. Lifecycle hooks should stay simple and explicit + +Worktrunk uses practical lifecycle hooks such as create/start/remove/merge-oriented commands. + +The main lesson is not to build a huge workflow engine. The lesson is to give users a few well-defined lifecycle moments to attach automation to. + +### Recommendation + +Paperclip should keep workspace automation centered on a small set of hooks: + +- `setup` +- `cleanup` +- optionally `before_review` +- optionally `after_merge` or `after_close` + +These should remain project/workspace policy concerns, not agent-prompt conventions. + +## 3. Workspace status visibility is a real product feature + +Worktrunk's listing/status experience is doing important product work: + +- which workspaces exist +- what branch they are on +- what services or URLs they own +- whether they are active or stale + +### Recommendation + +Paperclip should provide the equivalent visibility in the project `Code` surface: + +- active execution workspaces +- linked issues +- linked PRs +- linked previews/runtime services +- cleanup eligibility + +This reinforces why `execution workspace` needs to be a first-class recorded object. + +## 4. Execution workspaces are runtime islands, not just checkouts + +One of Worktrunk's strongest implicit ideas is that a worktree is not only code. It often owns an entire local runtime environment. + +### Recommendation + +Paperclip should treat execution workspaces as the natural home for: + +- dev servers +- preview processes +- sandbox credentials or provider references +- branch/ref identity +- local or remote environment bootstrap + +This supports the `work product` model and the preview/runtime service model proposed above. + +## 5. Machine-readable workspace state matters + +Worktrunk exposes structured state that can be consumed by tools and automation. + +### Recommendation + +Paperclip should ensure that execution workspaces and work product have clean structured API surfaces, not just UI-only representation. + +That is important for: + +- agents +- CLIs +- dashboards +- future automation and cleanup tooling + +## 6. Cleanup should be first-class, not an afterthought + +Worktrunk makes create/remove/merge cleanup part of the workflow. + +### Recommendation + +Paperclip should continue treating cleanup policy as part of the core workspace model: + +- when is cleanup allowed +- what blocks cleanup +- what gets archived versus destroyed +- what happens when cleanup fails + +This validates the explicit cleanup policy proposed earlier in this plan. + +## 7. What not to copy + +There are also important limits to the analogy. + +Paperclip should not adopt these Worktrunk assumptions as universal product rules: + +- every execution workspace is a local git worktree +- the Paperclip host has direct shell and filesystem access +- every workflow is merge-centric +- every user wants developer-tool-level workspace detail in the main navigation + +### Product implication + +Paperclip should borrow Worktrunk's good execution patterns while keeping the broader Paperclip model: + +- project plans the work +- workspace defines where work happens +- work product defines what came out +- git worktree remains one implementation strategy, not the product itself + ## Behavior Rules ## 1. Cleanup must not depend on agents remembering `in_review` From 80cdbdbd47ceb6fcb7f2ea1d784063cc9e51698d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 16:22:34 -0500 Subject: [PATCH 028/422] Add plugin framework and settings UI --- doc/plugins/PLUGIN_SPEC.md | 24 + .../db/src/migrations/0028_plugin_tables.sql | 177 ++ packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/index.ts | 8 + .../db/src/schema/plugin_company_settings.ts | 41 + packages/db/src/schema/plugin_config.ts | 30 + packages/db/src/schema/plugin_entities.ts | 54 + packages/db/src/schema/plugin_jobs.ts | 102 + packages/db/src/schema/plugin_logs.ts | 43 + packages/db/src/schema/plugin_state.ts | 90 + packages/db/src/schema/plugin_webhooks.ts | 65 + packages/db/src/schema/plugins.ts | 45 + .../plugins/create-paperclip-plugin/README.md | 38 + .../create-paperclip-plugin/package.json | 40 + .../create-paperclip-plugin/src/index.ts | 398 +++ .../create-paperclip-plugin/tsconfig.json | 9 + .../plugin-file-browser-example/README.md | 62 + .../plugin-file-browser-example/package.json | 42 + .../scripts/build-ui.mjs | 24 + .../plugin-file-browser-example/src/index.ts | 2 + .../src/manifest.ts | 85 + .../src/ui/index.tsx | 815 ++++++ .../plugin-file-browser-example/src/worker.ts | 226 ++ .../plugin-file-browser-example/tsconfig.json | 10 + .../plugin-hello-world-example/README.md | 38 + .../plugin-hello-world-example/package.json | 35 + .../plugin-hello-world-example/src/index.ts | 2 + .../src/manifest.ts | 39 + .../src/ui/index.tsx | 17 + .../plugin-hello-world-example/src/worker.ts | 27 + .../plugin-hello-world-example/tsconfig.json | 10 + packages/plugins/sdk/README.md | 959 +++++++ packages/plugins/sdk/package.json | 124 + packages/plugins/sdk/src/bundlers.ts | 161 ++ packages/plugins/sdk/src/define-plugin.ts | 255 ++ packages/plugins/sdk/src/dev-cli.ts | 54 + packages/plugins/sdk/src/dev-server.ts | 228 ++ .../plugins/sdk/src/host-client-factory.ts | 563 ++++ packages/plugins/sdk/src/index.ts | 287 ++ packages/plugins/sdk/src/protocol.ts | 1038 +++++++ packages/plugins/sdk/src/testing.ts | 720 +++++ packages/plugins/sdk/src/types.ts | 1116 ++++++++ packages/plugins/sdk/src/ui/components.ts | 310 +++ packages/plugins/sdk/src/ui/hooks.ts | 153 ++ packages/plugins/sdk/src/ui/index.ts | 125 + packages/plugins/sdk/src/ui/runtime.ts | 51 + packages/plugins/sdk/src/ui/types.ts | 358 +++ packages/plugins/sdk/src/worker-rpc-host.ts | 1221 +++++++++ packages/plugins/sdk/tsconfig.json | 9 + packages/shared/src/constants.ts | 312 ++- packages/shared/src/index.ts | 94 + packages/shared/src/types/index.ts | 24 + packages/shared/src/types/plugin.ts | 545 ++++ packages/shared/src/validators/index.ts | 42 + packages/shared/src/validators/plugin.ts | 694 +++++ pnpm-workspace.yaml | 2 + scripts/ensure-plugin-build-deps.mjs | 46 + server/package.json | 5 +- .../__tests__/plugin-worker-manager.test.ts | 43 + server/src/app.ts | 125 +- server/src/routes/plugin-ui-static.ts | 496 ++++ server/src/routes/plugins.ts | 2417 +++++++++++++++++ server/src/services/cron.ts | 373 +++ server/src/services/live-events.ts | 14 + .../services/plugin-capability-validator.ts | 451 +++ .../src/services/plugin-config-validator.ts | 50 + server/src/services/plugin-dev-watcher.ts | 189 ++ server/src/services/plugin-event-bus.ts | 515 ++++ .../services/plugin-host-service-cleanup.ts | 59 + server/src/services/plugin-host-services.ts | 1077 ++++++++ server/src/services/plugin-job-coordinator.ts | 260 ++ server/src/services/plugin-job-scheduler.ts | 752 +++++ server/src/services/plugin-job-store.ts | 465 ++++ server/src/services/plugin-lifecycle.ts | 807 ++++++ server/src/services/plugin-loader.ts | 1852 +++++++++++++ server/src/services/plugin-log-retention.ts | 86 + .../src/services/plugin-manifest-validator.ts | 163 ++ server/src/services/plugin-registry.ts | 963 +++++++ server/src/services/plugin-runtime-sandbox.ts | 221 ++ server/src/services/plugin-secrets-handler.ts | 367 +++ server/src/services/plugin-state-store.ts | 237 ++ server/src/services/plugin-stream-bus.ts | 81 + server/src/services/plugin-tool-dispatcher.ts | 448 +++ server/src/services/plugin-tool-registry.ts | 449 +++ server/src/services/plugin-worker-manager.ts | 1342 +++++++++ ui/src/App.tsx | 13 +- ui/src/api/client.ts | 2 + ui/src/api/plugins.ts | 469 ++++ ui/src/components/InstanceSidebar.tsx | 5 +- ui/src/components/JsonSchemaForm.tsx | 1048 +++++++ ui/src/components/Layout.tsx | 54 +- ui/src/components/SidebarProjects.tsx | 75 +- ui/src/lib/queryKeys.ts | 16 + ui/src/main.tsx | 5 + ui/src/pages/Dashboard.tsx | 8 + ui/src/pages/PluginManager.tsx | 512 ++++ ui/src/pages/PluginPage.tsx | 113 + ui/src/pages/PluginSettings.tsx | 836 ++++++ ui/src/pages/ProjectDetail.tsx | 66 +- ui/src/plugins/bridge-init.ts | 116 + ui/src/plugins/bridge.ts | 361 +++ ui/src/plugins/launchers.tsx | 829 ++++++ ui/src/plugins/slots.tsx | 862 ++++++ 103 files changed, 31760 insertions(+), 35 deletions(-) create mode 100644 packages/db/src/migrations/0028_plugin_tables.sql create mode 100644 packages/db/src/schema/plugin_company_settings.ts create mode 100644 packages/db/src/schema/plugin_config.ts create mode 100644 packages/db/src/schema/plugin_entities.ts create mode 100644 packages/db/src/schema/plugin_jobs.ts create mode 100644 packages/db/src/schema/plugin_logs.ts create mode 100644 packages/db/src/schema/plugin_state.ts create mode 100644 packages/db/src/schema/plugin_webhooks.ts create mode 100644 packages/db/src/schema/plugins.ts create mode 100644 packages/plugins/create-paperclip-plugin/README.md create mode 100644 packages/plugins/create-paperclip-plugin/package.json create mode 100644 packages/plugins/create-paperclip-plugin/src/index.ts create mode 100644 packages/plugins/create-paperclip-plugin/tsconfig.json create mode 100644 packages/plugins/examples/plugin-file-browser-example/README.md create mode 100644 packages/plugins/examples/plugin-file-browser-example/package.json create mode 100644 packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/index.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-file-browser-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-file-browser-example/tsconfig.json create mode 100644 packages/plugins/examples/plugin-hello-world-example/README.md create mode 100644 packages/plugins/examples/plugin-hello-world-example/package.json create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/index.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/manifest.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx create mode 100644 packages/plugins/examples/plugin-hello-world-example/src/worker.ts create mode 100644 packages/plugins/examples/plugin-hello-world-example/tsconfig.json create mode 100644 packages/plugins/sdk/README.md create mode 100644 packages/plugins/sdk/package.json create mode 100644 packages/plugins/sdk/src/bundlers.ts create mode 100644 packages/plugins/sdk/src/define-plugin.ts create mode 100644 packages/plugins/sdk/src/dev-cli.ts create mode 100644 packages/plugins/sdk/src/dev-server.ts create mode 100644 packages/plugins/sdk/src/host-client-factory.ts create mode 100644 packages/plugins/sdk/src/index.ts create mode 100644 packages/plugins/sdk/src/protocol.ts create mode 100644 packages/plugins/sdk/src/testing.ts create mode 100644 packages/plugins/sdk/src/types.ts create mode 100644 packages/plugins/sdk/src/ui/components.ts create mode 100644 packages/plugins/sdk/src/ui/hooks.ts create mode 100644 packages/plugins/sdk/src/ui/index.ts create mode 100644 packages/plugins/sdk/src/ui/runtime.ts create mode 100644 packages/plugins/sdk/src/ui/types.ts create mode 100644 packages/plugins/sdk/src/worker-rpc-host.ts create mode 100644 packages/plugins/sdk/tsconfig.json create mode 100644 packages/shared/src/types/plugin.ts create mode 100644 packages/shared/src/validators/plugin.ts create mode 100644 scripts/ensure-plugin-build-deps.mjs create mode 100644 server/src/__tests__/plugin-worker-manager.test.ts create mode 100644 server/src/routes/plugin-ui-static.ts create mode 100644 server/src/routes/plugins.ts create mode 100644 server/src/services/cron.ts create mode 100644 server/src/services/plugin-capability-validator.ts create mode 100644 server/src/services/plugin-config-validator.ts create mode 100644 server/src/services/plugin-dev-watcher.ts create mode 100644 server/src/services/plugin-event-bus.ts create mode 100644 server/src/services/plugin-host-service-cleanup.ts create mode 100644 server/src/services/plugin-host-services.ts create mode 100644 server/src/services/plugin-job-coordinator.ts create mode 100644 server/src/services/plugin-job-scheduler.ts create mode 100644 server/src/services/plugin-job-store.ts create mode 100644 server/src/services/plugin-lifecycle.ts create mode 100644 server/src/services/plugin-loader.ts create mode 100644 server/src/services/plugin-log-retention.ts create mode 100644 server/src/services/plugin-manifest-validator.ts create mode 100644 server/src/services/plugin-registry.ts create mode 100644 server/src/services/plugin-runtime-sandbox.ts create mode 100644 server/src/services/plugin-secrets-handler.ts create mode 100644 server/src/services/plugin-state-store.ts create mode 100644 server/src/services/plugin-stream-bus.ts create mode 100644 server/src/services/plugin-tool-dispatcher.ts create mode 100644 server/src/services/plugin-tool-registry.ts create mode 100644 server/src/services/plugin-worker-manager.ts create mode 100644 ui/src/api/plugins.ts create mode 100644 ui/src/components/JsonSchemaForm.tsx create mode 100644 ui/src/pages/PluginManager.tsx create mode 100644 ui/src/pages/PluginPage.tsx create mode 100644 ui/src/pages/PluginSettings.tsx create mode 100644 ui/src/plugins/bridge-init.ts create mode 100644 ui/src/plugins/bridge.ts create mode 100644 ui/src/plugins/launchers.tsx create mode 100644 ui/src/plugins/slots.tsx diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 896f5115..65fabac0 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -8,6 +8,26 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). It is the full target architecture for the plugin system that should follow V1. +## Current implementation caveats + +The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec. + +Today, the practical deployment model is: + +- single-tenant +- self-hosted +- single-node or otherwise filesystem-persistent + +Current limitations to keep in mind: + +- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory. +- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry. +- Published npm packages are the intended install artifact for deployed plugins. +- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build. +- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet. + +In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution. + ## 1. Scope This spec covers: @@ -212,6 +232,8 @@ Suggested layout: The package install directory and the plugin data directory are separate. +This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work. + ## 8.2 Operator Commands Paperclip should add CLI commands: @@ -237,6 +259,8 @@ The install process is: 7. Start plugin worker and run health/validation. 8. Mark plugin `ready` or `error`. +For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added. + ## 9. Load Order And Precedence Load order must be deterministic. diff --git a/packages/db/src/migrations/0028_plugin_tables.sql b/packages/db/src/migrations/0028_plugin_tables.sql new file mode 100644 index 00000000..8ee0d937 --- /dev/null +++ b/packages/db/src/migrations/0028_plugin_tables.sql @@ -0,0 +1,177 @@ +-- Rollback: +-- DROP INDEX IF EXISTS "plugin_logs_level_idx"; +-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq"; +-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_external_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_type_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx"; +-- DROP INDEX IF EXISTS "plugins_status_idx"; +-- DROP INDEX IF EXISTS "plugins_plugin_key_idx"; +-- DROP TABLE IF EXISTS "plugin_logs"; +-- DROP TABLE IF EXISTS "plugin_company_settings"; +-- DROP TABLE IF EXISTS "plugin_webhook_deliveries"; +-- DROP TABLE IF EXISTS "plugin_job_runs"; +-- DROP TABLE IF EXISTS "plugin_jobs"; +-- DROP TABLE IF EXISTS "plugin_entities"; +-- DROP TABLE IF EXISTS "plugin_state"; +-- DROP TABLE IF EXISTS "plugin_config"; +-- DROP TABLE IF EXISTS "plugins"; + +CREATE TABLE "plugins" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_key" text NOT NULL, + "package_name" text NOT NULL, + "package_path" text, + "version" text NOT NULL, + "api_version" integer DEFAULT 1 NOT NULL, + "categories" jsonb DEFAULT '[]'::jsonb NOT NULL, + "manifest_json" jsonb NOT NULL, + "status" text DEFAULT 'installed' NOT NULL, + "install_order" integer, + "last_error" text, + "installed_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "config_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "namespace" text DEFAULT 'default' NOT NULL, + "state_key" text NOT NULL, + "value_json" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key") +); +--> statement-breakpoint +CREATE TABLE "plugin_entities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "entity_type" text NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "external_id" text, + "title" text, + "status" text, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "job_key" text NOT NULL, + "schedule" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "last_run_at" timestamp with time zone, + "next_run_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_job_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "job_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "trigger" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "logs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "webhook_key" text NOT NULL, + "external_id" text, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "payload" jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_company_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "enabled" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "plugin_id" uuid NOT NULL, + "level" text NOT NULL DEFAULT 'info', + "message" text NOT NULL, + "meta" jsonb, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint +CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint +CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint +CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint +CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 80a1dfbd..63c18087 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1773150731736, "tag": "0027_tranquil_tenebrous", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773417600000, + "tag": "0028_plugin_tables", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3416ea9a..25904f70 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -32,3 +32,11 @@ export { approvalComments } from "./approval_comments.js"; export { activityLog } from "./activity_log.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; +export { plugins } from "./plugins.js"; +export { pluginConfig } from "./plugin_config.js"; +export { pluginCompanySettings } from "./plugin_company_settings.js"; +export { pluginState } from "./plugin_state.js"; +export { pluginEntities } from "./plugin_entities.js"; +export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js"; +export { pluginWebhookDeliveries } from "./plugin_webhooks.js"; +export { pluginLogs } from "./plugin_logs.js"; diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts new file mode 100644 index 00000000..87d4b4af --- /dev/null +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -0,0 +1,41 @@ +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_company_settings` table — stores operator-managed plugin settings + * scoped to a specific company. + * + * This is distinct from `plugin_config`, which stores instance-wide plugin + * configuration. Each company can have at most one settings row per plugin. + * + * Rows represent explicit overrides from the default company behavior: + * - no row => plugin is enabled for the company by default + * - row with `enabled = false` => plugin is disabled for that company + * - row with `enabled = true` => plugin remains enabled and stores company settings + */ +export const pluginCompanySettings = pgTable( + "plugin_company_settings", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + enabled: boolean("enabled").notNull().default(true), + settingsJson: jsonb("settings_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("plugin_company_settings_company_idx").on(table.companyId), + pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId), + companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on( + table.companyId, + table.pluginId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts new file mode 100644 index 00000000..24407b97 --- /dev/null +++ b/packages/db/src/schema/plugin_config.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_config` table — stores operator-provided instance configuration + * for each plugin (one row per plugin, enforced by a unique index on + * `plugin_id`). + * + * The `config_json` column holds the values that the operator enters in the + * plugin settings UI. These values are validated at runtime against the + * plugin's `instanceConfigSchema` from the manifest. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginConfig = pgTable( + "plugin_config", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + configJson: jsonb("config_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId), + }), +); diff --git a/packages/db/src/schema/plugin_entities.ts b/packages/db/src/schema/plugin_entities.ts new file mode 100644 index 00000000..5f732304 --- /dev/null +++ b/packages/db/src/schema/plugin_entities.ts @@ -0,0 +1,54 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; + +/** + * `plugin_entities` table — persistent high-level mapping between Paperclip + * objects and external plugin-defined entities. + * + * This table is used by plugins (e.g. `linear`, `github`) to store pointers + * to their respective external IDs for projects, issues, etc. and to store + * their custom data. + * + * Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities` + * is intended for structured object mappings that the host can understand + * and query for cross-plugin UI integration. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginEntities = pgTable( + "plugin_entities", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + scopeKind: text("scope_kind").$type().notNull(), + scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id) + externalId: text("external_id"), // ID in the external system + title: text("title"), + status: text("status"), + data: jsonb("data").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId), + typeIdx: index("plugin_entities_type_idx").on(table.entityType), + scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId), + externalIdx: uniqueIndex("plugin_entities_external_idx").on( + table.pluginId, + table.entityType, + table.externalId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts new file mode 100644 index 00000000..fec0d0c4 --- /dev/null +++ b/packages/db/src/schema/plugin_jobs.ts @@ -0,0 +1,102 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared"; + +/** + * `plugin_jobs` table — registration and runtime configuration for + * scheduled jobs declared by plugins in their manifests. + * + * Each row represents one scheduled job entry for a plugin. The + * `job_key` matches the key declared in the manifest's `jobs` array. + * The `schedule` column stores the cron expression or interval string + * used by the job scheduler to decide when to fire the job. + * + * Status values: + * - `active` — job is enabled and will run on schedule + * - `paused` — job is temporarily disabled by the operator + * - `error` — job has been disabled due to repeated failures + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` + */ +export const pluginJobs = pgTable( + "plugin_jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `jobs` array. */ + jobKey: text("job_key").notNull(), + /** Cron expression (e.g. `"0 * * * *"`) or interval string. */ + schedule: text("schedule").notNull(), + /** Current scheduling state. */ + status: text("status").$type().notNull().default("active"), + /** Timestamp of the most recent successful execution. */ + lastRunAt: timestamp("last_run_at", { withTimezone: true }), + /** Pre-computed timestamp of the next scheduled execution. */ + nextRunAt: timestamp("next_run_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId), + nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt), + uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey), + }), +); + +/** + * `plugin_job_runs` table — immutable execution history for plugin-owned jobs. + * + * Each row is created when a job run begins and updated when it completes. + * Rows are never modified after `status` reaches a terminal value + * (`succeeded` | `failed` | `cancelled`). + * + * Trigger values: + * - `scheduled` — fired automatically by the cron/interval scheduler + * - `manual` — triggered by an operator via the admin UI or API + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs` + */ +export const pluginJobRuns = pgTable( + "plugin_job_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the parent job definition. Cascades on delete. */ + jobId: uuid("job_id") + .notNull() + .references(() => pluginJobs.id, { onDelete: "cascade" }), + /** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** What caused this run to start (`"scheduled"` or `"manual"`). */ + trigger: text("trigger").$type().notNull(), + /** Current lifecycle state of this run. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock duration in milliseconds. Null until the run finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Ordered list of log lines emitted during this run. */ + logs: jsonb("logs").$type().notNull().default([]), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + jobIdx: index("plugin_job_runs_job_idx").on(table.jobId), + pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_job_runs_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/plugin_logs.ts b/packages/db/src/schema/plugin_logs.ts new file mode 100644 index 00000000..d32908f1 --- /dev/null +++ b/packages/db/src/schema/plugin_logs.ts @@ -0,0 +1,43 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_logs` table — structured log storage for plugin workers. + * + * Each row stores a single log entry emitted by a plugin worker via + * `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and + * time range to support the operator logs panel and debugging workflows. + * + * Rows are inserted by the host when handling `log` notifications from + * the worker process. A capped retention policy can be applied via + * periodic cleanup (e.g. delete rows older than 7 days). + * + * @see PLUGIN_SPEC.md §26 — Observability + */ +export const pluginLogs = pgTable( + "plugin_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + level: text("level").notNull().default("info"), + message: text("message").notNull(), + meta: jsonb("meta").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginTimeIdx: index("plugin_logs_plugin_time_idx").on( + table.pluginId, + table.createdAt, + ), + levelIdx: index("plugin_logs_level_idx").on(table.level), + }), +); diff --git a/packages/db/src/schema/plugin_state.ts b/packages/db/src/schema/plugin_state.ts new file mode 100644 index 00000000..600797fa --- /dev/null +++ b/packages/db/src/schema/plugin_state.ts @@ -0,0 +1,90 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + unique, +} from "drizzle-orm/pg-core"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_state` table — scoped key-value storage for plugin workers. + * + * Each row stores a single JSON value identified by + * `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use + * this table through `ctx.state.get()`, `ctx.state.set()`, and + * `ctx.state.delete()` in the SDK. + * + * Scope kinds determine the granularity of isolation: + * - `instance` — one value shared across the whole Paperclip instance + * - `company` — one value per company + * - `project` — one value per project + * - `project_workspace` — one value per project workspace + * - `agent` — one value per agent + * - `issue` — one value per issue + * - `goal` — one value per goal + * - `run` — one value per agent run + * + * The `namespace` column defaults to `"default"` and can be used to + * logically group keys without polluting the root namespace. + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` + */ +export const pluginState = pgTable( + "plugin_state", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */ + scopeKind: text("scope_kind").$type().notNull(), + /** + * UUID or text identifier for the scoped object. + * Null for `instance` scope (which has no associated entity). + */ + scopeId: text("scope_id"), + /** + * Sub-namespace to avoid key collisions within a scope. + * Defaults to `"default"` if the plugin does not specify one. + */ + namespace: text("namespace").notNull().default("default"), + /** The key identifying this state entry within the namespace. */ + stateKey: text("state_key").notNull(), + /** JSON-serializable value stored by the plugin. */ + valueJson: jsonb("value_json").notNull(), + /** Timestamp of the most recent write. */ + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + /** + * Unique constraint enforces that there is at most one value per + * (plugin, scope kind, scope id, namespace, key) tuple. + * + * `nullsNotDistinct()` is required so that `scope_id IS NULL` entries + * (used by `instance` scope) are treated as equal by PostgreSQL rather + * than as distinct nulls — otherwise the upsert target in `set()` would + * fail to match existing rows and create duplicates. + * + * Requires PostgreSQL 15+. + */ + uniqueEntry: unique("plugin_state_unique_entry_idx") + .on( + table.pluginId, + table.scopeKind, + table.scopeId, + table.namespace, + table.stateKey, + ) + .nullsNotDistinct(), + /** Speed up lookups by plugin + scope kind (most common access pattern). */ + pluginScopeIdx: index("plugin_state_plugin_scope_idx").on( + table.pluginId, + table.scopeKind, + ), + }), +); diff --git a/packages/db/src/schema/plugin_webhooks.ts b/packages/db/src/schema/plugin_webhooks.ts new file mode 100644 index 00000000..0580e970 --- /dev/null +++ b/packages/db/src/schema/plugin_webhooks.ts @@ -0,0 +1,65 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared"; + +/** + * `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins. + * + * When an external system sends an HTTP POST to a plugin's registered webhook + * endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server + * creates a row in this table before dispatching the payload to the plugin + * worker. This provides an auditable log of every delivery attempt. + * + * The `webhook_key` matches the key declared in the plugin manifest's + * `webhooks` array. `external_id` is an optional identifier supplied by the + * remote system (e.g. a GitHub delivery GUID) that can be used to detect + * and reject duplicate deliveries. + * + * Status values: + * - `pending` — received but not yet dispatched to the worker + * - `processing` — currently being handled by the plugin worker + * - `succeeded` — worker processed the payload successfully + * - `failed` — worker returned an error or timed out + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries` + */ +export const pluginWebhookDeliveries = pgTable( + "plugin_webhook_deliveries", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `webhooks` array. */ + webhookKey: text("webhook_key").notNull(), + /** Optional de-duplication ID provided by the external system. */ + externalId: text("external_id"), + /** Current delivery state. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock processing duration in milliseconds. Null until delivery finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Raw JSON body of the inbound HTTP request. */ + payload: jsonb("payload").$type>().notNull(), + /** Relevant HTTP headers from the inbound request (e.g. signature headers). */ + headers: jsonb("headers").$type>().notNull().default({}), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status), + keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey), + }), +); diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts new file mode 100644 index 00000000..948e5d60 --- /dev/null +++ b/packages/db/src/schema/plugins.ts @@ -0,0 +1,45 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared"; + +/** + * `plugins` table — stores one row per installed plugin. + * + * Each plugin is uniquely identified by `plugin_key` (derived from + * the manifest `id`). The full manifest is persisted as JSONB in + * `manifest_json` so the host can reconstruct capability and UI + * slot information without loading the plugin package. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const plugins = pgTable( + "plugins", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginKey: text("plugin_key").notNull(), + packageName: text("package_name").notNull(), + version: text("version").notNull(), + apiVersion: integer("api_version").notNull().default(1), + categories: jsonb("categories").$type().notNull().default([]), + manifestJson: jsonb("manifest_json").$type().notNull(), + status: text("status").$type().notNull().default("installed"), + installOrder: integer("install_order"), + /** Resolved package path for local-path installs; used to find worker entrypoint. */ + packagePath: text("package_path"), + lastError: text("last_error"), + installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey), + statusIdx: index("plugins_status_idx").on(table.status), + }), +); diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md new file mode 100644 index 00000000..46519da1 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -0,0 +1,38 @@ +# @paperclipai/create-paperclip-plugin + +Scaffolding tool for creating new Paperclip plugins. + +```bash +npx @paperclipai/create-paperclip-plugin my-plugin +``` + +Or with options: + +```bash +npx @paperclipai/create-paperclip-plugin @acme/my-plugin \ + --template connector \ + --category connector \ + --display-name "Acme Connector" \ + --description "Syncs Acme data into Paperclip" \ + --author "Acme Inc" +``` + +Supported templates: `default`, `connector`, `workspace` +Supported categories: `connector`, `workspace`, `automation`, `ui` + +Generates: +- typed manifest + worker entrypoint +- example UI widget using `@paperclipai/plugin-sdk/ui` +- test file using `@paperclipai/plugin-sdk/testing` +- `esbuild` and `rollup` config files using SDK bundler presets +- dev server script for hot-reload (`paperclip-plugin-dev-server`) + +## Workflow after scaffolding + +```bash +cd my-plugin +pnpm install +pnpm dev # watch worker + manifest + ui bundles +pnpm dev:ui # local UI preview server with hot-reload events +pnpm test +``` diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json new file mode 100644 index 00000000..e863cd6c --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@paperclipai/create-paperclip-plugin", + "version": "0.1.0", + "type": "module", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts new file mode 100644 index 00000000..6d0e6c2d --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -0,0 +1,398 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const VALID_TEMPLATES = ["default", "connector", "workspace"] as const; +type PluginTemplate = (typeof VALID_TEMPLATES)[number]; +const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const); + +export interface ScaffoldPluginOptions { + pluginName: string; + outputDir: string; + template?: PluginTemplate; + displayName?: string; + description?: string; + author?: string; + category?: "connector" | "workspace" | "automation" | "ui"; +} + +/** Validate npm-style plugin package names (scoped or unscoped). */ +export function isValidPluginName(name: string): boolean { + const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/; + const unscopedPattern = /^[a-z0-9._-]+$/; + return scopedPattern.test(name) || unscopedPattern.test(name); +} + +/** Convert `@scope/name` to an output directory basename (`name`). */ +function packageToDirName(pluginName: string): string { + return pluginName.replace(/^@[^/]+\//, ""); +} + +/** Convert an npm package name into a manifest-safe plugin id. */ +function packageToManifestId(pluginName: string): string { + if (!pluginName.startsWith("@")) { + return pluginName; + } + + return pluginName.slice(1).replace("/", "."); +} + +/** Build a human-readable display name from package name tokens. */ +function makeDisplayName(pluginName: string): string { + const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim(); + return raw + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function writeFile(target: string, content: string) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +/** + * Generate a complete Paperclip plugin starter project. + * + * Output includes manifest/worker/UI entries, SDK harness tests, bundler presets, + * and a local dev server script for hot-reload workflow. + */ +export function scaffoldPluginProject(options: ScaffoldPluginOptions): string { + const template = options.template ?? "default"; + if (!VALID_TEMPLATES.includes(template)) { + throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`); + } + + if (!isValidPluginName(options.pluginName)) { + throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens."); + } + + if (options.category && !VALID_CATEGORIES.has(options.category)) { + throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`); + } + + const outputDir = path.resolve(options.outputDir); + if (fs.existsSync(outputDir)) { + throw new Error(`Directory already exists: ${outputDir}`); + } + + const displayName = options.displayName ?? makeDisplayName(options.pluginName); + const description = options.description ?? "A Paperclip plugin"; + const author = options.author ?? "Plugin Author"; + const category = options.category ?? (template === "workspace" ? "workspace" : "connector"); + const manifestId = packageToManifestId(options.pluginName); + + fs.mkdirSync(outputDir, { recursive: true }); + + const packageJson = { + name: options.pluginName, + version: "0.1.0", + type: "module", + private: true, + description, + scripts: { + build: "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + dev: "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + test: "vitest run", + typecheck: "tsc --noEmit" + }, + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui/" + }, + keywords: ["paperclip", "plugin", category], + author, + license: "MIT", + dependencies: { + "@paperclipai/plugin-sdk": "^1.0.0" + }, + devDependencies: { + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + esbuild: "^0.27.3", + rollup: "^4.38.0", + tslib: "^2.8.1", + typescript: "^5.7.3", + vitest: "^3.0.5" + }, + peerDependencies: { + react: ">=18" + } + }; + + writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + + const tsconfig = { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["ES2022", "DOM"], + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + declaration: true, + declarationMap: true, + sourceMap: true, + outDir: "dist", + rootDir: "src" + }, + include: ["src", "tests"], + exclude: ["dist", "node_modules"] + }; + + writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + + writeFile( + path.join(outputDir, "esbuild.config.mjs"), + `import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} +`, + ); + + writeFile( + path.join(outputDir, "rollup.config.mjs"), + `import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); +`, + ); + + writeFile( + path.join(outputDir, "src", "manifest.ts"), + `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: ${quote(manifestId)}, + apiVersion: 1, + version: "0.1.0", + displayName: ${quote(displayName)}, + description: ${quote(description)}, + author: ${quote(author)}, + categories: [${quote(category)}], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "health-widget", + displayName: ${quote(`${displayName} Health`)}, + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; +`, + ); + + writeFile( + path.join(outputDir, "src", "worker.ts"), + `import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); +`, + ); + + writeFile( + path.join(outputDir, "src", "ui", "index.tsx"), + `import { MetricCard, StatusBadge, usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return ; + + return ( +
+ + +
+ ); +} +`, + ); + + writeFile( + path.join(outputDir, "tests", "plugin.spec.ts"), + `import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("plugin scaffold", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); +`, + ); + + writeFile( + path.join(outputDir, "README.md"), + `# ${displayName} + +${description} + +## Development + +\`\`\`bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +\`\`\` + +## Install Into Paperclip + +\`\`\`bash +pnpm paperclipai plugin install ./ +\`\`\` + +## Build Options + +- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`. +- \`pnpm build:rollup\` uses rollup presets from the same SDK. +`, + ); + + writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n"); + + return outputDir; +} + +function parseArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +/** CLI wrapper for `scaffoldPluginProject`. */ +function runCli() { + const pluginName = process.argv[2]; + if (!pluginName) { + // eslint-disable-next-line no-console + console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ]"); + process.exit(1); + } + + const template = (parseArg("--template") ?? "default") as PluginTemplate; + const outputRoot = parseArg("--output") ?? process.cwd(); + const targetDir = path.resolve(outputRoot, packageToDirName(pluginName)); + + const out = scaffoldPluginProject({ + pluginName, + outputDir: targetDir, + template, + displayName: parseArg("--display-name"), + description: parseArg("--description"), + author: parseArg("--author"), + category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined, + }); + + // eslint-disable-next-line no-console + console.log(`Created plugin scaffold at ${out}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli(); +} diff --git a/packages/plugins/create-paperclip-plugin/tsconfig.json b/packages/plugins/create-paperclip-plugin/tsconfig.json new file mode 100644 index 00000000..90314411 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-file-browser-example/README.md b/packages/plugins/examples/plugin-file-browser-example/README.md new file mode 100644 index 00000000..ca02fcf7 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/README.md @@ -0,0 +1,62 @@ +# File Browser Example Plugin + +Example Paperclip plugin that demonstrates: + +- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off. +- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support. + +This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included. + +## Slots + +| Slot | Type | Description | +|---------------------|---------------------|--------------------------------------------------| +| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. | +| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.| + +## Settings + +- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off. +- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown. + +## Capabilities + +- `ui.sidebar.register` — project sidebar item +- `ui.detailTab.register` — project detail tab +- `projects.read` — resolve project +- `project.workspaces.read` — list workspaces and read paths for file access + +## Worker + +- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first). +- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`). +- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`). +- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk. + +## Local Install (Dev) + +From the repo root, build the plugin and install it by local path: + +```bash +pnpm --filter @paperclipai/plugin-file-browser-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example +``` + +To uninstall: + +```bash +pnpm paperclipai plugin uninstall paperclip-file-browser-example --force +``` + +**Local development notes:** + +- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists. +- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host. +- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin. +- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config. + +## Structure + +- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`). +- `src/worker.ts` — data handlers for workspaces, file list, file content. +- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor). diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json new file mode 100644 index 00000000..86c720d4 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -0,0 +1,42 @@ +{ + "name": "@paperclipai/plugin-file-browser-example", + "version": "0.1.0", + "description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc && node ./scripts/build-ui.mjs", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.28.0", + "@lezer/highlight": "^1.2.1", + "@paperclipai/plugin-sdk": "workspace:*", + "codemirror": "^6.0.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "esbuild": "^0.27.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, ".."); + +await esbuild.build({ + entryPoints: [path.join(packageRoot, "src/ui/index.tsx")], + outfile: path.join(packageRoot, "dist/ui/index.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@paperclipai/plugin-sdk/ui", + ], + logLevel: "info", +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/src/index.ts b/packages/plugins/examples/plugin-file-browser-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts new file mode 100644 index 00000000..027c134b --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts @@ -0,0 +1,85 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const PLUGIN_ID = "paperclip-file-browser-example"; +const FILES_SIDEBAR_SLOT_ID = "files-link"; +const FILES_TAB_SLOT_ID = "files-tab"; +const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links"; +const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.2.0", + displayName: "File Browser (Example)", + description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "ui.sidebar.register", + "ui.detailTab.register", + "ui.commentAnnotation.register", + "ui.action.register", + "projects.read", + "project.workspaces.read", + "issue.comments.read", + "plugin.state.read", + ], + instanceConfigSchema: { + type: "object", + properties: { + showFilesInSidebar: { + type: "boolean", + title: "Show Files in Sidebar", + default: false, + description: "Adds the Files link under each project in the sidebar.", + }, + commentAnnotationMode: { + type: "string", + title: "Comment File Links", + enum: ["annotation", "contextMenu", "both", "none"], + default: "both", + description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.", + }, + }, + }, + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "projectSidebarItem", + id: FILES_SIDEBAR_SLOT_ID, + displayName: "Files", + exportName: "FilesLink", + entityTypes: ["project"], + order: 10, + }, + { + type: "detailTab", + id: FILES_TAB_SLOT_ID, + displayName: "Files", + exportName: "FilesTab", + entityTypes: ["project"], + order: 10, + }, + { + type: "commentAnnotation", + id: COMMENT_FILE_LINKS_SLOT_ID, + displayName: "File Links", + exportName: "CommentFileLinks", + entityTypes: ["comment"], + }, + { + type: "commentContextMenuItem", + id: COMMENT_OPEN_FILES_SLOT_ID, + displayName: "Open in Files", + exportName: "CommentOpenFiles", + entityTypes: ["comment"], + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx new file mode 100644 index 00000000..0e12d903 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -0,0 +1,815 @@ +import type { + PluginProjectSidebarItemProps, + PluginDetailTabProps, + PluginCommentAnnotationProps, + PluginCommentContextMenuItemProps, +} from "@paperclipai/plugin-sdk/ui"; +import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui"; +import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react"; +import { EditorView } from "@codemirror/view"; +import { basicSetup } from "codemirror"; +import { javascript } from "@codemirror/lang-javascript"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +const PLUGIN_KEY = "paperclip-file-browser-example"; +const FILES_TAB_SLOT_ID = "files-tab"; + +const editorBaseTheme = { + "&": { + height: "100%", + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace", + fontSize: "13px", + lineHeight: "1.6", + }, + ".cm-content": { + padding: "12px 14px 18px", + }, +}; + +const editorDarkTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "oklch(0.23 0.02 255)", + color: "oklch(0.93 0.01 255)", + }, + ".cm-gutters": { + backgroundColor: "oklch(0.25 0.015 255)", + color: "oklch(0.74 0.015 255)", + borderRight: "1px solid oklch(0.34 0.01 255)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "oklch(0.30 0.012 255 / 0.55)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "oklch(0.42 0.02 255 / 0.45)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "oklch(0.47 0.025 255 / 0.5)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "oklch(0.93 0.01 255)", + }, + ".cm-matchingBracket": { + backgroundColor: "oklch(0.37 0.015 255 / 0.5)", + color: "oklch(0.95 0.01 255)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "oklch(0.70 0.08 24)", + }, +}, { dark: true }); + +const editorLightTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))", + color: "var(--foreground)", + }, + ".cm-content": { + ...editorBaseTheme[".cm-content"], + caretColor: "var(--foreground)", + }, + ".cm-gutters": { + backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))", + color: "var(--muted-foreground)", + borderRight: "1px solid var(--border)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)", + }, + ".cm-matchingBracket": { + backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)", + color: "var(--foreground)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "var(--destructive)", + }, +}); + +const editorDarkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.78 0.025 265)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" }, +]); + +const editorLightHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.45 0.07 270)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" }, +]); + +type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean }; +type FileEntry = { name: string; path: string; isDirectory: boolean }; +type FileTreeNodeProps = { + entry: FileEntry; + companyId: string | null; + projectId: string; + workspaceId: string; + selectedPath: string | null; + onSelect: (path: string) => void; + depth?: number; +}; + +const PathLikePattern = /[\\/]/; +const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/; +const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isLikelyPath(pathValue: string): boolean { + const trimmed = pathValue.trim(); + return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed); +} + +function workspaceLabel(workspace: Workspace): string { + const pathLabel = workspace.path.trim(); + const nameLabel = workspace.name.trim(); + const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel); + const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel); + const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : ""; + if (!baseLabel) { + return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)"; + } + + return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel; +} + +function useIsMobile(breakpointPx = 768): boolean { + const [isMobile, setIsMobile] = useState(() => + typeof window !== "undefined" ? window.innerWidth < breakpointPx : false, + ); + + useEffect(() => { + if (typeof window === "undefined") return; + const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`); + const update = () => setIsMobile(mediaQuery.matches); + update(); + mediaQuery.addEventListener("change", update); + return () => mediaQuery.removeEventListener("change", update); + }, [breakpointPx]); + + return isMobile; +} + +function useIsDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(() => + typeof document !== "undefined" && document.documentElement.classList.contains("dark"), + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + const update = () => setIsDarkMode(root.classList.contains("dark")); + update(); + + const observer = new MutationObserver(update); + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + return isDarkMode; +} + +function useAvailableHeight( + ref: RefObject, + options?: { bottomPadding?: number; minHeight?: number }, +): number | null { + const bottomPadding = options?.bottomPadding ?? 24; + const minHeight = options?.minHeight ?? 384; + const [height, setHeight] = useState(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + const update = () => { + const element = ref.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding)); + setHeight(nextHeight); + }; + + update(); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + + const observer = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => update()) + : null; + if (observer && ref.current) observer.observe(ref.current); + + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + observer?.disconnect(); + }; + }, [bottomPadding, minHeight, ref]); + + return height; +} + +function FileTreeNode({ + entry, + companyId, + projectId, + workspaceId, + selectedPath, + onSelect, + depth = 0, +}: FileTreeNodeProps) { + const [isExpanded, setIsExpanded] = useState(false); + const isSelected = selectedPath === entry.path; + + if (entry.isDirectory) { + return ( +
  • + + {isExpanded ? ( + + ) : null} +
  • + ); + } + + return ( +
  • + +
  • + ); +} + +function ExpandedDirectoryChildren({ + directoryPath, + companyId, + projectId, + workspaceId, + selectedPath, + onSelect, + depth, +}: { + directoryPath: string; + companyId: string | null; + projectId: string; + workspaceId: string; + selectedPath: string | null; + onSelect: (path: string) => void; + depth: number; +}) { + const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", { + companyId, + projectId, + workspaceId, + directoryPath, + }); + const children = childData?.entries ?? []; + + if (children.length === 0) { + return null; + } + + return ( +
      + {children.map((child) => ( + + ))} +
    + ); +} + +/** + * Project sidebar item: link "Files" that opens the project detail with the Files plugin tab. + */ +export function FilesLink({ context }: PluginProjectSidebarItemProps) { + const { data: config, loading: configLoading } = usePluginData("plugin-config", {}); + const showFilesInSidebar = config?.showFilesInSidebar ?? false; + + if (configLoading || !showFilesInSidebar) { + return null; + } + + const projectId = context.entityId; + const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null }) + .projectRef + ?? projectId; + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; + const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`; + const isActive = typeof window !== "undefined" && (() => { + const pathname = window.location.pathname.replace(/\/+$/, ""); + const segments = pathname.split("/").filter(Boolean); + const projectsIndex = segments.indexOf("projects"); + const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null; + const activeTab = new URLSearchParams(window.location.search).get("tab"); + if (activeTab !== tabValue) return false; + if (!activeProjectRef) return false; + return activeProjectRef === projectId || activeProjectRef === projectRef; + })(); + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented + || event.button !== 0 + || event.metaKey + || event.ctrlKey + || event.altKey + || event.shiftKey + ) { + return; + } + + event.preventDefault(); + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + }; + + return ( + + Files + + ); +} + +/** + * Project detail tab: workspace selector, file tree, and CodeMirror editor. + */ +export function FilesTab({ context }: PluginDetailTabProps) { + const companyId = context.companyId; + const projectId = context.entityId; + const isMobile = useIsMobile(); + const isDarkMode = useIsDarkMode(); + const panesRef = useRef(null); + const availableHeight = useAvailableHeight(panesRef, { + bottomPadding: isMobile ? 16 : 24, + minHeight: isMobile ? 320 : 420, + }); + const { data: workspacesData } = usePluginData("workspaces", { + projectId, + companyId, + }); + const workspaces = workspacesData ?? []; + const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); + const [workspaceId, setWorkspaceId] = useState(null); + const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null; + const selectedWorkspace = useMemo( + () => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null, + [workspaces, resolvedWorkspaceId], + ); + + const fileListParams = useMemo( + () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}), + [companyId, projectId, selectedWorkspace], + ); + const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>( + "fileList", + fileListParams, + ); + const entries = fileListData?.entries ?? []; + + // Track the `?file=` query parameter across navigations (popstate). + const [urlFilePath, setUrlFilePath] = useState(() => { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("file") || null; + }); + const lastConsumedFileRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + const onNav = () => { + const next = new URLSearchParams(window.location.search).get("file") || null; + setUrlFilePath(next); + }; + window.addEventListener("popstate", onNav); + return () => window.removeEventListener("popstate", onNav); + }, []); + + const [selectedPath, setSelectedPath] = useState(null); + useEffect(() => { + setSelectedPath(null); + setMobileView("browser"); + lastConsumedFileRef.current = null; + }, [selectedWorkspace?.id]); + + // When a file path appears (or changes) in the URL and workspace is ready, select it. + useEffect(() => { + if (!urlFilePath || !selectedWorkspace) return; + if (lastConsumedFileRef.current === urlFilePath) return; + lastConsumedFileRef.current = urlFilePath; + setSelectedPath(urlFilePath); + setMobileView("editor"); + }, [urlFilePath, selectedWorkspace]); + + const fileContentParams = useMemo( + () => + selectedPath && selectedWorkspace + ? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath } + : null, + [companyId, projectId, selectedWorkspace, selectedPath], + ); + const fileContentResult = usePluginData<{ content: string | null; error?: string }>( + "fileContent", + fileContentParams ?? {}, + ); + const { data: fileContentData, refresh: refreshFileContent } = fileContentResult; + const writeFile = usePluginAction("writeFile"); + const editorRef = useRef(null); + const viewRef = useRef(null); + const loadedContentRef = useRef(""); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + const [saveError, setSaveError] = useState(null); + const [mobileView, setMobileView] = useState<"browser" | "editor">("browser"); + + useEffect(() => { + if (!editorRef.current) return; + const content = fileContentData?.content ?? ""; + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage(null); + setSaveError(null); + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + const view = new EditorView({ + doc: content, + extensions: [ + basicSetup, + javascript(), + isDarkMode ? editorDarkTheme : editorLightTheme, + syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle), + EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + const nextValue = update.state.doc.toString(); + setIsDirty(nextValue !== loadedContentRef.current); + setSaveMessage(null); + setSaveError(null); + }), + ], + parent: editorRef.current, + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + }, [fileContentData?.content, selectedPath, isDarkMode]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") { + return; + } + if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) { + return; + } + event.preventDefault(); + void handleSave(); + }; + + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [selectedWorkspace, selectedPath, isDirty, isSaving]); + + async function handleSave() { + if (!selectedWorkspace || !selectedPath || !viewRef.current) { + return; + } + const content = viewRef.current.state.doc.toString(); + setIsSaving(true); + setSaveError(null); + setSaveMessage(null); + try { + await writeFile({ + projectId, + companyId, + workspaceId: selectedWorkspace.id, + filePath: selectedPath, + content, + }); + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage("Saved"); + refreshFileContent(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setIsSaving(false); + } + } + + return ( +
    +
    + + +
    + +
    +
    +
    + File Tree +
    +
    + {selectedWorkspace ? ( + fileListLoading ? ( +

    Loading files...

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

    No files found in this workspace.

    + ) + ) : ( +

    Select a workspace to browse files.

    + )} +
    +
    +
    +
    +
    + +
    Editor
    +
    {selectedPath ?? "No file selected"}
    +
    +
    + +
    +
    + {isDirty || saveMessage || saveError ? ( +
    + {saveError ? ( + {saveError} + ) : saveMessage ? ( + {saveMessage} + ) : ( + Unsaved changes + )} +
    + ) : null} + {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? ( +
    {fileContentData.error}
    + ) : null} +
    +
    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Comment Annotation: renders detected file links below each comment +// --------------------------------------------------------------------------- + +type PluginConfig = { + showFilesInSidebar?: boolean; + commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none"; +}; + +/** + * Per-comment annotation showing file-path-like links extracted from the + * comment body. Each link navigates to the project Files tab with the + * matching path pre-selected. + * + * Respects the `commentAnnotationMode` instance config — hidden when mode + * is `"contextMenu"` or `"none"`. + */ +function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string { + if (!projectId) return "#"; + const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; + return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`; +} + +function navigateToFileBrowser(href: string, event: MouseEvent) { + if ( + event.defaultPrevented + || event.button !== 0 + || event.metaKey + || event.ctrlKey + || event.altKey + || event.shiftKey + ) { + return; + } + event.preventDefault(); + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function CommentFileLinks({ context }: PluginCommentAnnotationProps) { + const { data: config } = usePluginData("plugin-config", {}); + const mode = config?.commentAnnotationMode ?? "both"; + + const { data } = usePluginData<{ links: string[] }>("comment-file-links", { + commentId: context.entityId, + issueId: context.parentEntityId, + companyId: context.companyId, + }); + + if (mode === "contextMenu" || mode === "none") return null; + if (!data?.links?.length) return null; + + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const projectId = context.projectId; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Comment Context Menu Item: "Open in Files" action per comment +// --------------------------------------------------------------------------- + +/** + * Per-comment context menu item that appears in the comment "more" (⋮) menu. + * Extracts file paths from the comment body and, if any are found, renders + * a button to open the first file in the project Files tab. + * + * Respects the `commentAnnotationMode` instance config — hidden when mode + * is `"annotation"` or `"none"`. + */ +export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) { + const { data: config } = usePluginData("plugin-config", {}); + const mode = config?.commentAnnotationMode ?? "both"; + + const { data } = usePluginData<{ links: string[] }>("comment-file-links", { + commentId: context.entityId, + issueId: context.parentEntityId, + companyId: context.companyId, + }); + + if (mode === "annotation" || mode === "none") return null; + if (!data?.links?.length) return null; + + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const projectId = context.projectId; + + return ( +
    +
    + Files +
    + {data.links.map((link) => { + const href = buildFileBrowserHref(prefix, projectId, link); + const fileName = link.split("/").pop() ?? link; + return ( + navigateToFileBrowser(href, e)} + className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors" + title={`Open ${link} in file browser`} + > + {fileName} + + ); + })} +
    + ); +} diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts new file mode 100644 index 00000000..1c39af75 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -0,0 +1,226 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const PLUGIN_NAME = "file-browser-example"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PATH_LIKE_PATTERN = /[\\/]/; +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +function looksLikePath(value: string): boolean { + const normalized = value.trim(); + return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized)) + && !UUID_PATTERN.test(normalized); +} + +function sanitizeWorkspacePath(pathValue: string): string { + return looksLikePath(pathValue) ? pathValue.trim() : ""; +} + +function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null { + const root = path.resolve(workspacePath); + const resolved = requestedPath ? path.resolve(root, requestedPath) : root; + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + return resolved; +} + +/** + * Regex that matches file-path-like tokens in comment text. + * Captures tokens that either start with `.` `/` `~` or contain a `/` + * (directory separator), plus bare words that could be filenames with + * extensions (e.g. `README.md`). The file-extension check in + * `extractFilePaths` filters out non-file matches. + */ +const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g; + +/** Common file extensions to recognise path-like tokens as actual file references. */ +const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/; + +/** + * Tokens that look like paths but are almost certainly URL route segments + * (e.g. `/projects/abc`, `/settings`, `/dashboard`). + */ +const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i; + +function extractFilePaths(body: string): string[] { + const paths = new Set(); + for (const match of body.matchAll(FILE_PATH_REGEX)) { + const raw = match[1]; + // Strip trailing punctuation that isn't part of a path + const cleaned = raw.replace(/[.:,;!?)]+$/, ""); + if (cleaned.length <= 1) continue; + // Must have a file extension (e.g. .ts, .json, .md) + if (!FILE_EXTENSION_REGEX.test(cleaned)) continue; + // Skip things that look like URL routes + if (URL_ROUTE_PATTERN.test(cleaned)) continue; + paths.add(cleaned); + } + return [...paths]; +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup`); + + // Expose the current plugin config so UI components can read the + // commentAnnotationMode setting and hide themselves when disabled. + ctx.data.register("plugin-config", async () => { + const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record | null; + return { + showFilesInSidebar: config?.showFilesInSidebar === true, + commentAnnotationMode: config?.commentAnnotationMode ?? "both", + }; + }); + + // Fetch a comment by ID and extract file-path-like tokens from its body. + ctx.data.register("comment-file-links", async (params: Record) => { + const commentId = typeof params.commentId === "string" ? params.commentId : ""; + const issueId = typeof params.issueId === "string" ? params.issueId : ""; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + if (!commentId || !issueId || !companyId) return { links: [] }; + try { + const comments = await ctx.issues.listComments(issueId, companyId); + const comment = comments.find((c) => c.id === commentId); + if (!comment?.body) return { links: [] }; + return { links: extractFilePaths(comment.body) }; + } catch (err) { + ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) }); + return { links: [] }; + } + }); + + ctx.data.register("workspaces", async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + if (!projectId || !companyId) return []; + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + return workspaces.map((w) => ({ + id: w.id, + projectId: w.projectId, + name: w.name, + path: sanitizeWorkspacePath(w.path), + isPrimary: w.isPrimary, + })); + }); + + ctx.data.register( + "fileList", + async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : ""; + if (!projectId || !companyId || !workspaceId) return { entries: [] }; + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) return { entries: [] }; + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) return { entries: [] }; + const dirPath = resolveWorkspace(workspacePath, directoryPath); + if (!dirPath) { + return { entries: [] }; + } + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return { entries: [] }; + } + const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b)); + const entries = names.map((name) => { + const full = path.join(dirPath, name); + const stat = fs.lstatSync(full); + const relativePath = path.relative(workspacePath, full); + return { + name, + path: relativePath, + isDirectory: stat.isDirectory(), + }; + }).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries }; + }, + ); + + ctx.data.register( + "fileContent", + async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const filePath = params.filePath as string; + if (!projectId || !companyId || !workspaceId || !filePath) { + return { content: null, error: "Missing file context" }; + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) return { content: null, error: "Workspace not found" }; + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) return { content: null, error: "Workspace has no path" }; + const fullPath = resolveWorkspace(workspacePath, filePath); + if (!fullPath) { + return { content: null, error: "Path outside workspace" }; + } + try { + const content = fs.readFileSync(fullPath, "utf-8"); + return { content }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { content: null, error: message }; + } + }, + ); + + ctx.actions.register( + "writeFile", + async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const filePath = typeof params.filePath === "string" ? params.filePath.trim() : ""; + if (!filePath) { + throw new Error("filePath must be a non-empty string"); + } + const content = typeof params.content === "string" ? params.content : null; + if (!projectId || !companyId || !workspaceId) { + throw new Error("Missing workspace context"); + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found"); + } + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) { + throw new Error("Workspace has no path"); + } + if (content === null) { + throw new Error("Missing file content"); + } + const fullPath = resolveWorkspace(workspacePath, filePath); + if (!fullPath) { + throw new Error("Path outside workspace"); + } + const stat = fs.statSync(fullPath); + if (!stat.isFile()) { + throw new Error("Selected path is not a file"); + } + fs.writeFileSync(fullPath, content, "utf-8"); + return { + ok: true, + path: filePath, + bytes: Buffer.byteLength(content, "utf-8"), + }; + }, + ); + }, + + async onHealth() { + return { status: "ok", message: `${PLUGIN_NAME} ready` }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-file-browser-example/tsconfig.json b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-hello-world-example/README.md b/packages/plugins/examples/plugin-hello-world-example/README.md new file mode 100644 index 00000000..889c9d25 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/README.md @@ -0,0 +1,38 @@ +# @paperclipai/plugin-hello-world-example + +First-party reference plugin showing the smallest possible UI extension. + +## What It Demonstrates + +- a manifest with a `dashboardWidget` UI slot +- `entrypoints.ui` wiring for plugin UI bundles +- a minimal React widget rendered in the Paperclip dashboard +- reading host context (`companyId`) from `PluginWidgetProps` +- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability + +## API Surface + +- This example does not add custom HTTP endpoints. +- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`). + +## Notes + +This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors. +It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds. + +## Local Install (Dev) + +From the repo root, build the plugin and install it by local path: + +```bash +pnpm --filter @paperclipai/plugin-hello-world-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example +``` + +**Local development notes:** + +- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists. +- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path. +- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin: + `pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then + `pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`. diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json new file mode 100644 index 00000000..5d055caa --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/package.json @@ -0,0 +1,35 @@ +{ + "name": "@paperclipai/plugin-hello-world-example", + "version": "0.1.0", + "description": "First-party reference plugin that adds a Hello World dashboard widget", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/index.ts b/packages/plugins/examples/plugin-hello-world-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts new file mode 100644 index 00000000..2fcd8077 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts @@ -0,0 +1,39 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +/** + * Stable plugin ID used by host registration and namespacing. + */ +const PLUGIN_ID = "paperclip.hello-world-example"; +const PLUGIN_VERSION = "0.1.0"; +const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget"; +const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget"; + +/** + * Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot. + */ +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: PLUGIN_VERSION, + displayName: "Hello World Widget (Example)", + description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.dashboardWidget.register"], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: DASHBOARD_WIDGET_SLOT_ID, + displayName: "Hello World", + exportName: DASHBOARD_WIDGET_EXPORT_NAME, + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx new file mode 100644 index 00000000..10e12fb0 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx @@ -0,0 +1,17 @@ +import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +const WIDGET_LABEL = "Hello world plugin widget"; + +/** + * Example dashboard widget showing the smallest possible UI contribution. + */ +export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) { + return ( +
    + Hello world +
    This widget was added by @paperclipai/plugin-hello-world-example.
    + {/* Include host context so authors can see where scoped IDs come from. */} +
    Company context: {context.companyId}
    +
    + ); +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/worker.ts b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts new file mode 100644 index 00000000..07c7fbea --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts @@ -0,0 +1,27 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const PLUGIN_NAME = "hello-world-example"; +const HEALTH_MESSAGE = "Hello World example plugin ready"; + +/** + * Worker lifecycle hooks for the Hello World reference plugin. + * This stays intentionally small so new authors can copy the shape quickly. + */ +const plugin = definePlugin({ + /** + * Called when the host starts the plugin worker. + */ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`); + }, + + /** + * Called by the host health probe endpoint. + */ + async onHealth() { + return { status: "ok", message: HEALTH_MESSAGE }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-hello-world-example/tsconfig.json b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md new file mode 100644 index 00000000..66cdebcb --- /dev/null +++ b/packages/plugins/sdk/README.md @@ -0,0 +1,959 @@ +# `@paperclipai/plugin-sdk` + +Official TypeScript SDK for Paperclip plugin authors. + +- **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle +- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks, components, slot props +- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness +- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets +- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload + +Reference: `doc/plugins/PLUGIN_SPEC.md` + +## Package surface + +| Import | Purpose | +|--------|--------| +| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers | +| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, shared components | +| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only | +| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces | +| `@paperclipai/plugin-sdk/ui/components` | `MetricCard`, `StatusBadge`, `Spinner`, `ErrorBoundary`, etc. | +| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests | +| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds | +| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` | +| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) | +| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) | + +## Manifest entrypoints + +In your plugin manifest you declare: + +- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`. +- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers. + +## Install + +```bash +pnpm add @paperclipai/plugin-sdk +``` + +## Current deployment caveats + +The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early. + +- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk. +- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime. +- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation. +- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes. + +If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience. + +## Worker quick start + +```ts +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + ctx.logger.info("Issue created", { issueId: event.entityId }); + }); + + ctx.data.register("health", async () => ({ status: "ok" })); + ctx.actions.register("ping", async () => ({ pong: true })); + + ctx.tools.register("calculator", { + displayName: "Calculator", + description: "Basic math", + parametersSchema: { + type: "object", + properties: { a: { type: "number" }, b: { type: "number" } }, + required: ["a", "b"] + } + }, async (params) => { + const { a, b } = params as { a: number; b: number }; + return { content: `Result: ${a + b}`, data: { result: a + b } }; + }); + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); +``` + +**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting. + +### Worker lifecycle and context + +**Lifecycle (definePlugin):** + +| Hook | Purpose | +|------|--------| +| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. | +| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. | +| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. | +| `onShutdown?()` | Optional. Clean up before process exit (limited time window). | +| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. | +| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. | + +**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `assets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. All host APIs are capability-gated; declare capabilities in the manifest. + +**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details. + +**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`. + +## Events + +Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`). + +**Core domain events (subscribe with `events.subscribe`):** + +| Event | Typical entity | +|-------|-----------------| +| `company.created`, `company.updated` | company | +| `project.created`, `project.updated` | project | +| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace | +| `issue.created`, `issue.updated`, `issue.comment.created` | issue | +| `agent.created`, `agent.updated`, `agent.status_changed` | agent | +| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run | +| `goal.created`, `goal.updated` | goal | +| `approval.created`, `approval.decided` | approval | +| `cost_event.created` | cost | +| `activity.logged` | activity | + +**Plugin-to-plugin:** Subscribe to `plugin..` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically. + +**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events. + +**Company-scoped delivery:** Events with a `companyId` are only delivered to plugins that are enabled for that company. If a company has disabled a plugin via settings, that plugin's handlers will not receive events belonging to that company. Events without a `companyId` are delivered to all subscribers. + +## Scheduled (recurring) jobs + +Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup. + +1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`. +2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression). +3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`. + +**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week): + +| Field | Values | Example | +|-------------|----------|---------| +| minute | 0–59 | `0`, `*/15` | +| hour | 0–23 | `2`, `*` | +| day of month | 1–31 | `1`, `*` | +| month | 1–12 | `*` | +| day of week | 0–6 (Sun=0) | `*`, `1-5` | + +Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00. + +**Job handler context** (`PluginJobContext`): + +| Field | Type | Description | +|-------------|----------|-------------| +| `jobKey` | string | Matches the manifest declaration. | +| `runId` | string | UUID for this run. | +| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. | +| `scheduledAt` | string | ISO 8601 time when the run was scheduled. | + +Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API. + +Example: + +**Manifest** — include `jobs.schedule` and declare the job: + +```ts +// In your manifest (e.g. manifest.ts): +const manifest = { + // ... + capabilities: ["jobs.schedule", "plugin.state.write"], + jobs: [ + { + jobKey: "heartbeat", + displayName: "Heartbeat", + description: "Runs every 5 minutes", + schedule: "*/5 * * * *", + }, + ], + // ... +}; +``` + +**Worker** — register the handler in `setup()`: + +```ts +ctx.jobs.register("heartbeat", async (job) => { + ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger }); + await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString()); +}); +``` + +## UI slots and launchers + +Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`). + +### Slot types / launcher placement zones + +The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy: + +| Slot type / placement zone | Scope | Entity types (when context-sensitive) | +|----------------------------|-------|---------------------------------------| +| `page` | Global | — | +| `sidebar` | Global | — | +| `sidebarPanel` | Global | — | +| `settingsPage` | Global | — | +| `dashboardWidget` | Global | — | +| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` | +| `taskDetailView` | Entity | (task/issue context) | +| `commentAnnotation` | Entity | `comment` | +| `commentContextMenuItem` | Entity | `comment` | +| `projectSidebarItem` | Entity | `project` | +| `toolbarButton` | Entity | varies by host surface | +| `contextMenuItem` | Entity | varies by host surface | + +**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue). + +**Entity types** (for `entityTypes` on slots): `project` \| `issue` \| `agent` \| `goal` \| `run` \| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@paperclipai/plugin-sdk`. + +### Slot component descriptions + +#### `page` + +A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-scoped). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability. + +#### `sidebar` + +Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability. + +#### `sidebarPanel` + +Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability. + +#### `settingsPage` + +Replaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, "Test Connection" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`). + +#### `dashboardWidget` + +A card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Paperclip information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability. + +#### `detailTab` + +An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability. + +#### `taskDetailView` + +A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability. + +#### `projectSidebarItem` + +A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin::`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability. + +#### `toolbarButton` + +A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability. + +#### `contextMenuItem` + +An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability. + +#### `commentAnnotation` + +A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability. + +#### `commentContextMenuItem` + +A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability. + +### Launcher actions and render options + +| Launcher action | Description | +|-----------------|-------------| +| `navigate` | Navigate to a route (plugin or host). | +| `openModal` | Open a modal. | +| `openDrawer` | Open a drawer. | +| `openPopover` | Open a popover. | +| `performAction` | Run an action (e.g. call plugin). | +| `deepLink` | Deep link to plugin or external URL. | + +| Render option | Values | Description | +|---------------|--------|-------------| +| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. | +| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. | + +### Capabilities + +Declare in `manifest.capabilities`. Grouped by scope: + +| Scope | Capability | +|-------|------------| +| **Company** | `companies.read` | +| | `projects.read` | +| | `project.workspaces.read` | +| | `issues.read` | +| | `issue.comments.read` | +| | `agents.read` | +| | `goals.read` | +| | `goals.create` | +| | `goals.update` | +| | `activity.read` | +| | `costs.read` | +| | `issues.create` | +| | `issues.update` | +| | `issue.comments.create` | +| | `assets.write` | +| | `assets.read` | +| | `activity.log.write` | +| | `metrics.write` | +| **Instance** | `instance.settings.register` | +| | `plugin.state.read` | +| | `plugin.state.write` | +| **Runtime** | `events.subscribe` | +| | `events.emit` | +| | `jobs.schedule` | +| | `webhooks.receive` | +| | `http.outbound` | +| | `secrets.read-ref` | +| **Agent** | `agent.tools.register` | +| | `agents.invoke` | +| | `agent.sessions.create` | +| | `agent.sessions.list` | +| | `agent.sessions.send` | +| | `agent.sessions.close` | +| **UI** | `ui.sidebar.register` | +| | `ui.page.register` | +| | `ui.detailTab.register` | +| | `ui.dashboardWidget.register` | +| | `ui.commentAnnotation.register` | +| | `ui.action.register` | + +Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`. + +## UI quick start + +```tsx +import { usePluginData, usePluginAction, MetricCard } from "@paperclipai/plugin-sdk/ui"; + +export function DashboardWidget() { + const { data } = usePluginData<{ status: string }>("health"); + const ping = usePluginAction("ping"); + return ( +
    + + +
    + ); +} +``` + +### Hooks reference + +#### `usePluginData(key, params?)` + +Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`. + +```tsx +import { usePluginData, Spinner, StatusBadge } from "@paperclipai/plugin-sdk/ui"; + +interface SyncStatus { + lastSyncAt: string; + syncedCount: number; + healthy: boolean; +} + +export function SyncStatusWidget({ context }: PluginWidgetProps) { + const { data, loading, error, refresh } = usePluginData("sync-status", { + companyId: context.companyId, + }); + + if (loading) return ; + if (error) return ; + + return ( +
    + +

    Synced {data!.syncedCount} items

    +

    Last sync: {data!.lastSyncAt}

    + +
    + ); +} +``` + +#### `usePluginAction(key)` + +Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure. + +```tsx +import { useState } from "react"; +import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui"; + +export function ResyncButton({ context }: PluginWidgetProps) { + const resync = usePluginAction("resync"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleClick() { + setBusy(true); + setError(null); + try { + await resync({ companyId: context.companyId }); + } catch (err) { + setError((err as PluginBridgeError).message); + } finally { + setBusy(false); + } + } + + return ( +
    + + {error &&

    {error}

    } +
    + ); +} +``` + +#### `useHostContext()` + +Reads the active company, project, entity, and user context. Use this to scope data fetches and actions. + +```tsx +import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui"; +import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; + +export function IssueLinearLink({ context }: PluginDetailTabProps) { + const { companyId, entityId, entityType } = context; + const { data } = usePluginData<{ url: string }>("linear-link", { + companyId, + issueId: entityId, + }); + + if (!data?.url) return

    No linked Linear issue.

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

    } onError={(err) => console.error(err)}> + +
    +``` + +### Slot component props + +Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`. + +| Slot type | Props interface | `context` extras | +|-----------|----------------|------------------| +| `page` | `PluginPageProps` | — | +| `sidebar` | `PluginSidebarProps` | — | +| `settingsPage` | `PluginSettingsPageProps` | — | +| `dashboardWidget` | `PluginWidgetProps` | — | +| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` | +| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` | +| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` | +| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` | + +Example detail tab with entity context: + +```tsx +import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui"; +import { usePluginData, KeyValueList, Spinner } from "@paperclipai/plugin-sdk/ui"; + +export function AgentMetricsTab({ context }: PluginDetailTabProps) { + const { data, loading } = usePluginData>("agent-metrics", { + agentId: context.entityId, + companyId: context.companyId, + }); + + if (loading) return ; + if (!data) return

    No metrics available.

    ; + + return ( + ({ label, value }))} + /> + ); +} +``` + +## Launcher surfaces and modals + +V1 does not provide a dedicated `modal` slot. Plugins can either: + +- declare concrete UI mount points in `ui.slots` +- declare host-rendered entry points in `ui.launchers` + +Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed. + +Declarative launcher example: + +```json +{ + "ui": { + "launchers": [ + { + "id": "sync-project", + "displayName": "Sync", + "placementZone": "toolbarButton", + "entityTypes": ["project"], + "action": { + "type": "openDrawer", + "target": "sync-project" + }, + "render": { + "environment": "hostOverlay", + "bounds": "wide" + } + } + ] + } +} +``` + +The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations. + +When a launcher opens a host-owned overlay or page, `useHostContext()`, +`usePluginData()`, and `usePluginAction()` receive the current +`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs. +full-page layouts without adding custom route parsing in the plugin. + +## Project sidebar item + +Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest: + +```json +{ + "ui": { + "slots": [ + { + "type": "projectSidebarItem", + "id": "files", + "displayName": "Files", + "exportName": "FilesLink", + "entityTypes": ["project"] + } + ] + }, + "capabilities": ["ui.sidebar.register", "ui.detailTab.register"] +} +``` + +Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec): + +```tsx +import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui"; + +export function FilesLink({ context }: PluginProjectSidebarItemProps) { + const projectId = context.entityId; + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const projectRef = projectId; // or resolve from host; entityId is project id + return ( + + Files + + ); +} +``` + +Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow. + +## Toolbar launcher with a local modal + +For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project. + +```json +{ + "ui": { + "slots": [ + { + "type": "toolbarButton", + "id": "sync-toolbar-button", + "displayName": "Sync", + "exportName": "SyncToolbarButton" + } + ] + }, + "capabilities": ["ui.action.register"] +} +``` + +```tsx +import { useState } from "react"; +import { + ErrorBoundary, + Spinner, + useHostContext, + usePluginAction, +} from "@paperclipai/plugin-sdk/ui"; + +export function SyncToolbarButton() { + const context = useHostContext(); + const syncProject = usePluginAction("sync-project"); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function confirm() { + if (!context.projectId) return; + setSubmitting(true); + setErrorMessage(null); + try { + await syncProject({ projectId: context.projectId }); + setOpen(false); + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : "Sync failed"); + } finally { + setSubmitting(false); + } + } + + return ( + + + {open ? ( +
    !submitting && setOpen(false)} + > +
    event.stopPropagation()} + > +

    Sync this project?

    +

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

    + {errorMessage ? ( +

    {errorMessage}

    + ) : null} +
    + + +
    +
    +
    + ) : null} +
    + ); +} +``` + +Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors. + +## Real-time streaming (`ctx.streams`) + +Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data. + +### Worker side + +In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done: + +```ts +const plugin = definePlugin({ + async setup(ctx) { + ctx.actions.register("chat", async (params) => { + const companyId = params.companyId as string; + ctx.streams.open("chat-stream", companyId); + + for await (const token of streamFromLLM(params.prompt as string)) { + ctx.streams.emit("chat-stream", { text: token }); + } + + ctx.streams.close("chat-stream"); + return { ok: true }; + }); + }, +}); +``` + +**API:** + +| Method | Description | +|--------|-------------| +| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. | +| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. | +| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. | + +Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution. + +### UI side + +Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI. + +### Host-side architecture + +The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients: + +1. Worker emits `streams.emit` notification via stdout +2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus` +3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response + +The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently. + +### Streaming agent responses to the UI + +`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time: + +``` +UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent +UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent +``` + +The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent. + +**Worker:** + +```ts +ctx.actions.register("ask-agent", async (params) => { + const { agentId, companyId, prompt } = params as { + agentId: string; companyId: string; prompt: string; + }; + + const channel = `agent:${agentId}`; + ctx.streams.open(channel, companyId); + + const session = await ctx.agents.sessions.create(agentId, companyId); + + await ctx.agents.sessions.sendMessage(session.sessionId, companyId, { + prompt, + onEvent: (event) => { + ctx.streams.emit(channel, { + type: event.eventType, // "chunk" | "done" | "error" + text: event.message ?? "", + }); + }, + }); + + ctx.streams.close(channel); + return { sessionId: session.sessionId }; +}); +``` + +**UI:** + +```tsx +import { useState } from "react"; +import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui"; + +interface AgentEvent { + type: "chunk" | "done" | "error"; + text: string; +} + +export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) { + const askAgent = usePluginAction("ask-agent"); + const { events, connected, close } = usePluginStream(`agent:${agentId}`, { companyId }); + const [prompt, setPrompt] = useState(""); + + async function send() { + setPrompt(""); + await askAgent({ agentId, companyId, prompt }); + } + + return ( +
    +
    {events.filter(e => e.type === "chunk").map((e, i) => {e.text})}
    + setPrompt(e.target.value)} /> + + {connected && } +
    + ); +} +``` + +## Agent sessions (two-way chat) + +Plugins can hold multi-turn conversational sessions with agents: + +```ts +// Create a session +const session = await ctx.agents.sessions.create(agentId, companyId); + +// Send a message and stream the response +await ctx.agents.sessions.sendMessage(session.sessionId, companyId, { + prompt: "Help me triage this issue", + onEvent: (event) => { + if (event.eventType === "chunk") console.log(event.message); + if (event.eventType === "done") console.log("Stream complete"); + }, +}); + +// List active sessions +const sessions = await ctx.agents.sessions.list(agentId, companyId); + +// Close when done +await ctx.agents.sessions.close(session.sessionId, companyId); +``` + +Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`. + +Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`. + +## Testing utilities + +```ts +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import plugin from "../src/worker.js"; +import manifest from "../src/manifest.js"; + +const harness = createTestHarness({ manifest }); +await plugin.definition.setup(harness.ctx); +await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); +``` + +## Bundler presets + +```ts +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui +// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui +``` + +## Local dev server (hot-reload events) + +```bash +paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177 +``` + +Or programmatically: + +```ts +import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server"; +const server = await startPluginDevServer({ rootDir: process.cwd() }); +``` + +Dev server endpoints: +- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }` +- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes diff --git a/packages/plugins/sdk/package.json b/packages/plugins/sdk/package.json new file mode 100644 index 00000000..d5e5c19c --- /dev/null +++ b/packages/plugins/sdk/package.json @@ -0,0 +1,124 @@ +{ + "name": "@paperclipai/plugin-sdk", + "version": "1.0.0", + "description": "Stable public API for Paperclip plugins — worker-side context and UI bridge hooks", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./protocol": { + "types": "./dist/protocol.d.ts", + "import": "./dist/protocol.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./ui/hooks": { + "types": "./dist/ui/hooks.d.ts", + "import": "./dist/ui/hooks.js" + }, + "./ui/types": { + "types": "./dist/ui/types.d.ts", + "import": "./dist/ui/types.js" + }, + "./ui/components": { + "types": "./dist/ui/components.d.ts", + "import": "./dist/ui/components.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + }, + "./bundlers": { + "types": "./dist/bundlers.d.ts", + "import": "./dist/bundlers.js" + }, + "./dev-server": { + "types": "./dist/dev-server.d.ts", + "import": "./dist/dev-server.js" + } + }, + "bin": { + "paperclip-plugin-dev-server": "./dist/dev-cli.js" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./protocol": { + "types": "./dist/protocol.d.ts", + "import": "./dist/protocol.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./ui/hooks": { + "types": "./dist/ui/hooks.d.ts", + "import": "./dist/ui/hooks.js" + }, + "./ui/types": { + "types": "./dist/ui/types.d.ts", + "import": "./dist/ui/types.js" + }, + "./ui/components": { + "types": "./dist/ui/components.d.ts", + "import": "./dist/ui/components.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "import": "./dist/testing.js" + }, + "./bundlers": { + "types": "./dist/bundlers.d.ts", + "import": "./dist/bundlers.js" + }, + "./dev-server": { + "types": "./dist/dev-server.d.ts", + "import": "./dist/dev-server.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm --filter @paperclipai/shared build && tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit", + "dev:server": "tsx src/dev-cli.ts" + }, + "dependencies": { + "@paperclipai/shared": "workspace:*", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/packages/plugins/sdk/src/bundlers.ts b/packages/plugins/sdk/src/bundlers.ts new file mode 100644 index 00000000..a8ec6850 --- /dev/null +++ b/packages/plugins/sdk/src/bundlers.ts @@ -0,0 +1,161 @@ +/** + * Bundling presets for Paperclip plugins. + * + * These helpers return plain config objects so plugin authors can use them + * with esbuild or rollup without re-implementing host contract defaults. + */ + +export interface PluginBundlerPresetInput { + pluginRoot?: string; + manifestEntry?: string; + workerEntry?: string; + uiEntry?: string; + outdir?: string; + sourcemap?: boolean; + minify?: boolean; +} + +export interface EsbuildLikeOptions { + entryPoints: string[]; + outdir: string; + bundle: boolean; + format: "esm"; + platform: "node" | "browser"; + target: string; + sourcemap?: boolean; + minify?: boolean; + external?: string[]; +} + +export interface RollupLikeConfig { + input: string; + output: { + dir: string; + format: "es"; + sourcemap?: boolean; + entryFileNames?: string; + }; + external?: string[]; + plugins?: unknown[]; +} + +export interface PluginBundlerPresets { + esbuild: { + worker: EsbuildLikeOptions; + ui?: EsbuildLikeOptions; + manifest: EsbuildLikeOptions; + }; + rollup: { + worker: RollupLikeConfig; + ui?: RollupLikeConfig; + manifest: RollupLikeConfig; + }; +} + +/** + * Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles. + * + * The presets intentionally externalize host/runtime deps (`react`, SDK packages) + * to match the Paperclip plugin loader contract. + */ +export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets { + const uiExternal = [ + "@paperclipai/plugin-sdk/ui", + "@paperclipai/plugin-sdk/ui/hooks", + "@paperclipai/plugin-sdk/ui/components", + "react", + "react-dom", + "react/jsx-runtime", + ]; + + const outdir = input.outdir ?? "dist"; + const workerEntry = input.workerEntry ?? "src/worker.ts"; + const manifestEntry = input.manifestEntry ?? "src/manifest.ts"; + const uiEntry = input.uiEntry; + const sourcemap = input.sourcemap ?? true; + const minify = input.minify ?? false; + + const esbuildWorker: EsbuildLikeOptions = { + entryPoints: [workerEntry], + outdir, + bundle: true, + format: "esm", + platform: "node", + target: "node20", + sourcemap, + minify, + external: ["@paperclipai/plugin-sdk", "@paperclipai/plugin-sdk/ui", "react", "react-dom"], + }; + + const esbuildManifest: EsbuildLikeOptions = { + entryPoints: [manifestEntry], + outdir, + bundle: false, + format: "esm", + platform: "node", + target: "node20", + sourcemap, + }; + + const esbuildUi = uiEntry + ? { + entryPoints: [uiEntry], + outdir: `${outdir}/ui`, + bundle: true, + format: "esm" as const, + platform: "browser" as const, + target: "es2022", + sourcemap, + minify, + external: uiExternal, + } + : undefined; + + const rollupWorker: RollupLikeConfig = { + input: workerEntry, + output: { + dir: outdir, + format: "es", + sourcemap, + entryFileNames: "worker.js", + }, + external: ["@paperclipai/plugin-sdk", "react", "react-dom"], + }; + + const rollupManifest: RollupLikeConfig = { + input: manifestEntry, + output: { + dir: outdir, + format: "es", + sourcemap, + entryFileNames: "manifest.js", + }, + external: ["@paperclipai/plugin-sdk"], + }; + + const rollupUi = uiEntry + ? { + input: uiEntry, + output: { + dir: `${outdir}/ui`, + format: "es" as const, + sourcemap, + entryFileNames: "index.js", + }, + external: uiExternal, + } + : undefined; + + return { + esbuild: { + worker: esbuildWorker, + manifest: esbuildManifest, + ...(esbuildUi ? { ui: esbuildUi } : {}), + }, + rollup: { + worker: rollupWorker, + manifest: rollupManifest, + ...(rollupUi ? { ui: rollupUi } : {}), + }, + }; +} diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts new file mode 100644 index 00000000..43fefdd2 --- /dev/null +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -0,0 +1,255 @@ +/** + * `definePlugin` — the top-level helper for authoring a Paperclip plugin. + * + * Plugin authors call `definePlugin()` and export the result as the default + * export from their worker entrypoint. The host imports the worker module, + * calls `setup()` with a `PluginContext`, and from that point the plugin + * responds to events, jobs, webhooks, and UI requests through the context. + * + * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape + * + * @example + * ```ts + * // dist/worker.ts + * import { definePlugin } from "@paperclipai/plugin-sdk"; + * + * export default definePlugin({ + * async setup(ctx) { + * ctx.logger.info("Linear sync plugin starting"); + * + * // Subscribe to events + * ctx.events.on("issue.created", async (event) => { + * const config = await ctx.config.get(); + * await ctx.http.fetch(`https://api.linear.app/...`, { + * method: "POST", + * headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` }, + * body: JSON.stringify({ title: event.payload.title }), + * }); + * }); + * + * // Register a job handler + * ctx.jobs.register("full-sync", async (job) => { + * ctx.logger.info("Running full-sync job", { runId: job.runId }); + * // ... sync logic + * }); + * + * // Register data for the UI + * ctx.data.register("sync-health", async ({ companyId }) => { + * const state = await ctx.state.get({ + * scopeKind: "company", + * scopeId: String(companyId), + * stateKey: "last-sync", + * }); + * return { lastSync: state }; + * }); + * }, + * }); + * ``` + */ + +import type { PluginContext } from "./types.js"; + +// --------------------------------------------------------------------------- +// Health check result +// --------------------------------------------------------------------------- + +/** + * Optional plugin-reported diagnostics returned from the `health()` RPC method. + * + * @see PLUGIN_SPEC.md §13.2 — `health` + */ +export interface PluginHealthDiagnostics { + /** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */ + status: "ok" | "degraded" | "error"; + /** Human-readable description of the current health state. */ + message?: string; + /** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */ + details?: Record; +} + +// --------------------------------------------------------------------------- +// Config validation result +// --------------------------------------------------------------------------- + +/** + * Result returned from the `validateConfig()` RPC method. + * + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + */ +export interface PluginConfigValidationResult { + /** Whether the config is valid. */ + ok: boolean; + /** Non-fatal warnings about the config. */ + warnings?: string[]; + /** Validation errors (populated when `ok` is `false`). */ + errors?: string[]; +} + +// --------------------------------------------------------------------------- +// Webhook handler input +// --------------------------------------------------------------------------- + +/** + * Input received by the plugin worker's `handleWebhook` handler. + * + * @see PLUGIN_SPEC.md §13.7 — `handleWebhook` + */ +export interface PluginWebhookInput { + /** Endpoint key matching the manifest declaration. */ + endpointKey: string; + /** Inbound request headers. */ + headers: Record; + /** Raw request body as a UTF-8 string. */ + rawBody: string; + /** Parsed JSON body (if applicable and parseable). */ + parsedBody?: unknown; + /** Unique request identifier for idempotency checks. */ + requestId: string; +} + +// --------------------------------------------------------------------------- +// Plugin definition +// --------------------------------------------------------------------------- + +/** + * The plugin definition shape passed to `definePlugin()`. + * + * The only required field is `setup`, which receives the `PluginContext` and + * is where the plugin registers its handlers (events, jobs, data, actions, + * tools, etc.). + * + * All other lifecycle hooks are optional. If a hook is not implemented the + * host applies default behaviour (e.g. restarting the worker on config change + * instead of calling `onConfigChanged`). + * + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + */ +export interface PluginDefinition { + /** + * Called once when the plugin worker starts up, after `initialize` completes. + * + * This is where the plugin registers all its handlers: event subscriptions, + * job handlers, data/action handlers, and tool registrations. Registration + * must be synchronous after `setup` resolves — do not register handlers + * inside async callbacks that may resolve after `setup` returns. + * + * @param ctx - The full plugin context provided by the host + */ + setup(ctx: PluginContext): Promise; + + /** + * Called when the host wants to know if the plugin is healthy. + * + * The host polls this on a regular interval and surfaces the result in the + * plugin health dashboard. If not implemented, the host infers health from + * worker process liveness. + * + * @see PLUGIN_SPEC.md §13.2 — `health` + */ + onHealth?(): Promise; + + /** + * Called when the operator updates the plugin's instance configuration at + * runtime, without restarting the worker. + * + * If not implemented, the host restarts the worker to apply the new config. + * + * @param newConfig - The newly resolved configuration + * @see PLUGIN_SPEC.md §13.4 — `configChanged` + */ + onConfigChanged?(newConfig: Record): Promise; + + /** + * Called when the host is about to shut down the plugin worker. + * + * The worker has at most 10 seconds (configurable via plugin config) to + * finish in-flight work and resolve this promise. After the deadline the + * host sends SIGTERM, then SIGKILL. + * + * @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy + */ + onShutdown?(): Promise; + + /** + * Called to validate the current plugin configuration. + * + * The host calls this: + * - after the plugin starts (to surface config errors immediately) + * - after the operator saves a new config (to validate before persisting) + * - via the "Test Connection" button in the settings UI + * + * @param config - The configuration to validate + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + */ + onValidateConfig?(config: Record): Promise; + + /** + * Called to handle an inbound webhook delivery. + * + * The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to + * this handler. The plugin is responsible for signature verification using + * a resolved secret ref. + * + * If not implemented but webhooks are declared in the manifest, the host + * returns HTTP 501 for webhook deliveries. + * + * @param input - Webhook delivery metadata and payload + * @see PLUGIN_SPEC.md §13.7 — `handleWebhook` + */ + onWebhook?(input: PluginWebhookInput): Promise; +} + +// --------------------------------------------------------------------------- +// PaperclipPlugin — the sealed object returned by definePlugin() +// --------------------------------------------------------------------------- + +/** + * The sealed plugin object returned by `definePlugin()`. + * + * Plugin authors export this as the default export from their worker + * entrypoint. The host imports it and calls the lifecycle methods. + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ +export interface PaperclipPlugin { + /** The original plugin definition passed to `definePlugin()`. */ + readonly definition: PluginDefinition; +} + +// --------------------------------------------------------------------------- +// definePlugin — top-level factory +// --------------------------------------------------------------------------- + +/** + * Define a Paperclip plugin. + * + * Call this function in your worker entrypoint and export the result as the + * default export. The host will import the module and call lifecycle methods + * on the returned object. + * + * @param definition - Plugin lifecycle handlers + * @returns A sealed `PaperclipPlugin` object for the host to consume + * + * @example + * ```ts + * import { definePlugin } from "@paperclipai/plugin-sdk"; + * + * export default definePlugin({ + * async setup(ctx) { + * ctx.logger.info("Plugin started"); + * ctx.events.on("issue.created", async (event) => { + * // handle event + * }); + * }, + * + * async onHealth() { + * return { status: "ok" }; + * }, + * }); + * ``` + * + * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape + */ +export function definePlugin(definition: PluginDefinition): PaperclipPlugin { + return Object.freeze({ definition }); +} diff --git a/packages/plugins/sdk/src/dev-cli.ts b/packages/plugins/sdk/src/dev-cli.ts new file mode 100644 index 00000000..7c1b0e2b --- /dev/null +++ b/packages/plugins/sdk/src/dev-cli.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import path from "node:path"; +import { startPluginDevServer } from "./dev-server.js"; + +function parseArg(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + if (index < 0) return undefined; + return process.argv[index + 1]; +} + +/** + * CLI entrypoint for the local plugin UI preview server. + * + * This is intentionally minimal and delegates all serving behavior to + * `startPluginDevServer` so tests and programmatic usage share one path. + */ +async function main() { + const rootDir = parseArg("--root") ?? process.cwd(); + const uiDir = parseArg("--ui-dir") ?? "dist/ui"; + const host = parseArg("--host") ?? "127.0.0.1"; + const rawPort = parseArg("--port") ?? "4177"; + const port = Number.parseInt(rawPort, 10); + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid --port value: ${rawPort}`); + } + + const server = await startPluginDevServer({ + rootDir: path.resolve(rootDir), + uiDir, + host, + port, + }); + + // eslint-disable-next-line no-console + console.log(`Paperclip plugin dev server listening at ${server.url}`); + + const shutdown = async () => { + await server.close(); + process.exit(0); + }; + + process.on("SIGINT", () => { + void shutdown(); + }); + process.on("SIGTERM", () => { + void shutdown(); + }); +} + +void main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/packages/plugins/sdk/src/dev-server.ts b/packages/plugins/sdk/src/dev-server.ts new file mode 100644 index 00000000..2eadff81 --- /dev/null +++ b/packages/plugins/sdk/src/dev-server.ts @@ -0,0 +1,228 @@ +import { createReadStream, existsSync, statSync, watch } from "node:fs"; +import { mkdir, readdir, stat } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; + +export interface PluginDevServerOptions { + /** Plugin project root. Defaults to `process.cwd()`. */ + rootDir?: string; + /** Relative path from root to built UI assets. Defaults to `dist/ui`. */ + uiDir?: string; + /** Bind port for local preview server. Defaults to `4177`. */ + port?: number; + /** Bind host. Defaults to `127.0.0.1`. */ + host?: string; +} + +export interface PluginDevServer { + url: string; + close(): Promise; +} + +interface Closeable { + close(): void; +} + +function contentType(filePath: string): string { + if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; + if (filePath.endsWith(".json")) return "application/json; charset=utf-8"; + if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; + if (filePath.endsWith(".svg")) return "image/svg+xml"; + return "application/octet-stream"; +} + +function normalizeFilePath(baseDir: string, reqPath: string): string { + const pathname = reqPath.split("?")[0] || "/"; + const resolved = pathname === "/" ? "/index.js" : pathname; + const absolute = path.resolve(baseDir, `.${resolved}`); + const normalizedBase = `${path.resolve(baseDir)}${path.sep}`; + if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) { + throw new Error("path traversal blocked"); + } + return absolute; +} + +function send404(res: ServerResponse) { + res.statusCode = 404; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ error: "Not found" })); +} + +function sendJson(res: ServerResponse, value: unknown) { + res.statusCode = 200; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(value)); +} + +async function ensureUiDir(uiDir: string): Promise { + if (existsSync(uiDir)) return; + await mkdir(uiDir, { recursive: true }); +} + +async function listFilesRecursive(dir: string): Promise { + const out: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...await listFilesRecursive(abs)); + } else if (entry.isFile()) { + out.push(abs); + } + } + return out; +} + +function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string { + return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|"); +} + +async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise { + try { + // macOS/Windows support recursive native watching. + const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => { + if (!filename) return; + onReload(path.join(uiDir, filename)); + }); + return watcher; + } catch { + // Linux may reject recursive watch. Fall back to polling snapshots. + let previous = snapshotSignature( + (await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({ + file: row.file, + mtimeMs: row.mtimeMs, + })), + ); + + const timer = setInterval(async () => { + try { + const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir)); + const next = snapshotSignature(nextRows); + if (next === previous) return; + previous = next; + onReload("__snapshot__"); + } catch { + // Ignore transient read errors while bundlers are writing files. + } + }, 500); + + return { + close() { + clearInterval(timer); + }, + }; + } +} + +/** + * Start a local static server for plugin UI assets with SSE reload events. + * + * Endpoint summary: + * - `GET /__paperclip__/health` for diagnostics + * - `GET /__paperclip__/events` for hot-reload stream + * - Any other path serves files from the configured UI build directory + */ +export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise { + const rootDir = path.resolve(options.rootDir ?? process.cwd()); + const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui"); + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 4177; + + await ensureUiDir(uiDir); + + const sseClients = new Set(); + + const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { + const url = req.url ?? "/"; + + if (url === "/__paperclip__/health") { + sendJson(res, { ok: true, rootDir, uiDir }); + return; + } + + if (url === "/__paperclip__/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + res.write(`event: connected\ndata: {"ok":true}\n\n`); + sseClients.add(res); + req.on("close", () => { + sseClients.delete(res); + }); + return; + } + + try { + const filePath = normalizeFilePath(uiDir, url); + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + send404(res); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", contentType(filePath)); + createReadStream(filePath).pipe(res); + } catch { + send404(res); + } + }; + + const server = createServer((req, res) => { + void handleRequest(req, res); + }); + + const notifyReload = (filePath: string) => { + const rel = path.relative(uiDir, filePath); + const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() }); + for (const client of sseClients) { + client.write(`event: reload\ndata: ${payload}\n\n`); + } + }; + + const watcher = await startUiWatcher(uiDir, notifyReload); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => resolve()); + }); + + const address = server.address(); + const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port; + + return { + url: `http://${host}:${actualPort}`, + async close() { + watcher.close(); + for (const client of sseClients) { + client.end(); + } + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +} + +/** + * Return a stable file+mtime snapshot for a built plugin UI directory. + * + * Used by the polling watcher fallback and useful for tests that need to assert + * whether a UI build has changed between runs. + */ +export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise> { + const baseDir = path.resolve(rootDir, uiDir); + if (!existsSync(baseDir)) return []; + const files = await listFilesRecursive(baseDir); + const rows = await Promise.all(files.map(async (filePath) => { + const fileStat = await stat(filePath); + return { + file: path.relative(baseDir, filePath), + mtimeMs: fileStat.mtimeMs, + }; + })); + return rows.sort((a, b) => a.file.localeCompare(b.file)); +} diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts new file mode 100644 index 00000000..82d2b42f --- /dev/null +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -0,0 +1,563 @@ +/** + * Host-side client factory — creates capability-gated handler maps for + * servicing worker→host JSON-RPC calls. + * + * When a plugin worker calls `ctx.state.get(...)` inside its process, the + * SDK serializes the call as a JSON-RPC request over stdio. On the host side, + * the `PluginWorkerManager` receives the request and dispatches it to the + * handler registered for that method. This module provides a factory that + * creates those handlers for all `WorkerToHostMethods`, with automatic + * capability enforcement. + * + * ## Design + * + * 1. **Capability gating**: Each handler checks the plugin's declared + * capabilities before executing. If the plugin lacks a required capability, + * the handler throws a `CapabilityDeniedError` (which the worker manager + * translates into a JSON-RPC error response with code + * `CAPABILITY_DENIED`). + * + * 2. **Service adapters**: The caller provides a `HostServices` object with + * concrete implementations of each platform service. The factory wires + * each handler to the appropriate service method. + * + * 3. **Type safety**: The returned handler map is typed as + * `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs + * directly into `WorkerStartOptions.hostHandlers`. + * + * @example + * ```ts + * const handlers = createHostClientHandlers({ + * pluginId: "acme.linear", + * capabilities: manifest.capabilities, + * services: { + * config: { get: () => registry.getConfig(pluginId) }, + * state: { get: ..., set: ..., delete: ... }, + * entities: { upsert: ..., list: ... }, + * // ... all services + * }, + * }); + * + * await workerManager.startWorker("acme.linear", { + * // ... + * hostHandlers: handlers, + * }); + * ``` + * + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + * @see PLUGIN_SPEC.md §15 — Capability Model + */ + +import type { PluginCapability } from "@paperclipai/shared"; +import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js"; +import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +/** + * Thrown when a plugin calls a host method it does not have the capability for. + * + * The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so + * the worker manager can propagate it as the correct JSON-RPC error code. + */ +export class CapabilityDeniedError extends Error { + override readonly name = "CapabilityDeniedError"; + readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED; + + constructor(pluginId: string, method: string, capability: PluginCapability) { + super( + `Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`, + ); + } +} + +// --------------------------------------------------------------------------- +// Host service interfaces +// --------------------------------------------------------------------------- + +/** + * Service adapters that the host must provide. Each property maps to a group + * of `WorkerToHostMethods`. The factory wires JSON-RPC params to these + * function signatures. + * + * All methods return promises to support async I/O (database, HTTP, etc.). + */ +export interface HostServices { + /** Provides `config.get`. */ + config: { + get(): Promise>; + }; + + /** Provides `state.get`, `state.set`, `state.delete`. */ + state: { + get(params: WorkerToHostMethods["state.get"][0]): Promise; + set(params: WorkerToHostMethods["state.set"][0]): Promise; + delete(params: WorkerToHostMethods["state.delete"][0]): Promise; + }; + + /** Provides `entities.upsert`, `entities.list`. */ + entities: { + upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise; + list(params: WorkerToHostMethods["entities.list"][0]): Promise; + }; + + /** Provides `events.emit`. */ + events: { + emit(params: WorkerToHostMethods["events.emit"][0]): Promise; + }; + + /** Provides `http.fetch`. */ + http: { + fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise; + }; + + /** Provides `secrets.resolve`. */ + secrets: { + resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise; + }; + + /** Provides `assets.upload`, `assets.getUrl`. */ + assets: { + upload(params: WorkerToHostMethods["assets.upload"][0]): Promise; + getUrl(params: WorkerToHostMethods["assets.getUrl"][0]): Promise; + }; + + /** Provides `activity.log`. */ + activity: { + log(params: { + companyId: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; + }): Promise; + }; + + /** Provides `metrics.write`. */ + metrics: { + write(params: WorkerToHostMethods["metrics.write"][0]): Promise; + }; + + /** Provides `log`. */ + logger: { + log(params: WorkerToHostMethods["log"][0]): Promise; + }; + + /** Provides `companies.list`, `companies.get`. */ + companies: { + list(params: WorkerToHostMethods["companies.list"][0]): Promise; + get(params: WorkerToHostMethods["companies.get"][0]): Promise; + }; + + /** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */ + projects: { + list(params: WorkerToHostMethods["projects.list"][0]): Promise; + get(params: WorkerToHostMethods["projects.get"][0]): Promise; + listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise; + getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise; + getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise; + }; + + /** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */ + issues: { + list(params: WorkerToHostMethods["issues.list"][0]): Promise; + get(params: WorkerToHostMethods["issues.get"][0]): Promise; + create(params: WorkerToHostMethods["issues.create"][0]): Promise; + update(params: WorkerToHostMethods["issues.update"][0]): Promise; + listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise; + createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise; + }; + + /** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */ + agents: { + list(params: WorkerToHostMethods["agents.list"][0]): Promise; + get(params: WorkerToHostMethods["agents.get"][0]): Promise; + pause(params: WorkerToHostMethods["agents.pause"][0]): Promise; + resume(params: WorkerToHostMethods["agents.resume"][0]): Promise; + invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise; + }; + + /** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */ + agentSessions: { + create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise; + list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise; + sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise; + close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise; + }; + + /** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */ + goals: { + list(params: WorkerToHostMethods["goals.list"][0]): Promise; + get(params: WorkerToHostMethods["goals.get"][0]): Promise; + create(params: WorkerToHostMethods["goals.create"][0]): Promise; + update(params: WorkerToHostMethods["goals.update"][0]): Promise; + }; +} + +// --------------------------------------------------------------------------- +// Factory input +// --------------------------------------------------------------------------- + +/** + * Options for `createHostClientHandlers`. + */ +export interface HostClientFactoryOptions { + /** The plugin ID. Used for error messages and logging. */ + pluginId: string; + + /** + * The capabilities declared by the plugin in its manifest. The factory + * enforces these at runtime before delegating to the service adapter. + */ + capabilities: readonly PluginCapability[]; + + /** + * Concrete implementations of host platform services. Each handler in the + * returned map delegates to the corresponding service method. + */ + services: HostServices; +} + +// --------------------------------------------------------------------------- +// Handler map type (compatible with WorkerToHostHandlers from worker manager) +// --------------------------------------------------------------------------- + +/** + * A handler function for a specific worker→host method. + */ +type HostHandler = ( + params: WorkerToHostMethods[M][0], +) => Promise; + +/** + * A complete map of all worker→host method handlers. + * + * This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts` + * but makes every handler required (the factory always provides all handlers). + */ +export type HostClientHandlers = { + [M in WorkerToHostMethodName]: HostHandler; +}; + +// --------------------------------------------------------------------------- +// Capability → method mapping +// --------------------------------------------------------------------------- + +/** + * Maps each worker→host RPC method to the capability required to invoke it. + * Methods without a capability requirement (e.g. `config.get`, `log`) are + * mapped to `null`. + * + * @see PLUGIN_SPEC.md §15 — Capability Model + */ +const METHOD_CAPABILITY_MAP: Record = { + // Config — always allowed + "config.get": null, + + // State + "state.get": "plugin.state.read", + "state.set": "plugin.state.write", + "state.delete": "plugin.state.write", + + // Entities — no specific capability required (plugin-scoped by design) + "entities.upsert": null, + "entities.list": null, + + // Events + "events.emit": "events.emit", + + // HTTP + "http.fetch": "http.outbound", + + // Secrets + "secrets.resolve": "secrets.read-ref", + + // Assets + "assets.upload": "assets.write", + "assets.getUrl": "assets.read", + + // Activity + "activity.log": "activity.log.write", + + // Metrics + "metrics.write": "metrics.write", + + // Logger — always allowed + "log": null, + + // Companies + "companies.list": "companies.read", + "companies.get": "companies.read", + + // Projects + "projects.list": "projects.read", + "projects.get": "projects.read", + "projects.listWorkspaces": "project.workspaces.read", + "projects.getPrimaryWorkspace": "project.workspaces.read", + "projects.getWorkspaceForIssue": "project.workspaces.read", + + // Issues + "issues.list": "issues.read", + "issues.get": "issues.read", + "issues.create": "issues.create", + "issues.update": "issues.update", + "issues.listComments": "issue.comments.read", + "issues.createComment": "issue.comments.create", + + // Agents + "agents.list": "agents.read", + "agents.get": "agents.read", + "agents.pause": "agents.pause", + "agents.resume": "agents.resume", + "agents.invoke": "agents.invoke", + + // Agent Sessions + "agents.sessions.create": "agent.sessions.create", + "agents.sessions.list": "agent.sessions.list", + "agents.sessions.sendMessage": "agent.sessions.send", + "agents.sessions.close": "agent.sessions.close", + + // Goals + "goals.list": "goals.read", + "goals.get": "goals.read", + "goals.create": "goals.create", + "goals.update": "goals.update", +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a complete handler map for all worker→host JSON-RPC methods. + * + * Each handler: + * 1. Checks the plugin's declared capabilities against the required capability + * for the method (if any). + * 2. Delegates to the corresponding service adapter method. + * 3. Returns the service result, which is serialized as the JSON-RPC response + * by the worker manager. + * + * If a capability check fails, the handler throws a `CapabilityDeniedError` + * with code `CAPABILITY_DENIED`. The worker manager catches this and sends a + * JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError` + * in the plugin's SDK client. + * + * @param options - Plugin ID, capabilities, and service adapters + * @returns A handler map suitable for `WorkerStartOptions.hostHandlers` + */ +export function createHostClientHandlers( + options: HostClientFactoryOptions, +): HostClientHandlers { + const { pluginId, services } = options; + const capabilitySet = new Set(options.capabilities); + + /** + * Assert that the plugin has the required capability for a method. + * Throws `CapabilityDeniedError` if the capability is missing. + */ + function requireCapability( + method: WorkerToHostMethodName, + ): void { + const required = METHOD_CAPABILITY_MAP[method]; + if (required === null) return; // No capability required + if (capabilitySet.has(required)) return; + throw new CapabilityDeniedError(pluginId, method, required); + } + + /** + * Create a capability-gated proxy handler for a method. + * + * @param method - The RPC method name (used for capability lookup) + * @param handler - The actual handler implementation + * @returns A wrapper that checks capabilities before delegating + */ + function gated( + method: M, + handler: HostHandler, + ): HostHandler { + return async (params: WorkerToHostMethods[M][0]) => { + requireCapability(method); + return handler(params); + }; + } + + // ------------------------------------------------------------------------- + // Build the complete handler map + // ------------------------------------------------------------------------- + + return { + // Config + "config.get": gated("config.get", async () => { + return services.config.get(); + }), + + // State + "state.get": gated("state.get", async (params) => { + return services.state.get(params); + }), + "state.set": gated("state.set", async (params) => { + return services.state.set(params); + }), + "state.delete": gated("state.delete", async (params) => { + return services.state.delete(params); + }), + + // Entities + "entities.upsert": gated("entities.upsert", async (params) => { + return services.entities.upsert(params); + }), + "entities.list": gated("entities.list", async (params) => { + return services.entities.list(params); + }), + + // Events + "events.emit": gated("events.emit", async (params) => { + return services.events.emit(params); + }), + + // HTTP + "http.fetch": gated("http.fetch", async (params) => { + return services.http.fetch(params); + }), + + // Secrets + "secrets.resolve": gated("secrets.resolve", async (params) => { + return services.secrets.resolve(params); + }), + + // Assets + "assets.upload": gated("assets.upload", async (params) => { + return services.assets.upload(params); + }), + "assets.getUrl": gated("assets.getUrl", async (params) => { + return services.assets.getUrl(params); + }), + + // Activity + "activity.log": gated("activity.log", async (params) => { + return services.activity.log(params); + }), + + // Metrics + "metrics.write": gated("metrics.write", async (params) => { + return services.metrics.write(params); + }), + + // Logger + "log": gated("log", async (params) => { + return services.logger.log(params); + }), + + // Companies + "companies.list": gated("companies.list", async (params) => { + return services.companies.list(params); + }), + "companies.get": gated("companies.get", async (params) => { + return services.companies.get(params); + }), + + // Projects + "projects.list": gated("projects.list", async (params) => { + return services.projects.list(params); + }), + "projects.get": gated("projects.get", async (params) => { + return services.projects.get(params); + }), + "projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => { + return services.projects.listWorkspaces(params); + }), + "projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => { + return services.projects.getPrimaryWorkspace(params); + }), + "projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => { + return services.projects.getWorkspaceForIssue(params); + }), + + // Issues + "issues.list": gated("issues.list", async (params) => { + return services.issues.list(params); + }), + "issues.get": gated("issues.get", async (params) => { + return services.issues.get(params); + }), + "issues.create": gated("issues.create", async (params) => { + return services.issues.create(params); + }), + "issues.update": gated("issues.update", async (params) => { + return services.issues.update(params); + }), + "issues.listComments": gated("issues.listComments", async (params) => { + return services.issues.listComments(params); + }), + "issues.createComment": gated("issues.createComment", async (params) => { + return services.issues.createComment(params); + }), + + // Agents + "agents.list": gated("agents.list", async (params) => { + return services.agents.list(params); + }), + "agents.get": gated("agents.get", async (params) => { + return services.agents.get(params); + }), + "agents.pause": gated("agents.pause", async (params) => { + return services.agents.pause(params); + }), + "agents.resume": gated("agents.resume", async (params) => { + return services.agents.resume(params); + }), + "agents.invoke": gated("agents.invoke", async (params) => { + return services.agents.invoke(params); + }), + + // Agent Sessions + "agents.sessions.create": gated("agents.sessions.create", async (params) => { + return services.agentSessions.create(params); + }), + "agents.sessions.list": gated("agents.sessions.list", async (params) => { + return services.agentSessions.list(params); + }), + "agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => { + return services.agentSessions.sendMessage(params); + }), + "agents.sessions.close": gated("agents.sessions.close", async (params) => { + return services.agentSessions.close(params); + }), + + // Goals + "goals.list": gated("goals.list", async (params) => { + return services.goals.list(params); + }), + "goals.get": gated("goals.get", async (params) => { + return services.goals.get(params); + }), + "goals.create": gated("goals.create", async (params) => { + return services.goals.create(params); + }), + "goals.update": gated("goals.update", async (params) => { + return services.goals.update(params); + }), + }; +} + +// --------------------------------------------------------------------------- +// Utility: getRequiredCapability +// --------------------------------------------------------------------------- + +/** + * Get the capability required for a given worker→host method, or `null` if + * no capability is required. + * + * Useful for inspecting capability requirements without calling the factory. + * + * @param method - The worker→host method name + * @returns The required capability, or `null` + */ +export function getRequiredCapability( + method: WorkerToHostMethodName, +): PluginCapability | null { + return METHOD_CAPABILITY_MAP[method]; +} diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts new file mode 100644 index 00000000..60c59f6a --- /dev/null +++ b/packages/plugins/sdk/src/index.ts @@ -0,0 +1,287 @@ +/** + * `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK. + * + * This is the main entrypoint for plugin worker code. For plugin UI bundles, + * import from `@paperclipai/plugin-sdk/ui` instead. + * + * @example + * ```ts + * // Plugin worker entrypoint (dist/worker.ts) + * import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk"; + * + * const plugin = definePlugin({ + * async setup(ctx) { + * ctx.logger.info("Plugin starting up"); + * + * ctx.events.on("issue.created", async (event) => { + * ctx.logger.info("Issue created", { issueId: event.entityId }); + * }); + * + * ctx.jobs.register("full-sync", async (job) => { + * ctx.logger.info("Starting full sync", { runId: job.runId }); + * // ... sync implementation + * }); + * + * ctx.data.register("sync-health", async ({ companyId }) => { + * const state = await ctx.state.get({ + * scopeKind: "company", + * scopeId: String(companyId), + * stateKey: "last-sync-at", + * }); + * return { lastSync: state }; + * }); + * }, + * + * async onHealth() { + * return { status: "ok" }; + * }, + * }); + * + * export default plugin; + * runWorker(plugin, import.meta.url); + * ``` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + * @see PLUGIN_SPEC.md §29.2 — SDK Versioning + */ + +// --------------------------------------------------------------------------- +// Main factory +// --------------------------------------------------------------------------- + +export { definePlugin } from "./define-plugin.js"; +export { createTestHarness } from "./testing.js"; +export { createPluginBundlerPresets } from "./bundlers.js"; +export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js"; +export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js"; +export { + createHostClientHandlers, + getRequiredCapability, + CapabilityDeniedError, +} from "./host-client-factory.js"; + +// JSON-RPC protocol helpers and constants +export { + JSONRPC_VERSION, + JSONRPC_ERROR_CODES, + PLUGIN_RPC_ERROR_CODES, + HOST_TO_WORKER_REQUIRED_METHODS, + HOST_TO_WORKER_OPTIONAL_METHODS, + MESSAGE_DELIMITER, + createRequest, + createSuccessResponse, + createErrorResponse, + createNotification, + isJsonRpcRequest, + isJsonRpcNotification, + isJsonRpcResponse, + isJsonRpcSuccessResponse, + isJsonRpcErrorResponse, + serializeMessage, + parseMessage, + JsonRpcParseError, + JsonRpcCallError, + _resetIdCounter, +} from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Type exports +// --------------------------------------------------------------------------- + +// Plugin definition and lifecycle types +export type { + PluginDefinition, + PaperclipPlugin, + PluginHealthDiagnostics, + PluginConfigValidationResult, + PluginWebhookInput, +} from "./define-plugin.js"; +export type { + TestHarness, + TestHarnessOptions, + TestHarnessLogEntry, +} from "./testing.js"; +export type { + PluginBundlerPresetInput, + PluginBundlerPresets, + EsbuildLikeOptions, + RollupLikeConfig, +} from "./bundlers.js"; +export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js"; +export type { + WorkerRpcHostOptions, + WorkerRpcHost, + RunWorkerOptions, +} from "./worker-rpc-host.js"; +export type { + HostServices, + HostClientFactoryOptions, + HostClientHandlers, +} from "./host-client-factory.js"; + +// JSON-RPC protocol types +export type { + JsonRpcId, + JsonRpcRequest, + JsonRpcSuccessResponse, + JsonRpcError, + JsonRpcErrorResponse, + JsonRpcResponse, + JsonRpcNotification, + JsonRpcMessage, + JsonRpcErrorCode, + PluginRpcErrorCode, + InitializeParams, + InitializeResult, + ConfigChangedParams, + ValidateConfigParams, + OnEventParams, + RunJobParams, + GetDataParams, + PerformActionParams, + ExecuteToolParams, + PluginModalBoundsRequest, + PluginRenderCloseEvent, + PluginLauncherRenderContextSnapshot, + HostToWorkerMethods, + HostToWorkerMethodName, + WorkerToHostMethods, + WorkerToHostMethodName, + HostToWorkerRequest, + HostToWorkerResponse, + WorkerToHostRequest, + WorkerToHostResponse, + WorkerToHostNotifications, + WorkerToHostNotificationName, +} from "./protocol.js"; + +// Plugin context and all client interfaces +export type { + PluginContext, + PluginConfigClient, + PluginEventsClient, + PluginJobsClient, + PluginLaunchersClient, + PluginHttpClient, + PluginSecretsClient, + PluginAssetsClient, + PluginActivityClient, + PluginActivityLogEntry, + PluginStateClient, + PluginEntitiesClient, + PluginProjectsClient, + PluginCompaniesClient, + PluginIssuesClient, + PluginAgentsClient, + PluginAgentSessionsClient, + AgentSession, + AgentSessionEvent, + AgentSessionSendResult, + PluginGoalsClient, + PluginDataClient, + PluginActionsClient, + PluginStreamsClient, + PluginToolsClient, + PluginMetricsClient, + PluginLogger, +} from "./types.js"; + +// Supporting types for context clients +export type { + ScopeKey, + EventFilter, + PluginEvent, + PluginJobContext, + PluginLauncherRegistration, + ToolRunContext, + ToolResult, + PluginEntityUpsert, + PluginEntityRecord, + PluginEntityQuery, + PluginWorkspace, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "./types.js"; + +// Manifest and constant types re-exported from @paperclipai/shared +// Plugin authors import manifest types from here so they have a single +// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs. +export type { + PaperclipPluginManifestV1, + PluginJobDeclaration, + PluginWebhookDeclaration, + PluginToolDeclaration, + PluginUiSlotDeclaration, + PluginUiDeclaration, + PluginLauncherActionDeclaration, + PluginLauncherRenderDeclaration, + PluginLauncherDeclaration, + PluginMinimumHostVersion, + PluginRecord, + PluginConfig, + JsonSchema, + PluginStatus, + PluginCategory, + PluginCapability, + PluginUiSlotType, + PluginUiSlotEntityType, + PluginLauncherPlacementZone, + PluginLauncherAction, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, + PluginStateScopeKind, + PluginJobStatus, + PluginJobRunStatus, + PluginJobRunTrigger, + PluginWebhookDeliveryStatus, + PluginEventType, + PluginBridgeErrorCode, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Zod re-export +// --------------------------------------------------------------------------- + +/** + * Zod is re-exported for plugin authors to use when defining their + * `instanceConfigSchema` and tool `parametersSchema`. + * + * Plugin authors do not need to add a separate `zod` dependency. + * + * @see PLUGIN_SPEC.md §14.1 — Example SDK Shape + * + * @example + * ```ts + * import { z } from "@paperclipai/plugin-sdk"; + * + * const configSchema = z.object({ + * apiKey: z.string().describe("Your API key"), + * workspace: z.string().optional(), + * }); + * ``` + */ +export { z } from "zod"; + +// --------------------------------------------------------------------------- +// Constants re-exports (for plugin code that needs to check values at runtime) +// --------------------------------------------------------------------------- + +export { + PLUGIN_API_VERSION, + PLUGIN_STATUSES, + PLUGIN_CATEGORIES, + PLUGIN_CAPABILITIES, + PLUGIN_UI_SLOT_TYPES, + PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_STATE_SCOPE_KINDS, + PLUGIN_JOB_STATUSES, + PLUGIN_JOB_RUN_STATUSES, + PLUGIN_JOB_RUN_TRIGGERS, + PLUGIN_WEBHOOK_DELIVERY_STATUSES, + PLUGIN_EVENT_TYPES, + PLUGIN_BRIDGE_ERROR_CODES, +} from "@paperclipai/shared"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts new file mode 100644 index 00000000..211f2573 --- /dev/null +++ b/packages/plugins/sdk/src/protocol.ts @@ -0,0 +1,1038 @@ +/** + * JSON-RPC 2.0 message types and protocol helpers for the host ↔ worker IPC + * channel. + * + * The Paperclip plugin runtime uses JSON-RPC 2.0 over stdio to communicate + * between the host process and each plugin worker process. This module defines: + * + * - Core JSON-RPC 2.0 envelope types (request, response, notification, error) + * - Standard and plugin-specific error codes + * - Typed method maps for host→worker and worker→host calls + * - Helper functions for creating well-formed messages + * + * @see PLUGIN_SPEC.md §12.1 — Process Model + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + * @see https://www.jsonrpc.org/specification + */ + +import type { + PaperclipPluginManifestV1, + PluginLauncherBounds, + PluginLauncherRenderContextSnapshot, + PluginLauncherRenderEnvironment, + PluginStateScopeKind, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; +export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; + +import type { + PluginEvent, + PluginJobContext, + PluginWorkspace, + ToolRunContext, + ToolResult, +} from "./types.js"; +import type { + PluginHealthDiagnostics, + PluginConfigValidationResult, + PluginWebhookInput, +} from "./define-plugin.js"; + +// --------------------------------------------------------------------------- +// JSON-RPC 2.0 — Core Protocol Types +// --------------------------------------------------------------------------- + +/** The JSON-RPC protocol version. Always `"2.0"`. */ +export const JSONRPC_VERSION = "2.0" as const; + +/** + * A unique request identifier. JSON-RPC 2.0 allows strings or numbers; + * we use strings (UUIDs or monotonic counters) for all Paperclip messages. + */ +export type JsonRpcId = string | number; + +/** + * A JSON-RPC 2.0 request message. + * + * The host sends requests to the worker (or vice versa) and expects a + * matching response with the same `id`. + */ +export interface JsonRpcRequest< + TMethod extends string = string, + TParams = unknown, +> { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Unique request identifier. Must be echoed in the response. */ + readonly id: JsonRpcId; + /** The RPC method name to invoke. */ + readonly method: TMethod; + /** Structured parameters for the method call. */ + readonly params: TParams; +} + +/** + * A JSON-RPC 2.0 success response. + */ +export interface JsonRpcSuccessResponse { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Echoed request identifier. */ + readonly id: JsonRpcId; + /** The method return value. */ + readonly result: TResult; + readonly error?: never; +} + +/** + * A JSON-RPC 2.0 error object embedded in an error response. + */ +export interface JsonRpcError { + /** Machine-readable error code. */ + readonly code: number; + /** Human-readable error message. */ + readonly message: string; + /** Optional structured error data. */ + readonly data?: TData; +} + +/** + * A JSON-RPC 2.0 error response. + */ +export interface JsonRpcErrorResponse { + readonly jsonrpc: typeof JSONRPC_VERSION; + /** Echoed request identifier. */ + readonly id: JsonRpcId | null; + readonly result?: never; + /** The error object. */ + readonly error: JsonRpcError; +} + +/** + * A JSON-RPC 2.0 response — either success or error. + */ +export type JsonRpcResponse = + | JsonRpcSuccessResponse + | JsonRpcErrorResponse; + +/** + * A JSON-RPC 2.0 notification (a request with no `id`). + * + * Notifications are fire-and-forget — no response is expected. + */ +export interface JsonRpcNotification< + TMethod extends string = string, + TParams = unknown, +> { + readonly jsonrpc: typeof JSONRPC_VERSION; + readonly id?: never; + /** The notification method name. */ + readonly method: TMethod; + /** Structured parameters for the notification. */ + readonly params: TParams; +} + +/** + * Any well-formed JSON-RPC 2.0 message (request, response, or notification). + */ +export type JsonRpcMessage = + | JsonRpcRequest + | JsonRpcResponse + | JsonRpcNotification; + +// --------------------------------------------------------------------------- +// Error Codes +// --------------------------------------------------------------------------- + +/** + * Standard JSON-RPC 2.0 error codes. + * + * @see https://www.jsonrpc.org/specification#error_object + */ +export const JSONRPC_ERROR_CODES = { + /** Invalid JSON was received by the server. */ + PARSE_ERROR: -32700, + /** The JSON sent is not a valid Request object. */ + INVALID_REQUEST: -32600, + /** The method does not exist or is not available. */ + METHOD_NOT_FOUND: -32601, + /** Invalid method parameter(s). */ + INVALID_PARAMS: -32602, + /** Internal JSON-RPC error. */ + INTERNAL_ERROR: -32603, +} as const; + +export type JsonRpcErrorCode = + (typeof JSONRPC_ERROR_CODES)[keyof typeof JSONRPC_ERROR_CODES]; + +/** + * Paperclip plugin-specific error codes. + * + * These live in the JSON-RPC "server error" reserved range (-32000 to -32099) + * as specified by JSON-RPC 2.0 for implementation-defined server errors. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export const PLUGIN_RPC_ERROR_CODES = { + /** The worker process is not running or not reachable. */ + WORKER_UNAVAILABLE: -32000, + /** The plugin does not have the required capability for this operation. */ + CAPABILITY_DENIED: -32001, + /** The worker reported an unhandled error during method execution. */ + WORKER_ERROR: -32002, + /** The method call timed out waiting for the worker response. */ + TIMEOUT: -32003, + /** The worker does not implement the requested optional method. */ + METHOD_NOT_IMPLEMENTED: -32004, + /** A catch-all for errors that do not fit other categories. */ + UNKNOWN: -32099, +} as const; + +export type PluginRpcErrorCode = + (typeof PLUGIN_RPC_ERROR_CODES)[keyof typeof PLUGIN_RPC_ERROR_CODES]; + +// --------------------------------------------------------------------------- +// Host → Worker Method Signatures (§13 Host-Worker Protocol) +// --------------------------------------------------------------------------- + +/** + * Input for the `initialize` RPC method. + * + * @see PLUGIN_SPEC.md §13.1 — `initialize` + */ +export interface InitializeParams { + /** Full plugin manifest snapshot. */ + manifest: PaperclipPluginManifestV1; + /** Resolved operator configuration (validated against `instanceConfigSchema`). */ + config: Record; + /** Instance-level metadata. */ + instanceInfo: { + /** UUID of this Paperclip instance. */ + instanceId: string; + /** Semver version of the running Paperclip host. */ + hostVersion: string; + }; + /** Host API version. */ + apiVersion: number; +} + +/** + * Result returned by the `initialize` RPC method. + */ +export interface InitializeResult { + /** Whether initialization succeeded. */ + ok: boolean; + /** Optional methods the worker has implemented (e.g. "validateConfig", "onEvent"). */ + supportedMethods?: string[]; +} + +/** + * Input for the `configChanged` RPC method. + * + * @see PLUGIN_SPEC.md §13.4 — `configChanged` + */ +export interface ConfigChangedParams { + /** The newly resolved configuration. */ + config: Record; +} + +/** + * Input for the `validateConfig` RPC method. + * + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + */ +export interface ValidateConfigParams { + /** The configuration to validate. */ + config: Record; +} + +/** + * Input for the `onEvent` RPC method. + * + * @see PLUGIN_SPEC.md §13.5 — `onEvent` + */ +export interface OnEventParams { + /** The domain event to deliver. */ + event: PluginEvent; +} + +/** + * Input for the `runJob` RPC method. + * + * @see PLUGIN_SPEC.md §13.6 — `runJob` + */ +export interface RunJobParams { + /** Job execution context. */ + job: PluginJobContext; +} + +/** + * Input for the `getData` RPC method. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + */ +export interface GetDataParams { + /** Plugin-defined data key (e.g. `"sync-health"`). */ + key: string; + /** Context and query parameters from the UI. */ + params: Record; + /** Optional launcher/container metadata from the host render environment. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; +} + +/** + * Input for the `performAction` RPC method. + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + */ +export interface PerformActionParams { + /** Plugin-defined action key (e.g. `"resync"`). */ + key: string; + /** Action parameters from the UI. */ + params: Record; + /** Optional launcher/container metadata from the host render environment. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; +} + +/** + * Input for the `executeTool` RPC method. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ExecuteToolParams { + /** Tool name (without plugin namespace prefix). */ + toolName: string; + /** Parsed parameters matching the tool's declared schema. */ + parameters: unknown; + /** Agent run context. */ + runContext: ToolRunContext; +} + +// --------------------------------------------------------------------------- +// UI launcher / modal host interaction payloads +// --------------------------------------------------------------------------- + +/** + * Bounds request issued by a plugin UI running inside a host-managed launcher + * container such as a modal, drawer, or popover. + */ +export interface PluginModalBoundsRequest { + /** High-level size preset requested from the host. */ + bounds: PluginLauncherBounds; + /** Optional explicit width override in CSS pixels. */ + width?: number; + /** Optional explicit height override in CSS pixels. */ + height?: number; + /** Optional lower bounds for host resizing decisions. */ + minWidth?: number; + minHeight?: number; + /** Optional upper bounds for host resizing decisions. */ + maxWidth?: number; + maxHeight?: number; +} + +/** + * Reason metadata supplied by host-managed close lifecycle callbacks. + */ +export interface PluginRenderCloseEvent { + reason: + | "escapeKey" + | "backdrop" + | "hostNavigation" + | "programmatic" + | "submit" + | "unknown"; + nativeEvent?: unknown; +} + +/** + * Map of host→worker RPC method names to their `[params, result]` types. + * + * This type is the single source of truth for all methods the host can call + * on a worker. Used by both the host dispatcher and the worker handler to + * ensure type safety across the IPC boundary. + */ +export interface HostToWorkerMethods { + /** @see PLUGIN_SPEC.md §13.1 */ + initialize: [params: InitializeParams, result: InitializeResult]; + /** @see PLUGIN_SPEC.md §13.2 */ + health: [params: Record, result: PluginHealthDiagnostics]; + /** @see PLUGIN_SPEC.md §12.5 */ + shutdown: [params: Record, result: void]; + /** @see PLUGIN_SPEC.md §13.3 */ + validateConfig: [params: ValidateConfigParams, result: PluginConfigValidationResult]; + /** @see PLUGIN_SPEC.md §13.4 */ + configChanged: [params: ConfigChangedParams, result: void]; + /** @see PLUGIN_SPEC.md §13.5 */ + onEvent: [params: OnEventParams, result: void]; + /** @see PLUGIN_SPEC.md §13.6 */ + runJob: [params: RunJobParams, result: void]; + /** @see PLUGIN_SPEC.md §13.7 */ + handleWebhook: [params: PluginWebhookInput, result: void]; + /** @see PLUGIN_SPEC.md §13.8 */ + getData: [params: GetDataParams, result: unknown]; + /** @see PLUGIN_SPEC.md §13.9 */ + performAction: [params: PerformActionParams, result: unknown]; + /** @see PLUGIN_SPEC.md §13.10 */ + executeTool: [params: ExecuteToolParams, result: ToolResult]; +} + +/** Union of all host→worker method names. */ +export type HostToWorkerMethodName = keyof HostToWorkerMethods; + +/** Required methods the worker MUST implement. */ +export const HOST_TO_WORKER_REQUIRED_METHODS: readonly HostToWorkerMethodName[] = [ + "initialize", + "health", + "shutdown", +] as const; + +/** Optional methods the worker MAY implement. */ +export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] = [ + "validateConfig", + "configChanged", + "onEvent", + "runJob", + "handleWebhook", + "getData", + "performAction", + "executeTool", +] as const; + +// --------------------------------------------------------------------------- +// Worker → Host Method Signatures (SDK client calls) +// --------------------------------------------------------------------------- + +/** + * Map of worker→host RPC method names to their `[params, result]` types. + * + * These represent the SDK client calls that the worker makes back to the + * host to access platform services (state, entities, config, etc.). + */ +export interface WorkerToHostMethods { + // Config + "config.get": [params: Record, result: Record]; + + // State + "state.get": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string }, + result: unknown, + ]; + "state.set": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string; value: unknown }, + result: void, + ]; + "state.delete": [ + params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string }, + result: void, + ]; + + // Entities + "entities.upsert": [ + params: { + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId?: string; + externalId?: string; + title?: string; + status?: string; + data: Record; + }, + result: { + id: string; + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId: string | null; + externalId: string | null; + title: string | null; + status: string | null; + data: Record; + createdAt: string; + updatedAt: string; + }, + ]; + "entities.list": [ + params: { + entityType?: string; + scopeKind?: PluginStateScopeKind; + scopeId?: string; + externalId?: string; + limit?: number; + offset?: number; + }, + result: Array<{ + id: string; + entityType: string; + scopeKind: PluginStateScopeKind; + scopeId: string | null; + externalId: string | null; + title: string | null; + status: string | null; + data: Record; + createdAt: string; + updatedAt: string; + }>, + ]; + + // Events + "events.emit": [ + params: { name: string; companyId: string; payload: unknown }, + result: void, + ]; + + // HTTP + "http.fetch": [ + params: { url: string; init?: Record }, + result: { status: number; statusText: string; headers: Record; body: string }, + ]; + + // Secrets + "secrets.resolve": [ + params: { secretRef: string }, + result: string, + ]; + + // Assets + "assets.upload": [ + params: { filename: string; contentType: string; data: string }, + result: { assetId: string; url: string }, + ]; + "assets.getUrl": [ + params: { assetId: string }, + result: string, + ]; + + // Activity + "activity.log": [ + params: { + companyId: string; + message: string; + entityType?: string; + entityId?: string; + metadata?: Record; + }, + result: void, + ]; + + // Metrics + "metrics.write": [ + params: { name: string; value: number; tags?: Record }, + result: void, + ]; + + // Logger + "log": [ + params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record }, + result: void, + ]; + + // Companies (read) + "companies.list": [ + params: { limit?: number; offset?: number }, + result: Company[], + ]; + "companies.get": [ + params: { companyId: string }, + result: Company | null, + ]; + + // Projects (read) + "projects.list": [ + params: { companyId: string; limit?: number; offset?: number }, + result: Project[], + ]; + "projects.get": [ + params: { projectId: string; companyId: string }, + result: Project | null, + ]; + "projects.listWorkspaces": [ + params: { projectId: string; companyId: string }, + result: PluginWorkspace[], + ]; + "projects.getPrimaryWorkspace": [ + params: { projectId: string; companyId: string }, + result: PluginWorkspace | null, + ]; + "projects.getWorkspaceForIssue": [ + params: { issueId: string; companyId: string }, + result: PluginWorkspace | null, + ]; + + // Issues + "issues.list": [ + params: { + companyId: string; + projectId?: string; + assigneeAgentId?: string; + status?: string; + limit?: number; + offset?: number; + }, + result: Issue[], + ]; + "issues.get": [ + params: { issueId: string; companyId: string }, + result: Issue | null, + ]; + "issues.create": [ + params: { + companyId: string; + projectId?: string; + goalId?: string; + parentId?: string; + title: string; + description?: string; + priority?: string; + assigneeAgentId?: string; + }, + result: Issue, + ]; + "issues.update": [ + params: { + issueId: string; + patch: Record; + companyId: string; + }, + result: Issue, + ]; + "issues.listComments": [ + params: { issueId: string; companyId: string }, + result: IssueComment[], + ]; + "issues.createComment": [ + params: { issueId: string; body: string; companyId: string }, + result: IssueComment, + ]; + + // Agents (read) + "agents.list": [ + params: { companyId: string; status?: string; limit?: number; offset?: number }, + result: Agent[], + ]; + "agents.get": [ + params: { agentId: string; companyId: string }, + result: Agent | null, + ]; + + // Agents (write) + "agents.pause": [ + params: { agentId: string; companyId: string }, + result: Agent, + ]; + "agents.resume": [ + params: { agentId: string; companyId: string }, + result: Agent, + ]; + "agents.invoke": [ + params: { agentId: string; companyId: string; prompt: string; reason?: string }, + result: { runId: string }, + ]; + + // Agent Sessions + "agents.sessions.create": [ + params: { agentId: string; companyId: string; taskKey?: string; reason?: string }, + result: { sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }, + ]; + "agents.sessions.list": [ + params: { agentId: string; companyId: string }, + result: Array<{ sessionId: string; agentId: string; companyId: string; status: "active" | "closed"; createdAt: string }>, + ]; + "agents.sessions.sendMessage": [ + params: { sessionId: string; companyId: string; prompt: string; reason?: string }, + result: { runId: string }, + ]; + "agents.sessions.close": [ + params: { sessionId: string; companyId: string }, + result: void, + ]; + + // Goals + "goals.list": [ + params: { companyId: string; level?: string; status?: string; limit?: number; offset?: number }, + result: Goal[], + ]; + "goals.get": [ + params: { goalId: string; companyId: string }, + result: Goal | null, + ]; + "goals.create": [ + params: { + companyId: string; + title: string; + description?: string; + level?: string; + status?: string; + parentId?: string; + ownerAgentId?: string; + }, + result: Goal, + ]; + "goals.update": [ + params: { + goalId: string; + patch: Record; + companyId: string; + }, + result: Goal, + ]; +} + +/** Union of all worker→host method names. */ +export type WorkerToHostMethodName = keyof WorkerToHostMethods; + +// --------------------------------------------------------------------------- +// Worker→Host Notification Types (fire-and-forget, no response) +// --------------------------------------------------------------------------- + +/** + * Typed parameter shapes for worker→host JSON-RPC notifications. + * + * Notifications are fire-and-forget — the worker does not wait for a response. + * These are used for streaming events and logging, not for request-response RPCs. + */ +export interface WorkerToHostNotifications { + /** + * Forward a stream event to connected SSE clients. + * + * Emitted by the worker for each event on a stream channel. The host + * publishes to the PluginStreamBus, which fans out to all SSE clients + * subscribed to the (pluginId, channel, companyId) tuple. + * + * The `event` payload is JSON-serializable and sent as SSE `data:`. + * The default SSE event type is `"message"`. + */ + "streams.emit": { + channel: string; + companyId: string; + event: unknown; + }; + + /** + * Signal that a stream channel has been opened. + * + * Emitted when the worker calls `ctx.streams.open(channel, companyId)`. + * UI clients may use this to display a "connected" indicator or begin + * buffering input. The host tracks open channels so it can emit synthetic + * close events if the worker crashes. + */ + "streams.open": { + channel: string; + companyId: string; + }; + + /** + * Signal that a stream channel has been closed. + * + * Emitted when the worker calls `ctx.streams.close(channel)`, or + * synthetically by the host when a worker process exits with channels + * still open. UI clients should treat this as terminal and disconnect + * the SSE connection. + */ + "streams.close": { + channel: string; + companyId: string; + }; +} + +/** Union of all worker→host notification method names. */ +export type WorkerToHostNotificationName = keyof WorkerToHostNotifications; + +// --------------------------------------------------------------------------- +// Typed Request / Response Helpers +// --------------------------------------------------------------------------- + +/** + * A typed JSON-RPC request for a specific host→worker method. + */ +export type HostToWorkerRequest = + JsonRpcRequest; + +/** + * A typed JSON-RPC success response for a specific host→worker method. + */ +export type HostToWorkerResponse = + JsonRpcSuccessResponse; + +/** + * A typed JSON-RPC request for a specific worker→host method. + */ +export type WorkerToHostRequest = + JsonRpcRequest; + +/** + * A typed JSON-RPC success response for a specific worker→host method. + */ +export type WorkerToHostResponse = + JsonRpcSuccessResponse; + +// --------------------------------------------------------------------------- +// Message Factory Functions +// --------------------------------------------------------------------------- + +/** Counter for generating unique request IDs when no explicit ID is provided. */ +let _nextId = 1; + +/** Wrap around before reaching Number.MAX_SAFE_INTEGER to prevent precision loss. */ +const MAX_SAFE_RPC_ID = Number.MAX_SAFE_INTEGER - 1; + +/** + * Create a JSON-RPC 2.0 request message. + * + * @param method - The RPC method name + * @param params - Structured parameters + * @param id - Optional explicit request ID (auto-generated if omitted) + */ +export function createRequest( + method: TMethod, + params: unknown, + id?: JsonRpcId, +): JsonRpcRequest { + if (_nextId >= MAX_SAFE_RPC_ID) { + _nextId = 1; + } + return { + jsonrpc: JSONRPC_VERSION, + id: id ?? _nextId++, + method, + params, + }; +} + +/** + * Create a JSON-RPC 2.0 success response. + * + * @param id - The request ID being responded to + * @param result - The result value + */ +export function createSuccessResponse( + id: JsonRpcId, + result: TResult, +): JsonRpcSuccessResponse { + return { + jsonrpc: JSONRPC_VERSION, + id, + result, + }; +} + +/** + * Create a JSON-RPC 2.0 error response. + * + * @param id - The request ID being responded to (null if the request ID could not be determined) + * @param code - Machine-readable error code + * @param message - Human-readable error message + * @param data - Optional structured error data + */ +export function createErrorResponse( + id: JsonRpcId | null, + code: number, + message: string, + data?: TData, +): JsonRpcErrorResponse { + const response: JsonRpcErrorResponse = { + jsonrpc: JSONRPC_VERSION, + id, + error: data !== undefined + ? { code, message, data } + : { code, message } as JsonRpcError, + }; + return response; +} + +/** + * Create a JSON-RPC 2.0 notification (fire-and-forget, no response expected). + * + * @param method - The notification method name + * @param params - Structured parameters + */ +export function createNotification( + method: TMethod, + params: unknown, +): JsonRpcNotification { + return { + jsonrpc: JSONRPC_VERSION, + method, + params, + }; +} + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 request. + * + * A request has `jsonrpc: "2.0"`, a string `method`, and an `id`. + */ +export function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + typeof obj.method === "string" && + "id" in obj && + obj.id !== undefined && + obj.id !== null + ); +} + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 notification. + * + * A notification has `jsonrpc: "2.0"`, a string `method`, but no `id`. + */ +export function isJsonRpcNotification( + value: unknown, +): value is JsonRpcNotification { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + typeof obj.method === "string" && + !("id" in obj) + ); +} + +/** + * Check whether a value is a well-formed JSON-RPC 2.0 response (success or error). + */ +export function isJsonRpcResponse(value: unknown): value is JsonRpcResponse { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.jsonrpc === JSONRPC_VERSION && + "id" in obj && + ("result" in obj || "error" in obj) + ); +} + +/** + * Check whether a JSON-RPC response is a success response. + */ +export function isJsonRpcSuccessResponse( + response: JsonRpcResponse, +): response is JsonRpcSuccessResponse { + return "result" in response && !("error" in response && response.error !== undefined); +} + +/** + * Check whether a JSON-RPC response is an error response. + */ +export function isJsonRpcErrorResponse( + response: JsonRpcResponse, +): response is JsonRpcErrorResponse { + return "error" in response && response.error !== undefined; +} + +// --------------------------------------------------------------------------- +// Serialization Helpers +// --------------------------------------------------------------------------- + +/** + * Line delimiter for JSON-RPC messages over stdio. + * + * Each message is a single line of JSON terminated by a newline character. + * This follows the newline-delimited JSON (NDJSON) convention. + */ +export const MESSAGE_DELIMITER = "\n" as const; + +/** + * Serialize a JSON-RPC message to a newline-delimited string for transmission + * over stdio. + * + * @param message - Any JSON-RPC message (request, response, or notification) + * @returns The JSON string terminated with a newline + */ +export function serializeMessage(message: JsonRpcMessage): string { + return JSON.stringify(message) + MESSAGE_DELIMITER; +} + +/** + * Parse a JSON string into a JSON-RPC message. + * + * Returns the parsed message or throws a `JsonRpcParseError` if the input + * is not valid JSON or does not conform to the JSON-RPC 2.0 structure. + * + * @param line - A single line of JSON text (with or without trailing newline) + * @returns The parsed JSON-RPC message + * @throws {JsonRpcParseError} If parsing fails + */ +export function parseMessage(line: string): JsonRpcMessage { + const trimmed = line.trim(); + if (trimmed.length === 0) { + throw new JsonRpcParseError("Empty message"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new JsonRpcParseError(`Invalid JSON: ${trimmed.slice(0, 200)}`); + } + + if (typeof parsed !== "object" || parsed === null) { + throw new JsonRpcParseError("Message must be a JSON object"); + } + + const obj = parsed as Record; + + if (obj.jsonrpc !== JSONRPC_VERSION) { + throw new JsonRpcParseError( + `Invalid or missing jsonrpc version (expected "${JSONRPC_VERSION}", got ${JSON.stringify(obj.jsonrpc)})`, + ); + } + + // It's a valid JSON-RPC 2.0 envelope — return as-is and let the caller + // use the type guards for more specific classification. + return parsed as JsonRpcMessage; +} + +// --------------------------------------------------------------------------- +// Error Classes +// --------------------------------------------------------------------------- + +/** + * Error thrown when a JSON-RPC message cannot be parsed. + */ +export class JsonRpcParseError extends Error { + override readonly name = "JsonRpcParseError"; + constructor(message: string) { + super(message); + } +} + +/** + * Error thrown when a JSON-RPC call fails with a structured error response. + * + * Captures the full `JsonRpcError` so callers can inspect the code and data. + */ +export class JsonRpcCallError extends Error { + override readonly name = "JsonRpcCallError"; + /** The JSON-RPC error code. */ + readonly code: number; + /** Optional structured error data from the response. */ + readonly data: unknown; + + constructor(error: JsonRpcError) { + super(error.message); + this.code = error.code; + this.data = error.data; + } +} + +// --------------------------------------------------------------------------- +// Reset helper (testing only) +// --------------------------------------------------------------------------- + +/** + * Reset the internal request ID counter. **For testing only.** + * + * @internal + */ +export function _resetIdCounter(): void { + _nextId = 1; +} diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts new file mode 100644 index 00000000..ee8a38ce --- /dev/null +++ b/packages/plugins/sdk/src/testing.ts @@ -0,0 +1,720 @@ +import { randomUUID } from "node:crypto"; +import type { + PaperclipPluginManifestV1, + PluginCapability, + PluginEventType, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; +import type { + EventFilter, + PluginContext, + PluginEntityRecord, + PluginEntityUpsert, + PluginJobContext, + PluginLauncherRegistration, + PluginEvent, + ScopeKey, + ToolResult, + ToolRunContext, + PluginWorkspace, + AgentSession, + AgentSessionEvent, +} from "./types.js"; + +export interface TestHarnessOptions { + /** Plugin manifest used to seed capability checks and metadata. */ + manifest: PaperclipPluginManifestV1; + /** Optional capability override. Defaults to `manifest.capabilities`. */ + capabilities?: PluginCapability[]; + /** Initial config returned by `ctx.config.get()`. */ + config?: Record; +} + +export interface TestHarnessLogEntry { + level: "info" | "warn" | "error" | "debug"; + message: string; + meta?: Record; +} + +export interface TestHarness { + /** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */ + ctx: PluginContext; + /** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */ + seed(input: { + companies?: Company[]; + projects?: Project[]; + issues?: Issue[]; + issueComments?: IssueComment[]; + agents?: Agent[]; + goals?: Goal[]; + }): void; + setConfig(config: Record): void; + /** Dispatch a host or plugin event to registered handlers. */ + emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial): Promise; + /** Execute a previously-registered scheduled job handler. */ + runJob(jobKey: string, partial?: Partial): Promise; + /** Invoke a `ctx.data.register(...)` handler by key. */ + getData(key: string, params?: Record): Promise; + /** Invoke a `ctx.actions.register(...)` handler by key. */ + performAction(key: string, params?: Record): Promise; + /** Execute a registered tool handler via `ctx.tools.execute(...)`. */ + executeTool(name: string, params: unknown, runCtx?: Partial): Promise; + /** Read raw in-memory state for assertions. */ + getState(input: ScopeKey): unknown; + /** Simulate a streaming event arriving for an active session. */ + simulateSessionEvent(sessionId: string, event: Omit): void; + logs: TestHarnessLogEntry[]; + activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record }>; + metrics: Array<{ name: string; value: number; tags?: Record }>; +} + +type EventRegistration = { + name: PluginEventType | `plugin.${string}`; + filter?: EventFilter; + fn: (event: PluginEvent) => Promise; +}; + +function normalizeScope(input: ScopeKey): Required> & Pick { + return { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace ?? "default", + stateKey: input.stateKey, + }; +} + +function stateMapKey(input: ScopeKey): string { + const normalized = normalizeScope(input); + return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`; +} + +function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean { + if (!filter) return true; + if (filter.companyId && filter.companyId !== String((event.payload as Record | undefined)?.companyId ?? "")) return false; + if (filter.projectId && filter.projectId !== String((event.payload as Record | undefined)?.projectId ?? "")) return false; + if (filter.agentId && filter.agentId !== String((event.payload as Record | undefined)?.agentId ?? "")) return false; + return true; +} + +function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set, capability: PluginCapability) { + if (allowed.has(capability)) return; + throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`); +} + +function requireCompanyId(companyId?: string): string { + if (!companyId) throw new Error("companyId is required for this operation"); + return companyId; +} + +function isInCompany( + record: T | null | undefined, + companyId: string, +): record is T { + return Boolean(record && record.companyId === companyId); +} + +/** + * Create an in-memory host harness for plugin worker tests. + * + * The harness enforces declared capabilities and simulates host APIs, so tests + * can validate plugin behavior without spinning up the Paperclip server runtime. + */ +export function createTestHarness(options: TestHarnessOptions): TestHarness { + const manifest = options.manifest; + const capabilitySet = new Set(options.capabilities ?? manifest.capabilities); + let currentConfig = { ...(options.config ?? {}) }; + + const logs: TestHarnessLogEntry[] = []; + const activity: TestHarness["activity"] = []; + const metrics: TestHarness["metrics"] = []; + + const state = new Map(); + const entities = new Map(); + const entityExternalIndex = new Map(); + const assets = new Map(); + + const companies = new Map(); + const projects = new Map(); + const issues = new Map(); + const issueComments = new Map(); + const agents = new Map(); + const goals = new Map(); + const projectWorkspaces = new Map(); + + const sessions = new Map(); + const sessionEventCallbacks = new Map void>(); + + const events: EventRegistration[] = []; + const jobs = new Map Promise>(); + const launchers = new Map(); + const dataHandlers = new Map) => Promise>(); + const actionHandlers = new Map) => Promise>(); + const toolHandlers = new Map Promise>(); + + const ctx: PluginContext = { + manifest, + config: { + async get() { + return { ...currentConfig }; + }, + }, + events: { + on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => void { + requireCapability(manifest, capabilitySet, "events.subscribe"); + let registration: EventRegistration; + if (typeof filterOrFn === "function") { + registration = { name, fn: filterOrFn }; + } else { + if (!maybeFn) throw new Error("event handler is required"); + registration = { name, filter: filterOrFn, fn: maybeFn }; + } + events.push(registration); + return () => { + const idx = events.indexOf(registration); + if (idx !== -1) events.splice(idx, 1); + }; + }, + async emit(name, companyId, payload) { + requireCapability(manifest, capabilitySet, "events.emit"); + await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId }); + }, + }, + jobs: { + register(key, fn) { + requireCapability(manifest, capabilitySet, "jobs.schedule"); + jobs.set(key, fn); + }, + }, + launchers: { + register(launcher) { + launchers.set(launcher.id, launcher); + }, + }, + http: { + async fetch(url, init) { + requireCapability(manifest, capabilitySet, "http.outbound"); + return fetch(url, init); + }, + }, + secrets: { + async resolve(secretRef) { + requireCapability(manifest, capabilitySet, "secrets.read-ref"); + return `resolved:${secretRef}`; + }, + }, + assets: { + async upload(filename, contentType, data) { + requireCapability(manifest, capabilitySet, "assets.write"); + const assetId = `asset_${randomUUID()}`; + assets.set(assetId, { contentType, data: data instanceof Uint8Array ? data : new Uint8Array(data) }); + return { assetId, url: `memory://assets/${filename}` }; + }, + async getUrl(assetId) { + requireCapability(manifest, capabilitySet, "assets.read"); + if (!assets.has(assetId)) throw new Error(`Asset not found: ${assetId}`); + return `memory://assets/${assetId}`; + }, + }, + activity: { + async log(entry) { + requireCapability(manifest, capabilitySet, "activity.log.write"); + activity.push(entry); + }, + }, + state: { + async get(input) { + requireCapability(manifest, capabilitySet, "plugin.state.read"); + return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null; + }, + async set(input, value) { + requireCapability(manifest, capabilitySet, "plugin.state.write"); + state.set(stateMapKey(input), value); + }, + async delete(input) { + requireCapability(manifest, capabilitySet, "plugin.state.write"); + state.delete(stateMapKey(input)); + }, + }, + entities: { + async upsert(input: PluginEntityUpsert) { + const externalKey = input.externalId + ? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}` + : null; + const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined; + const existing = existingId ? entities.get(existingId) : undefined; + const now = new Date().toISOString(); + const previousExternalKey = existing?.externalId + ? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}` + : null; + const record: PluginEntityRecord = existing + ? { + ...existing, + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId ?? null, + externalId: input.externalId ?? null, + title: input.title ?? null, + status: input.status ?? null, + data: input.data, + updatedAt: now, + } + : { + id: randomUUID(), + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId ?? null, + externalId: input.externalId ?? null, + title: input.title ?? null, + status: input.status ?? null, + data: input.data, + createdAt: now, + updatedAt: now, + }; + entities.set(record.id, record); + if (previousExternalKey && previousExternalKey !== externalKey) { + entityExternalIndex.delete(previousExternalKey); + } + if (externalKey) entityExternalIndex.set(externalKey, record.id); + return record; + }, + async list(query) { + let out = [...entities.values()]; + if (query.entityType) out = out.filter((r) => r.entityType === query.entityType); + if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind); + if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId); + if (query.externalId) out = out.filter((r) => r.externalId === query.externalId); + if (query.offset) out = out.slice(query.offset); + if (query.limit) out = out.slice(0, query.limit); + return out; + }, + }, + projects: { + async list(input) { + requireCapability(manifest, capabilitySet, "projects.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...projects.values()]; + out = out.filter((project) => project.companyId === companyId); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(projectId, companyId) { + requireCapability(manifest, capabilitySet, "projects.read"); + const project = projects.get(projectId); + return isInCompany(project, companyId) ? project : null; + }, + async listWorkspaces(projectId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + if (!isInCompany(projects.get(projectId), companyId)) return []; + return projectWorkspaces.get(projectId) ?? []; + }, + async getPrimaryWorkspace(projectId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + if (!isInCompany(projects.get(projectId), companyId)) return null; + const workspaces = projectWorkspaces.get(projectId) ?? []; + return workspaces.find((workspace) => workspace.isPrimary) ?? null; + }, + async getWorkspaceForIssue(issueId, companyId) { + requireCapability(manifest, capabilitySet, "project.workspaces.read"); + const issue = issues.get(issueId); + if (!isInCompany(issue, companyId)) return null; + const projectId = (issue as unknown as Record)?.projectId as string | undefined; + if (!projectId) return null; + if (!isInCompany(projects.get(projectId), companyId)) return null; + const workspaces = projectWorkspaces.get(projectId) ?? []; + return workspaces.find((workspace) => workspace.isPrimary) ?? null; + }, + }, + companies: { + async list(input) { + requireCapability(manifest, capabilitySet, "companies.read"); + let out = [...companies.values()]; + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(companyId) { + requireCapability(manifest, capabilitySet, "companies.read"); + return companies.get(companyId) ?? null; + }, + }, + issues: { + async list(input) { + requireCapability(manifest, capabilitySet, "issues.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...issues.values()]; + out = out.filter((issue) => issue.companyId === companyId); + if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId); + if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId); + if (input?.status) out = out.filter((issue) => issue.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issues.read"); + const issue = issues.get(issueId); + return isInCompany(issue, companyId) ? issue : null; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "issues.create"); + const now = new Date(); + const record: Issue = { + id: randomUUID(), + companyId: input.companyId, + projectId: input.projectId ?? null, + goalId: input.goalId ?? null, + parentId: input.parentId ?? null, + title: input.title, + description: input.description ?? null, + status: "todo", + priority: input.priority ?? "medium", + assigneeAgentId: input.assigneeAgentId ?? null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: null, + identifier: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: now, + updatedAt: now, + }; + issues.set(record.id, record); + return record; + }, + async update(issueId, patch, companyId) { + requireCapability(manifest, capabilitySet, "issues.update"); + const record = issues.get(issueId); + if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`); + const updated: Issue = { + ...record, + ...patch, + updatedAt: new Date(), + }; + issues.set(issueId, updated); + return updated; + }, + async listComments(issueId, companyId) { + requireCapability(manifest, capabilitySet, "issue.comments.read"); + if (!isInCompany(issues.get(issueId), companyId)) return []; + return issueComments.get(issueId) ?? []; + }, + async createComment(issueId, body, companyId) { + requireCapability(manifest, capabilitySet, "issue.comments.create"); + const parentIssue = issues.get(issueId); + if (!isInCompany(parentIssue, companyId)) { + throw new Error(`Issue not found: ${issueId}`); + } + const now = new Date(); + const comment: IssueComment = { + id: randomUUID(), + companyId: parentIssue.companyId, + issueId, + authorAgentId: null, + authorUserId: null, + body, + createdAt: now, + updatedAt: now, + }; + const current = issueComments.get(issueId) ?? []; + current.push(comment); + issueComments.set(issueId, current); + return comment; + }, + }, + agents: { + async list(input) { + requireCapability(manifest, capabilitySet, "agents.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...agents.values()]; + out = out.filter((agent) => agent.companyId === companyId); + if (input?.status) out = out.filter((agent) => agent.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.read"); + const agent = agents.get(agentId); + return isInCompany(agent, companyId) ? agent : null; + }, + async pause(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.pause"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent"); + const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() }; + agents.set(agentId, updated); + return updated; + }, + async resume(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agents.resume"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent"); + if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed"); + const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() }; + agents.set(agentId, updated); + return updated; + }, + async invoke(agentId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agents.invoke"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + if ( + agent!.status === "paused" || + agent!.status === "terminated" || + agent!.status === "pending_approval" + ) { + throw new Error(`Agent is not invokable in its current state: ${agent!.status}`); + } + return { runId: randomUUID() }; + }, + sessions: { + async create(agentId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agent.sessions.create"); + const cid = requireCompanyId(companyId); + const agent = agents.get(agentId); + if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`); + const session: AgentSession = { + sessionId: randomUUID(), + agentId, + companyId: cid, + status: "active", + createdAt: new Date().toISOString(), + }; + sessions.set(session.sessionId, session); + return session; + }, + async list(agentId, companyId) { + requireCapability(manifest, capabilitySet, "agent.sessions.list"); + const cid = requireCompanyId(companyId); + return [...sessions.values()].filter( + (s) => s.agentId === agentId && s.companyId === cid && s.status === "active", + ); + }, + async sendMessage(sessionId, companyId, opts) { + requireCapability(manifest, capabilitySet, "agent.sessions.send"); + const session = sessions.get(sessionId); + if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`); + if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); + if (opts.onEvent) { + sessionEventCallbacks.set(sessionId, opts.onEvent); + } + return { runId: randomUUID() }; + }, + async close(sessionId, companyId) { + requireCapability(manifest, capabilitySet, "agent.sessions.close"); + const session = sessions.get(sessionId); + if (!session) throw new Error(`Session not found: ${sessionId}`); + if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`); + session.status = "closed"; + sessionEventCallbacks.delete(sessionId); + }, + }, + }, + goals: { + async list(input) { + requireCapability(manifest, capabilitySet, "goals.read"); + const companyId = requireCompanyId(input?.companyId); + let out = [...goals.values()]; + out = out.filter((goal) => goal.companyId === companyId); + if (input?.level) out = out.filter((goal) => goal.level === input.level); + if (input?.status) out = out.filter((goal) => goal.status === input.status); + if (input?.offset) out = out.slice(input.offset); + if (input?.limit) out = out.slice(0, input.limit); + return out; + }, + async get(goalId, companyId) { + requireCapability(manifest, capabilitySet, "goals.read"); + const goal = goals.get(goalId); + return isInCompany(goal, companyId) ? goal : null; + }, + async create(input) { + requireCapability(manifest, capabilitySet, "goals.create"); + const now = new Date(); + const record: Goal = { + id: randomUUID(), + companyId: input.companyId, + title: input.title, + description: input.description ?? null, + level: input.level ?? "task", + status: input.status ?? "planned", + parentId: input.parentId ?? null, + ownerAgentId: input.ownerAgentId ?? null, + createdAt: now, + updatedAt: now, + }; + goals.set(record.id, record); + return record; + }, + async update(goalId, patch, companyId) { + requireCapability(manifest, capabilitySet, "goals.update"); + const record = goals.get(goalId); + if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`); + const updated: Goal = { + ...record, + ...patch, + updatedAt: new Date(), + }; + goals.set(goalId, updated); + return updated; + }, + }, + data: { + register(key, handler) { + dataHandlers.set(key, handler); + }, + }, + actions: { + register(key, handler) { + actionHandlers.set(key, handler); + }, + }, + streams: (() => { + const channelCompanyMap = new Map(); + return { + open(channel: string, companyId: string) { + channelCompanyMap.set(channel, companyId); + }, + emit(_channel: string, _event: unknown) { + // No-op in test harness — events are not forwarded + }, + close(channel: string) { + channelCompanyMap.delete(channel); + }, + }; + })(), + tools: { + register(name, _decl, fn) { + requireCapability(manifest, capabilitySet, "agent.tools.register"); + toolHandlers.set(name, fn); + }, + }, + metrics: { + async write(name, value, tags) { + requireCapability(manifest, capabilitySet, "metrics.write"); + metrics.push({ name, value, tags }); + }, + }, + logger: { + info(message, meta) { + logs.push({ level: "info", message, meta }); + }, + warn(message, meta) { + logs.push({ level: "warn", message, meta }); + }, + error(message, meta) { + logs.push({ level: "error", message, meta }); + }, + debug(message, meta) { + logs.push({ level: "debug", message, meta }); + }, + }, + }; + + const harness: TestHarness = { + ctx, + seed(input) { + for (const row of input.companies ?? []) companies.set(row.id, row); + for (const row of input.projects ?? []) projects.set(row.id, row); + for (const row of input.issues ?? []) issues.set(row.id, row); + for (const row of input.issueComments ?? []) { + const list = issueComments.get(row.issueId) ?? []; + list.push(row); + issueComments.set(row.issueId, list); + } + for (const row of input.agents ?? []) agents.set(row.id, row); + for (const row of input.goals ?? []) goals.set(row.id, row); + }, + setConfig(config) { + currentConfig = { ...config }; + }, + async emit(eventType, payload, base) { + const event: PluginEvent = { + eventId: base?.eventId ?? randomUUID(), + eventType, + companyId: base?.companyId ?? "test-company", + occurredAt: base?.occurredAt ?? new Date().toISOString(), + actorId: base?.actorId, + actorType: base?.actorType, + entityId: base?.entityId, + entityType: base?.entityType, + payload, + }; + + for (const handler of events) { + const exactMatch = handler.name === event.eventType; + const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin."); + const wildcardPluginOne = String(handler.name).endsWith(".*") + && String(event.eventType).startsWith(String(handler.name).slice(0, -1)); + if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; + if (!allowsEvent(handler.filter, event)) continue; + await handler.fn(event); + } + }, + async runJob(jobKey, partial = {}) { + const handler = jobs.get(jobKey); + if (!handler) throw new Error(`No job handler registered for '${jobKey}'`); + await handler({ + jobKey, + runId: partial.runId ?? randomUUID(), + trigger: partial.trigger ?? "manual", + scheduledAt: partial.scheduledAt ?? new Date().toISOString(), + }); + }, + async getData(key: string, params: Record = {}) { + const handler = dataHandlers.get(key); + if (!handler) throw new Error(`No data handler registered for '${key}'`); + return await handler(params) as T; + }, + async performAction(key: string, params: Record = {}) { + const handler = actionHandlers.get(key); + if (!handler) throw new Error(`No action handler registered for '${key}'`); + return await handler(params) as T; + }, + async executeTool(name: string, params: unknown, runCtx: Partial = {}) { + const handler = toolHandlers.get(name); + if (!handler) throw new Error(`No tool handler registered for '${name}'`); + const ctxToPass: ToolRunContext = { + agentId: runCtx.agentId ?? "agent-test", + runId: runCtx.runId ?? randomUUID(), + companyId: runCtx.companyId ?? "company-test", + projectId: runCtx.projectId ?? "project-test", + }; + return await handler(params, ctxToPass) as T; + }, + getState(input) { + return state.get(stateMapKey(input)); + }, + simulateSessionEvent(sessionId, event) { + const cb = sessionEventCallbacks.get(sessionId); + if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`); + cb({ ...event, sessionId }); + }, + logs, + activity, + metrics, + }; + + return harness; +} diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts new file mode 100644 index 00000000..82940c7f --- /dev/null +++ b/packages/plugins/sdk/src/types.ts @@ -0,0 +1,1116 @@ +/** + * Core types for the Paperclip plugin worker-side SDK. + * + * These types define the stable public API surface that plugin workers import + * from `@paperclipai/plugin-sdk`. The host provides a concrete implementation + * of `PluginContext` to the plugin at initialisation time. + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + * @see PLUGIN_SPEC.md §29.2 — SDK Versioning + */ + +import type { + PaperclipPluginManifestV1, + PluginStateScopeKind, + PluginEventType, + PluginToolDeclaration, + PluginLauncherDeclaration, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; + +// --------------------------------------------------------------------------- +// Re-exports from @paperclipai/shared (plugin authors import from one place) +// --------------------------------------------------------------------------- + +export type { + PaperclipPluginManifestV1, + PluginJobDeclaration, + PluginWebhookDeclaration, + PluginToolDeclaration, + PluginUiSlotDeclaration, + PluginUiDeclaration, + PluginLauncherActionDeclaration, + PluginLauncherRenderDeclaration, + PluginLauncherDeclaration, + PluginMinimumHostVersion, + PluginRecord, + PluginConfig, + JsonSchema, + PluginStatus, + PluginCategory, + PluginCapability, + PluginUiSlotType, + PluginUiSlotEntityType, + PluginLauncherPlacementZone, + PluginLauncherAction, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, + PluginStateScopeKind, + PluginJobStatus, + PluginJobRunStatus, + PluginJobRunTrigger, + PluginWebhookDeliveryStatus, + PluginEventType, + PluginBridgeErrorCode, + Company, + Project, + Issue, + IssueComment, + Agent, + Goal, +} from "@paperclipai/shared"; + +// --------------------------------------------------------------------------- +// Scope key — identifies where plugin state is stored +// --------------------------------------------------------------------------- + +/** + * A scope key identifies the exact location where plugin state is stored. + * Scope is partitioned by `scopeKind` and optional `scopeId`. + * + * Examples: + * - `{ scopeKind: "instance" }` — single global value for the whole instance + * - `{ scopeKind: "project", scopeId: "proj-uuid" }` — per-project state + * - `{ scopeKind: "issue", scopeId: "iss-uuid" }` — per-issue state + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state` + */ +export interface ScopeKey { + /** What kind of Paperclip object this state is scoped to. */ + scopeKind: PluginStateScopeKind; + /** UUID or text identifier for the scoped object. Omit for `instance` scope. */ + scopeId?: string; + /** Optional sub-namespace within the scope to avoid key collisions. Defaults to `"default"`. */ + namespace?: string; + /** The state key within the namespace. */ + stateKey: string; +} + +// --------------------------------------------------------------------------- +// Event types +// --------------------------------------------------------------------------- + +/** + * Optional filter applied when subscribing to an event. The host evaluates + * the filter server-side so filtered-out events never cross the process boundary. + * + * All filter fields are optional. If omitted the plugin receives every event + * of the subscribed type. + * + * @see PLUGIN_SPEC.md §16.1 — Event Filtering + */ +export interface EventFilter { + /** Only receive events for this project. */ + projectId?: string; + /** Only receive events for this company. */ + companyId?: string; + /** Only receive events for this agent. */ + agentId?: string; + /** Additional arbitrary filter fields. */ + [key: string]: unknown; +} + +/** + * Envelope wrapping every domain event delivered to a plugin worker. + * + * @see PLUGIN_SPEC.md §16 — Event System + */ +export interface PluginEvent { + /** Unique event identifier (UUID). */ + eventId: string; + /** The event type (e.g. `"issue.created"`). */ + eventType: PluginEventType | `plugin.${string}`; + /** ISO 8601 timestamp when the event occurred. */ + occurredAt: string; + /** ID of the actor that caused the event, if applicable. */ + actorId?: string; + /** Type of actor: `"user"`, `"agent"`, `"system"`, or `"plugin"`. */ + actorType?: "user" | "agent" | "system" | "plugin"; + /** Primary entity involved in the event. */ + entityId?: string; + /** Type of the primary entity. */ + entityType?: string; + /** UUID of the company this event belongs to. */ + companyId: string; + /** Typed event payload. */ + payload: TPayload; +} + +// --------------------------------------------------------------------------- +// Job context +// --------------------------------------------------------------------------- + +/** + * Context passed to a plugin job handler when the host triggers a scheduled run. + * + * @see PLUGIN_SPEC.md §13.6 — `runJob` + */ +export interface PluginJobContext { + /** Stable job key matching the declaration in the manifest. */ + jobKey: string; + /** UUID for this specific job run instance. */ + runId: string; + /** What triggered this run. */ + trigger: "schedule" | "manual" | "retry"; + /** ISO 8601 timestamp when the run was scheduled to start. */ + scheduledAt: string; +} + +// --------------------------------------------------------------------------- +// Tool run context +// --------------------------------------------------------------------------- + +/** + * Run context passed to a plugin tool handler when an agent invokes the tool. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ToolRunContext { + /** UUID of the agent invoking the tool. */ + agentId: string; + /** UUID of the current agent run. */ + runId: string; + /** UUID of the company the run belongs to. */ + companyId: string; + /** UUID of the project the run belongs to. */ + projectId: string; +} + +/** + * Result returned from a plugin tool handler. + * + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ +export interface ToolResult { + /** String content returned to the agent. Required for success responses. */ + content?: string; + /** Structured data returned alongside or instead of string content. */ + data?: unknown; + /** If present, indicates the tool call failed. */ + error?: string; +} + +// --------------------------------------------------------------------------- +// Plugin entity store +// --------------------------------------------------------------------------- + +/** + * Input for creating or updating a plugin-owned entity. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntityUpsert { + /** Plugin-defined entity type (e.g. `"linear-issue"`, `"github-pr"`). */ + entityType: string; + /** Scope where this entity lives. */ + scopeKind: PluginStateScopeKind; + /** Optional scope ID. */ + scopeId?: string; + /** External identifier in the remote system (e.g. Linear issue ID). */ + externalId?: string; + /** Human-readable title for display in the Paperclip UI. */ + title?: string; + /** Optional status string. */ + status?: string; + /** Full entity data blob. Must be JSON-serializable. */ + data: Record; +} + +/** + * A plugin-owned entity record as returned by `ctx.entities.list()`. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntityRecord { + /** UUID primary key. */ + id: string; + /** Plugin-defined entity type. */ + entityType: string; + /** Scope kind. */ + scopeKind: PluginStateScopeKind; + /** Scope ID, if any. */ + scopeId: string | null; + /** External identifier, if any. */ + externalId: string | null; + /** Human-readable title. */ + title: string | null; + /** Status string. */ + status: string | null; + /** Full entity data. */ + data: Record; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; +} + +/** + * Query parameters for `ctx.entities.list()`. + */ +export interface PluginEntityQuery { + /** Filter by entity type. */ + entityType?: string; + /** Filter by scope kind. */ + scopeKind?: PluginStateScopeKind; + /** Filter by scope ID. */ + scopeId?: string; + /** Filter by external ID. */ + externalId?: string; + /** Maximum number of results to return. */ + limit?: number; + /** Number of results to skip (for pagination). */ + offset?: number; +} + +// --------------------------------------------------------------------------- +// Project workspace metadata (read-only via ctx.projects) +// --------------------------------------------------------------------------- + +/** + * Workspace metadata provided by the host. Plugins use this to resolve local + * filesystem paths for file browsing, git, terminal, and process operations. + * + * @see PLUGIN_SPEC.md §7 — Project Workspaces + * @see PLUGIN_SPEC.md §20 — Local Tooling + */ +export interface PluginWorkspace { + /** UUID primary key. */ + id: string; + /** UUID of the parent project. */ + projectId: string; + /** Display name for this workspace. */ + name: string; + /** Absolute filesystem path to the workspace directory. */ + path: string; + /** Whether this is the project's primary workspace. */ + isPrimary: boolean; + /** ISO 8601 creation timestamp. */ + createdAt: string; + /** ISO 8601 last-updated timestamp. */ + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Host API surfaces exposed via PluginContext +// --------------------------------------------------------------------------- + +/** + * `ctx.config` — read resolved operator configuration for this plugin. + * + * Plugin workers receive the resolved config at initialisation. Use `get()` + * to access the current configuration at any time. The host calls + * `configChanged` on the worker when the operator updates config at runtime. + * + * @see PLUGIN_SPEC.md §13.3 — `validateConfig` + * @see PLUGIN_SPEC.md §13.4 — `configChanged` + */ +export interface PluginConfigClient { + /** + * Returns the resolved operator configuration for this plugin instance. + * Values are validated against the plugin's `instanceConfigSchema` by the + * host before being passed to the worker. + */ + get(): Promise>; +} + +/** + * `ctx.events` — subscribe to and emit Paperclip domain events. + * + * Requires `events.subscribe` capability for `on()`. + * Requires `events.emit` capability for `emit()`. + * + * @see PLUGIN_SPEC.md §16 — Event System + */ +export interface PluginEventsClient { + /** + * Subscribe to a core Paperclip domain event or a plugin-namespaced event. + * + * @param name - Event type, e.g. `"issue.created"` or `"plugin.@acme/linear.sync-done"` + * @param fn - Async event handler + */ + on(name: PluginEventType | `plugin.${string}`, fn: (event: PluginEvent) => Promise): () => void; + + /** + * Subscribe to an event with an optional server-side filter. + * + * @param name - Event type + * @param filter - Server-side filter evaluated before dispatching to the worker + * @param fn - Async event handler + * @returns An unsubscribe function that removes the handler + */ + on(name: PluginEventType | `plugin.${string}`, filter: EventFilter, fn: (event: PluginEvent) => Promise): () => void; + + /** + * Emit a plugin-namespaced event. Other plugins with `events.subscribe` can + * subscribe to it using `"plugin.."`. + * + * Requires the `events.emit` capability. + * + * Plugin-emitted events are automatically namespaced: if the plugin ID is + * `"acme.linear"` and the event name is `"sync-done"`, the full event type + * becomes `"plugin.acme.linear.sync-done"`. + * + * @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events + * + * @param name - Bare event name (e.g. `"sync-done"`) + * @param companyId - UUID of the company this event belongs to + * @param payload - JSON-serializable event payload + */ + emit(name: string, companyId: string, payload: unknown): Promise; +} + +/** + * `ctx.jobs` — register handlers for scheduled jobs declared in the manifest. + * + * Requires `jobs.schedule` capability. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + */ +export interface PluginJobsClient { + /** + * Register a handler for a scheduled job. + * + * The `key` must match a `jobKey` declared in the plugin manifest. + * The host calls this handler according to the job's declared `schedule`. + * + * @param key - Job key matching the manifest declaration + * @param fn - Async job handler + */ + register(key: string, fn: (job: PluginJobContext) => Promise): void; +} + +/** + * A runtime launcher registration uses the same declaration shape as a + * manifest launcher entry. + */ +export type PluginLauncherRegistration = PluginLauncherDeclaration; + +/** + * `ctx.launchers` — register launcher declarations at runtime. + */ +export interface PluginLaunchersClient { + /** + * Register launcher metadata for host discovery. + * + * If a launcher with the same id is registered more than once, the latest + * declaration replaces the previous one. + */ + register(launcher: PluginLauncherRegistration): void; +} + +/** + * `ctx.http` — make outbound HTTP requests. + * + * Requires `http.outbound` capability. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Runtime/Integration + */ +export interface PluginHttpClient { + /** + * Perform an outbound HTTP request. + * + * The host enforces `http.outbound` capability before allowing the call. + * Plugins may also use standard Node `fetch` or other libraries directly — + * this client exists for host-managed tracing and audit logging. + * + * @param url - Target URL + * @param init - Standard `RequestInit` options + * @returns The response + */ + fetch(url: string, init?: RequestInit): Promise; +} + +/** + * `ctx.secrets` — resolve secret references. + * + * Requires `secrets.read-ref` capability. + * + * Plugins store secret *references* in their config (e.g. a secret name). + * This client resolves the reference through the Paperclip secret provider + * system and returns the resolved value at execution time. + * + * @see PLUGIN_SPEC.md §22 — Secrets + */ +export interface PluginSecretsClient { + /** + * Resolve a secret reference to its current value. + * + * The reference is a string identifier pointing to a secret configured + * in the Paperclip secret provider (e.g. `"MY_API_KEY"`). + * + * Secret values are resolved at call time and must never be cached or + * written to logs, config, or other persistent storage. + * + * @param secretRef - The secret reference string from plugin config + * @returns The resolved secret value + */ + resolve(secretRef: string): Promise; +} + +/** + * `ctx.assets` — read and write assets (files, images, etc.). + * + * `assets.read` capability required for `getUrl()`. + * `assets.write` capability required for `upload()`. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write + */ +export interface PluginAssetsClient { + /** + * Upload an asset (e.g. a screenshot or generated file). + * + * @param filename - Name for the asset file + * @param contentType - MIME type + * @param data - Raw asset data as a Buffer or Uint8Array + * @returns The asset ID and public URL + */ + upload(filename: string, contentType: string, data: Buffer | Uint8Array): Promise<{ assetId: string; url: string }>; + + /** + * Get the public URL for an existing asset by ID. + * + * @param assetId - Asset identifier + * @returns The public URL + */ + getUrl(assetId: string): Promise; +} + +/** + * Input for writing a plugin activity log entry. + * + * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes + */ +export interface PluginActivityLogEntry { + /** UUID of the company this activity belongs to. Required for auditing. */ + companyId: string; + /** Human-readable description of the activity. */ + message: string; + /** Optional entity type this activity relates to. */ + entityType?: string; + /** Optional entity ID this activity relates to. */ + entityId?: string; + /** Optional additional metadata. */ + metadata?: Record; +} + +/** + * `ctx.activity` — write plugin-originated activity log entries. + * + * Requires `activity.log.write` capability. + * + * @see PLUGIN_SPEC.md §21.4 — Activity Log Changes + */ +export interface PluginActivityClient { + /** + * Write an activity log entry attributed to this plugin. + * + * The host writes the entry with `actor_type = plugin` and + * `actor_id = `. + * + * @param entry - The activity log entry to write + */ + log(entry: PluginActivityLogEntry): Promise; +} + +/** + * `ctx.state` — read and write plugin-scoped key-value state. + * + * Each plugin gets an isolated namespace: state written by plugin A can never + * be read or overwritten by plugin B. Within a plugin, state is partitioned by + * a five-part composite key: `(pluginId, scopeKind, scopeId, namespace, stateKey)`. + * + * **Scope kinds** + * + * | `scopeKind` | `scopeId` | Typical use | + * |-------------|-----------|-------------| + * | `"instance"` | omit | Global flags, last full-sync timestamps | + * | `"company"` | company UUID | Per-company sync cursors | + * | `"project"` | project UUID | Per-project settings, branch tracking | + * | `"project_workspace"` | workspace UUID | Per-workspace state | + * | `"agent"` | agent UUID | Per-agent memory | + * | `"issue"` | issue UUID | Idempotency keys, linked external IDs | + * | `"goal"` | goal UUID | Per-goal progress | + * | `"run"` | run UUID | Per-run checkpoints | + * + * **Namespaces** + * + * The optional `namespace` field (default: `"default"`) lets you group related + * keys within a scope without risking collisions between different logical + * subsystems inside the same plugin. + * + * **Security** + * + * Never store resolved secret values. Store only secret references and resolve + * them at call time via `ctx.secrets.resolve()`. + * + * @example + * ```ts + * // Instance-global flag + * await ctx.state.set({ scopeKind: "instance", stateKey: "schema-version" }, 2); + * + * // Idempotency key per issue + * const synced = await ctx.state.get({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" }); + * if (!synced) { + * await syncToLinear(issueId); + * await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "synced-to-linear" }, true); + * } + * + * // Per-project, namespaced for two integrations + * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "linear", stateKey: "cursor" }, cursor); + * await ctx.state.set({ scopeKind: "project", scopeId: projectId, namespace: "github", stateKey: "last-event" }, eventId); + * ``` + * + * `plugin.state.read` capability required for `get()`. + * `plugin.state.write` capability required for `set()` and `delete()`. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state` + */ +export interface PluginStateClient { + /** + * Read a state value. + * + * Returns the stored JSON value as-is, or `null` if no entry has been set + * for this scope+key combination. Falsy values (`false`, `0`, `""`) are + * returned correctly and are not confused with "not set". + * + * @param input - Scope key identifying the entry to read + * @returns The stored JSON value, or `null` if no value has been set + */ + get(input: ScopeKey): Promise; + + /** + * Write a state value. Creates the row if it does not exist; replaces it + * atomically (upsert) if it does. Safe to call concurrently. + * + * Any JSON-serializable value is accepted: objects, arrays, strings, + * numbers, booleans, and `null`. + * + * @param input - Scope key identifying the entry to write + * @param value - JSON-serializable value to store + */ + set(input: ScopeKey, value: unknown): Promise; + + /** + * Delete a state value. No-ops silently if the entry does not exist + * (idempotent by design — safe to call without prior `get()`). + * + * @param input - Scope key identifying the entry to delete + */ + delete(input: ScopeKey): Promise; +} + +/** + * `ctx.entities` — create and query plugin-owned entity records. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_entities` + */ +export interface PluginEntitiesClient { + /** + * Create or update a plugin entity record (upsert by `externalId` within + * the given scope, or by `id` if provided). + * + * @param input - Entity data to upsert + */ + upsert(input: PluginEntityUpsert): Promise; + + /** + * Query plugin entity records. + * + * @param query - Filter criteria + * @returns Matching entity records + */ + list(query: PluginEntityQuery): Promise; +} + +/** + * `ctx.projects` — read project and workspace metadata. + * + * Requires `projects.read` capability. + * Requires `project.workspaces.read` capability for workspace operations. + * + * @see PLUGIN_SPEC.md §7 — Project Workspaces + */ +export interface PluginProjectsClient { + /** + * List projects visible to the plugin. + * + * Requires the `projects.read` capability. + */ + list(input: { companyId: string; limit?: number; offset?: number }): Promise; + + /** + * Get a single project by ID. + * + * Requires the `projects.read` capability. + */ + get(projectId: string, companyId: string): Promise; + + /** + * List all workspaces attached to a project. + * + * @param projectId - UUID of the project + * @param companyId - UUID of the company that owns the project + * @returns All workspaces for the project, ordered with primary first + */ + listWorkspaces(projectId: string, companyId: string): Promise; + + /** + * Get the primary workspace for a project. + * + * @param projectId - UUID of the project + * @param companyId - UUID of the company that owns the project + * @returns The primary workspace, or `null` if no workspace is configured + */ + getPrimaryWorkspace(projectId: string, companyId: string): Promise; + + /** + * Resolve the primary workspace for an issue by looking up the issue's + * project and returning its primary workspace. + * + * This is a convenience method that combines `issues.get()` and + * `getPrimaryWorkspace()` in a single RPC call. + * + * @param issueId - UUID of the issue + * @param companyId - UUID of the company that owns the issue + * @returns The primary workspace for the issue's project, or `null` if + * the issue has no project or the project has no workspace + * + * @see PLUGIN_SPEC.md §20 — Local Tooling + */ + getWorkspaceForIssue(issueId: string, companyId: string): Promise; +} + +/** + * `ctx.data` — register `getData` handlers that back `usePluginData()` in the + * plugin's frontend components. + * + * The plugin's UI calls `usePluginData(key, params)` which routes through the + * host bridge to the worker's registered handler. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + */ +export interface PluginDataClient { + /** + * Register a handler for a plugin-defined data key. + * + * @param key - Stable string identifier for this data type (e.g. `"sync-health"`) + * @param handler - Async function that receives request params and returns JSON-serializable data + */ + register(key: string, handler: (params: Record) => Promise): void; +} + +/** + * `ctx.actions` — register `performAction` handlers that back + * `usePluginAction()` in the plugin's frontend components. + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + */ +export interface PluginActionsClient { + /** + * Register a handler for a plugin-defined action key. + * + * @param key - Stable string identifier for this action (e.g. `"resync"`) + * @param handler - Async function that receives action params and returns a result + */ + register(key: string, handler: (params: Record) => Promise): void; +} + +/** + * `ctx.tools` — register handlers for agent tools declared in the manifest. + * + * Requires `agent.tools.register` capability. + * + * Tool names are automatically namespaced by plugin ID at runtime. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + */ +export interface PluginToolsClient { + /** + * Register a handler for a plugin-contributed agent tool. + * + * @param name - Tool name matching the manifest declaration (without namespace prefix) + * @param declaration - Tool metadata (displayName, description, parametersSchema) + * @param fn - Async handler that executes the tool + */ + register( + name: string, + declaration: Pick, + fn: (params: unknown, runCtx: ToolRunContext) => Promise, + ): void; +} + +/** + * `ctx.logger` — structured logging from the plugin worker. + * + * Log output is captured by the host, stored, and surfaced in the plugin + * health dashboard. + * + * @see PLUGIN_SPEC.md §26.1 — Logging + */ +export interface PluginLogger { + /** Log an informational message. */ + info(message: string, meta?: Record): void; + /** Log a warning. */ + warn(message: string, meta?: Record): void; + /** Log an error. */ + error(message: string, meta?: Record): void; + /** Log a debug message (may be suppressed in production). */ + debug(message: string, meta?: Record): void; +} + +// --------------------------------------------------------------------------- +// Plugin metrics +// --------------------------------------------------------------------------- + +/** + * `ctx.metrics` — write plugin-contributed metrics. + * + * Requires `metrics.write` capability. + * + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Data Write + */ +export interface PluginMetricsClient { + /** + * Write a numeric metric data point. + * + * @param name - Metric name (plugin-namespaced by the host) + * @param value - Numeric value + * @param tags - Optional key-value tags for filtering + */ + write(name: string, value: number, tags?: Record): Promise; +} + +/** + * `ctx.companies` — read company metadata. + * + * Requires `companies.read` capability. + */ +export interface PluginCompaniesClient { + /** + * List companies visible to this plugin. + */ + list(input?: { limit?: number; offset?: number }): Promise; + + /** + * Get one company by ID. + */ + get(companyId: string): Promise; +} + +/** + * `ctx.issues` — read and mutate issues plus comments. + * + * Requires: + * - `issues.read` for read operations + * - `issues.create` for create + * - `issues.update` for update + * - `issue.comments.read` for `listComments` + * - `issue.comments.create` for `createComment` + */ +export interface PluginIssuesClient { + list(input: { + companyId: string; + projectId?: string; + assigneeAgentId?: string; + status?: Issue["status"]; + limit?: number; + offset?: number; + }): Promise; + get(issueId: string, companyId: string): Promise; + create(input: { + companyId: string; + projectId?: string; + goalId?: string; + parentId?: string; + title: string; + description?: string; + priority?: Issue["priority"]; + assigneeAgentId?: string; + }): Promise; + update( + issueId: string, + patch: Partial>, + companyId: string, + ): Promise; + listComments(issueId: string, companyId: string): Promise; + createComment(issueId: string, body: string, companyId: string): Promise; +} + +/** + * `ctx.agents` — read and manage agents. + * + * Requires `agents.read` for reads; `agents.pause` / `agents.resume` / + * `agents.invoke` for write operations. + */ +export interface PluginAgentsClient { + list(input: { companyId: string; status?: Agent["status"]; limit?: number; offset?: number }): Promise; + get(agentId: string, companyId: string): Promise; + /** Pause an agent. Throws if agent is terminated or not found. Requires `agents.pause`. */ + pause(agentId: string, companyId: string): Promise; + /** Resume a paused agent (sets status to idle). Throws if terminated, pending_approval, or not found. Requires `agents.resume`. */ + resume(agentId: string, companyId: string): Promise; + /** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */ + invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>; + /** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */ + sessions: PluginAgentSessionsClient; +} + +// --------------------------------------------------------------------------- +// Agent Sessions — two-way chat with agents +// --------------------------------------------------------------------------- + +/** + * Represents an active conversational session with an agent. + * Maps to an `AgentTaskSession` row on the host. + */ +export interface AgentSession { + sessionId: string; + agentId: string; + companyId: string; + status: "active" | "closed"; + createdAt: string; +} + +/** + * A streaming event received during a session's `sendMessage` call. + * Delivered via JSON-RPC notifications from host to worker. + */ +export interface AgentSessionEvent { + sessionId: string; + runId: string; + seq: number; + /** The kind of event: "chunk" for output data, "status" for run state changes, "done" for end-of-stream, "error" for failures. */ + eventType: "chunk" | "status" | "done" | "error"; + stream: "stdout" | "stderr" | "system" | null; + message: string | null; + payload: Record | null; +} + +/** + * Result of sending a message to a session. + */ +export interface AgentSessionSendResult { + runId: string; +} + +/** + * `ctx.agents.sessions` — create, message, and close agent chat sessions. + * + * Requires `agent.sessions.create` for create, `agent.sessions.list` for list, + * `agent.sessions.send` for sendMessage, `agent.sessions.close` for close. + */ +export interface PluginAgentSessionsClient { + /** Create a new conversational session with an agent. Requires `agent.sessions.create`. */ + create(agentId: string, companyId: string, opts?: { + taskKey?: string; + reason?: string; + }): Promise; + + /** List active sessions for an agent owned by this plugin. Requires `agent.sessions.list`. */ + list(agentId: string, companyId: string): Promise; + + /** + * Send a message to a session and receive streaming events via the `onEvent` callback. + * Returns immediately with `{ runId }`. Events are delivered asynchronously. + * Requires `agent.sessions.send`. + */ + sendMessage(sessionId: string, companyId: string, opts: { + prompt: string; + reason?: string; + onEvent?: (event: AgentSessionEvent) => void; + }): Promise; + + /** Close a session, releasing resources. Requires `agent.sessions.close`. */ + close(sessionId: string, companyId: string): Promise; +} + +/** + * `ctx.goals` — read and mutate goals. + * + * Requires: + * - `goals.read` for read operations + * - `goals.create` for create + * - `goals.update` for update + */ +export interface PluginGoalsClient { + list(input: { + companyId: string; + level?: Goal["level"]; + status?: Goal["status"]; + limit?: number; + offset?: number; + }): Promise; + get(goalId: string, companyId: string): Promise; + create(input: { + companyId: string; + title: string; + description?: string; + level?: Goal["level"]; + status?: Goal["status"]; + parentId?: string; + ownerAgentId?: string; + }): Promise; + update( + goalId: string, + patch: Partial>, + companyId: string, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Streaming (worker → UI push channel) +// --------------------------------------------------------------------------- + +/** + * `ctx.streams` — push real-time events from the worker to the plugin UI. + * + * The worker opens a named channel, emits events on it, and closes it when + * done. On the UI side, `usePluginStream(channel)` receives these events in + * real time via SSE. + * + * Streams are scoped to `(pluginId, channel, companyId)`. Multiple UI clients + * can subscribe to the same channel concurrently. + * + * @example + * ```ts + * // Worker: stream chat tokens to the UI + * ctx.streams.open("chat", companyId); + * for await (const token of tokenStream) { + * ctx.streams.emit("chat", { type: "token", text: token }); + * } + * ctx.streams.close("chat"); + * ``` + * + * @see usePluginStream in `@paperclipai/plugin-sdk/ui` + */ +export interface PluginStreamsClient { + /** + * Open a named stream channel. Optional — `emit()` implicitly opens if needed. + * Sends a `stream:open` event to connected UI clients. + */ + open(channel: string, companyId: string): void; + + /** + * Push an event to all UI clients subscribed to this channel. + * + * @param channel - Stream channel name (e.g. `"chat"`, `"logs"`) + * @param event - JSON-serializable event payload + */ + emit(channel: string, event: unknown): void; + + /** + * Close a stream channel. Sends a `stream:close` event to connected UI + * clients so they know no more events will arrive. + */ + close(channel: string): void; +} + +// --------------------------------------------------------------------------- +// Full plugin context +// --------------------------------------------------------------------------- + +/** + * The full plugin context object passed to the plugin worker at initialisation. + * + * This is the central interface plugin authors use to interact with the host. + * Every client is capability-gated: calling a client method without the + * required capability declared in the manifest results in a runtime error. + * + * @example + * ```ts + * import { definePlugin } from "@paperclipai/plugin-sdk"; + * + * export default definePlugin({ + * async setup(ctx) { + * ctx.events.on("issue.created", async (event) => { + * ctx.logger.info("Issue created", { issueId: event.entityId }); + * }); + * + * ctx.data.register("sync-health", async ({ companyId }) => { + * const state = await ctx.state.get({ scopeKind: "company", scopeId: String(companyId), stateKey: "last-sync" }); + * return { lastSync: state }; + * }); + * }, + * }); + * ``` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ +export interface PluginContext { + /** The plugin's manifest as validated at install time. */ + manifest: PaperclipPluginManifestV1; + + /** Read resolved operator configuration. */ + config: PluginConfigClient; + + /** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */ + events: PluginEventsClient; + + /** Register handlers for scheduled jobs. Requires `jobs.schedule`. */ + jobs: PluginJobsClient; + + /** Register launcher metadata that the host can surface in plugin UI entry points. */ + launchers: PluginLaunchersClient; + + /** Make outbound HTTP requests. Requires `http.outbound`. */ + http: PluginHttpClient; + + /** Resolve secret references. Requires `secrets.read-ref`. */ + secrets: PluginSecretsClient; + + /** Read and write assets. Requires `assets.read` / `assets.write`. */ + assets: PluginAssetsClient; + + /** Write activity log entries. Requires `activity.log.write`. */ + activity: PluginActivityClient; + + /** Read and write scoped plugin state. Requires `plugin.state.read` / `plugin.state.write`. */ + state: PluginStateClient; + + /** Create and query plugin-owned entity records. */ + entities: PluginEntitiesClient; + + /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ + projects: PluginProjectsClient; + + /** Read company metadata. Requires `companies.read`. */ + companies: PluginCompaniesClient; + + /** Read and write issues/comments. Requires issue capabilities. */ + issues: PluginIssuesClient; + + /** Read and manage agents. Requires `agents.read` for reads; `agents.pause` / `agents.resume` / `agents.invoke` for write ops. */ + agents: PluginAgentsClient; + + /** Read and mutate goals. Requires `goals.read` for reads; `goals.create` / `goals.update` for write ops. */ + goals: PluginGoalsClient; + + /** Register getData handlers for the plugin's UI components. */ + data: PluginDataClient; + + /** Register performAction handlers for the plugin's UI components. */ + actions: PluginActionsClient; + + /** Push real-time events from the worker to the plugin UI via SSE. */ + streams: PluginStreamsClient; + + /** Register agent tool handlers. Requires `agent.tools.register`. */ + tools: PluginToolsClient; + + /** Write plugin metrics. Requires `metrics.write`. */ + metrics: PluginMetricsClient; + + /** Structured logger. Output is captured and surfaced in the plugin health dashboard. */ + logger: PluginLogger; +} diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts new file mode 100644 index 00000000..b93c1db4 --- /dev/null +++ b/packages/plugins/sdk/src/ui/components.ts @@ -0,0 +1,310 @@ +/** + * Shared UI component declarations for plugin frontends. + * + * These components are exported from `@paperclipai/plugin-sdk/ui` and are + * provided by the host at runtime. They match the host's design tokens and + * visual language, reducing the boilerplate needed to build consistent plugin UIs. + * + * **Plugins are not required to use these components.** They exist to reduce + * boilerplate and keep visual consistency. A plugin may render entirely custom + * UI using any React component library. + * + * Component implementations are provided by the host — plugin bundles contain + * only the type declarations; the runtime implementations are injected via the + * host module registry. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui` + */ + +import type React from "react"; +import { renderSdkUiComponent } from "./runtime.js"; + +// --------------------------------------------------------------------------- +// Component prop interfaces +// --------------------------------------------------------------------------- + +/** + * A trend value that can accompany a metric. + * Positive values indicate upward trends; negative values indicate downward trends. + */ +export interface MetricTrend { + /** Direction of the trend. */ + direction: "up" | "down" | "flat"; + /** Percentage change value (e.g. `12.5` for 12.5%). */ + percentage?: number; +} + +/** Props for `MetricCard`. */ +export interface MetricCardProps { + /** Short label describing the metric (e.g. `"Synced Issues"`). */ + label: string; + /** The metric value to display. */ + value: number | string; + /** Optional trend indicator. */ + trend?: MetricTrend; + /** Optional sparkline data (array of numbers, latest last). */ + sparkline?: number[]; + /** Optional unit suffix (e.g. `"%"`, `"ms"`). */ + unit?: string; +} + +/** Status variants for `StatusBadge`. */ +export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending"; + +/** Props for `StatusBadge`. */ +export interface StatusBadgeProps { + /** Human-readable label. */ + label: string; + /** Visual variant determining colour. */ + status: StatusBadgeVariant; +} + +/** A single column definition for `DataTable`. */ +export interface DataTableColumn> { + /** Column key, matching a field on the row object. */ + key: keyof T & string; + /** Column header label. */ + header: string; + /** Optional custom cell renderer. */ + render?: (value: unknown, row: T) => React.ReactNode; + /** Whether this column is sortable. */ + sortable?: boolean; + /** CSS width (e.g. `"120px"`, `"20%"`). */ + width?: string; +} + +/** Props for `DataTable`. */ +export interface DataTableProps> { + /** Column definitions. */ + columns: DataTableColumn[]; + /** Row data. Each row should have a stable `id` field. */ + rows: T[]; + /** Whether the table is currently loading. */ + loading?: boolean; + /** Message shown when `rows` is empty. */ + emptyMessage?: string; + /** Total row count for pagination (if different from `rows.length`). */ + totalCount?: number; + /** Current page (0-based, for pagination). */ + page?: number; + /** Rows per page (for pagination). */ + pageSize?: number; + /** Callback when page changes. */ + onPageChange?: (page: number) => void; + /** Callback when a column header is clicked to sort. */ + onSort?: (key: string, direction: "asc" | "desc") => void; +} + +/** A single data point for `TimeseriesChart`. */ +export interface TimeseriesDataPoint { + /** ISO 8601 timestamp. */ + timestamp: string; + /** Numeric value. */ + value: number; + /** Optional label for the point. */ + label?: string; +} + +/** Props for `TimeseriesChart`. */ +export interface TimeseriesChartProps { + /** Series data. */ + data: TimeseriesDataPoint[]; + /** Chart title. */ + title?: string; + /** Y-axis label. */ + yLabel?: string; + /** Chart type. Defaults to `"line"`. */ + type?: "line" | "bar"; + /** Height of the chart in pixels. Defaults to `200`. */ + height?: number; + /** Whether the chart is currently loading. */ + loading?: boolean; +} + +/** Props for `MarkdownBlock`. */ +export interface MarkdownBlockProps { + /** Markdown content to render. */ + content: string; +} + +/** A single key-value pair for `KeyValueList`. */ +export interface KeyValuePair { + /** Label for the key. */ + label: string; + /** Value to display. May be a string, number, or a React node. */ + value: React.ReactNode; +} + +/** Props for `KeyValueList`. */ +export interface KeyValueListProps { + /** Pairs to render in the list. */ + pairs: KeyValuePair[]; +} + +/** A single action button for `ActionBar`. */ +export interface ActionBarItem { + /** Button label. */ + label: string; + /** Action key to call via the plugin bridge. */ + actionKey: string; + /** Optional parameters to pass to the action handler. */ + params?: Record; + /** Button variant. Defaults to `"default"`. */ + variant?: "default" | "primary" | "destructive"; + /** Whether to show a confirmation dialog before executing. */ + confirm?: boolean; + /** Text for the confirmation dialog (used when `confirm` is true). */ + confirmMessage?: string; +} + +/** Props for `ActionBar`. */ +export interface ActionBarProps { + /** Action definitions. */ + actions: ActionBarItem[]; + /** Called after an action succeeds. Use to trigger data refresh. */ + onSuccess?: (actionKey: string, result: unknown) => void; + /** Called when an action fails. */ + onError?: (actionKey: string, error: unknown) => void; +} + +/** A single log line for `LogView`. */ +export interface LogViewEntry { + /** ISO 8601 timestamp. */ + timestamp: string; + /** Log level. */ + level: "info" | "warn" | "error" | "debug"; + /** Log message. */ + message: string; + /** Optional structured metadata. */ + meta?: Record; +} + +/** Props for `LogView`. */ +export interface LogViewProps { + /** Log entries to display. */ + entries: LogViewEntry[]; + /** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */ + maxHeight?: string; + /** Whether to auto-scroll to the latest entry. */ + autoScroll?: boolean; + /** Whether the log is currently loading. */ + loading?: boolean; +} + +/** Props for `JsonTree`. */ +export interface JsonTreeProps { + /** The data to render as a collapsible JSON tree. */ + data: unknown; + /** Initial depth to expand. Defaults to `2`. */ + defaultExpandDepth?: number; +} + +/** Props for `Spinner`. */ +export interface SpinnerProps { + /** Size of the spinner. Defaults to `"md"`. */ + size?: "sm" | "md" | "lg"; + /** Accessible label for the spinner (used as `aria-label`). */ + label?: string; +} + +/** Props for `ErrorBoundary`. */ +export interface ErrorBoundaryProps { + /** Content to render inside the error boundary. */ + children: React.ReactNode; + /** Optional custom fallback to render when an error is caught. */ + fallback?: React.ReactNode; + /** Called when an error is caught, for logging or reporting. */ + onError?: (error: Error, info: React.ErrorInfo) => void; +} + +// --------------------------------------------------------------------------- +// Component declarations (provided by host at runtime) +// --------------------------------------------------------------------------- + +// These are declared as ambient values so plugin TypeScript code can import +// and use them with full type-checking. The host's module registry provides +// the concrete React component implementations at bundle load time. + +/** + * Displays a single metric with an optional trend indicator and sparkline. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +function createSdkUiComponent(name: string): React.ComponentType { + return function PaperclipSdkUiComponent(props: TProps) { + return renderSdkUiComponent(name, props) as React.ReactNode; + }; +} + +export const MetricCard = createSdkUiComponent("MetricCard"); + +/** + * Displays an inline status badge (ok / warning / error / info / pending). + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const StatusBadge = createSdkUiComponent("StatusBadge"); + +/** + * Sortable, paginated data table. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const DataTable = createSdkUiComponent("DataTable"); + +/** + * Line or bar chart for time-series data. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const TimeseriesChart = createSdkUiComponent("TimeseriesChart"); + +/** + * Renders Markdown text as HTML. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const MarkdownBlock = createSdkUiComponent("MarkdownBlock"); + +/** + * Renders a definition-list of label/value pairs. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const KeyValueList = createSdkUiComponent("KeyValueList"); + +/** + * Row of action buttons wired to the plugin bridge's `performAction` handlers. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const ActionBar = createSdkUiComponent("ActionBar"); + +/** + * Scrollable, timestamped log output viewer. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const LogView = createSdkUiComponent("LogView"); + +/** + * Collapsible JSON tree for debugging or raw data inspection. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const JsonTree = createSdkUiComponent("JsonTree"); + +/** + * Loading indicator. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const Spinner = createSdkUiComponent("Spinner"); + +/** + * React error boundary that prevents plugin rendering errors from crashing + * the host page. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export const ErrorBoundary = createSdkUiComponent("ErrorBoundary"); diff --git a/packages/plugins/sdk/src/ui/hooks.ts b/packages/plugins/sdk/src/ui/hooks.ts new file mode 100644 index 00000000..fdba2fe3 --- /dev/null +++ b/packages/plugins/sdk/src/ui/hooks.ts @@ -0,0 +1,153 @@ +import type { PluginDataResult, PluginActionFn, PluginHostContext, PluginStreamResult } from "./types.js"; +import { getSdkUiRuntimeValue } from "./runtime.js"; + +// --------------------------------------------------------------------------- +// usePluginData +// --------------------------------------------------------------------------- + +/** + * Fetch data from the plugin worker's registered `getData` handler. + * + * Calls `ctx.data.register(key, handler)` in the worker and returns the + * result as reactive state. Re-fetches when `params` changes. + * + * @template T The expected shape of the returned data + * @param key - The data key matching the handler registered with `ctx.data.register()` + * @param params - Optional parameters forwarded to the handler + * @returns `PluginDataResult` with `data`, `loading`, `error`, and `refresh` + * + * @example + * ```tsx + * function SyncWidget({ context }: PluginWidgetProps) { + * const { data, loading, error } = usePluginData("sync-health", { + * companyId: context.companyId, + * }); + * + * if (loading) return ; + * if (error) return
    Error: {error.message}
    ; + * return ; + * } + * ``` + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export function usePluginData( + key: string, + params?: Record, +): PluginDataResult { + const impl = getSdkUiRuntimeValue< + (nextKey: string, nextParams?: Record) => PluginDataResult + >("usePluginData"); + return impl(key, params); +} + +// --------------------------------------------------------------------------- +// usePluginAction +// --------------------------------------------------------------------------- + +/** + * Get a callable function that invokes the plugin worker's registered + * `performAction` handler. + * + * The returned function is async and throws a `PluginBridgeError` on failure. + * + * @param key - The action key matching the handler registered with `ctx.actions.register()` + * @returns An async function that sends the action to the worker and resolves with the result + * + * @example + * ```tsx + * function ResyncButton({ context }: PluginWidgetProps) { + * const resync = usePluginAction("resync"); + * const [error, setError] = useState(null); + * + * async function handleClick() { + * try { + * await resync({ companyId: context.companyId }); + * } catch (err) { + * setError((err as PluginBridgeError).message); + * } + * } + * + * return ; + * } + * ``` + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export function usePluginAction(key: string): PluginActionFn { + const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction"); + return impl(key); +} + +// --------------------------------------------------------------------------- +// useHostContext +// --------------------------------------------------------------------------- + +/** + * Read the current host context (active company, project, entity, user). + * + * Use this to know which context the plugin component is being rendered in + * so you can scope data requests and actions accordingly. + * + * @returns The current `PluginHostContext` + * + * @example + * ```tsx + * function IssueTab() { + * const { companyId, entityId } = useHostContext(); + * const { data } = usePluginData("linear-link", { issueId: entityId }); + * return
    {data?.linearIssueUrl}
    ; + * } + * ``` + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export function useHostContext(): PluginHostContext { + const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext"); + return impl(); +} + +// --------------------------------------------------------------------------- +// usePluginStream +// --------------------------------------------------------------------------- + +/** + * Subscribe to a real-time event stream pushed from the plugin worker. + * + * Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel` + * and accumulates events as they arrive. The worker pushes events using + * `ctx.streams.emit(channel, event)`. + * + * @template T The expected shape of each streamed event + * @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`) + * @param options - Optional configuration for the stream + * @returns `PluginStreamResult` with `events`, `lastEvent`, connection status, and `close()` + * + * @example + * ```tsx + * function ChatMessages() { + * const { events, connected, close } = usePluginStream("chat-stream"); + * + * return ( + *
    + * {events.map((e, i) => {e.text})} + * {connected && } + * + *
    + * ); + * } + * ``` + * + * @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming + */ +export function usePluginStream( + channel: string, + options?: { companyId?: string }, +): PluginStreamResult { + const impl = getSdkUiRuntimeValue< + (nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult + >("usePluginStream"); + return impl(channel, options); +} diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts new file mode 100644 index 00000000..05fdad63 --- /dev/null +++ b/packages/plugins/sdk/src/ui/index.ts @@ -0,0 +1,125 @@ +/** + * `@paperclipai/plugin-sdk/ui` — Paperclip plugin UI SDK. + * + * Import this subpath from plugin UI bundles (React components that run in + * the host frontend). Do **not** import this from plugin worker code. + * + * The worker-side SDK is available from `@paperclipai/plugin-sdk` (root). + * + * @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK + * @see PLUGIN_SPEC.md §29.2 — SDK Versioning + * + * @example + * ```tsx + * // Plugin UI bundle entry (dist/ui/index.tsx) + * import { + * usePluginData, + * usePluginAction, + * useHostContext, + * MetricCard, + * StatusBadge, + * Spinner, + * } from "@paperclipai/plugin-sdk/ui"; + * import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + * + * export function DashboardWidget({ context }: PluginWidgetProps) { + * const { data, loading, error } = usePluginData("sync-health", { + * companyId: context.companyId, + * }); + * const resync = usePluginAction("resync"); + * + * if (loading) return ; + * if (error) return
    Error: {error.message}
    ; + * + * return ( + *
    + * + * + *
    + * ); + * } + * ``` + */ + +/** + * Bridge hooks for plugin UI components to communicate with the plugin worker. + * + * - `usePluginData(key, params)` — fetch data from the worker's `getData` handler + * - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler + * - `useHostContext()` — read the current active company, project, entity, and user IDs + * - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker + */ +export { + usePluginData, + usePluginAction, + useHostContext, + usePluginStream, +} from "./hooks.js"; + +// Bridge error and host context types +export type { + PluginBridgeError, + PluginBridgeErrorCode, + PluginHostContext, + PluginModalBoundsRequest, + PluginRenderCloseEvent, + PluginRenderCloseHandler, + PluginRenderCloseLifecycle, + PluginRenderEnvironmentContext, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, + PluginDataResult, + PluginActionFn, + PluginStreamResult, +} from "./types.js"; + +// Slot component prop interfaces +export type { + PluginPageProps, + PluginWidgetProps, + PluginDetailTabProps, + PluginSidebarProps, + PluginProjectSidebarItemProps, + PluginCommentAnnotationProps, + PluginCommentContextMenuItemProps, + PluginSettingsPageProps, +} from "./types.js"; + +// Shared UI components +export { + MetricCard, + StatusBadge, + DataTable, + TimeseriesChart, + MarkdownBlock, + KeyValueList, + ActionBar, + LogView, + JsonTree, + Spinner, + ErrorBoundary, +} from "./components.js"; + +// Shared component prop types (for plugin authors who need to extend them) +export type { + MetricCardProps, + MetricTrend, + StatusBadgeProps, + StatusBadgeVariant, + DataTableProps, + DataTableColumn, + TimeseriesChartProps, + TimeseriesDataPoint, + MarkdownBlockProps, + KeyValueListProps, + KeyValuePair, + ActionBarProps, + ActionBarItem, + LogViewProps, + LogViewEntry, + JsonTreeProps, + SpinnerProps, + ErrorBoundaryProps, +} from "./components.js"; diff --git a/packages/plugins/sdk/src/ui/runtime.ts b/packages/plugins/sdk/src/ui/runtime.ts new file mode 100644 index 00000000..998428e9 --- /dev/null +++ b/packages/plugins/sdk/src/ui/runtime.ts @@ -0,0 +1,51 @@ +type PluginBridgeRegistry = { + react?: { + createElement?: (type: unknown, props?: Record | null) => unknown; + } | null; + sdkUi?: Record | null; +}; + +type GlobalBridge = typeof globalThis & { + __paperclipPluginBridge__?: PluginBridgeRegistry; +}; + +function getBridgeRegistry(): PluginBridgeRegistry | undefined { + return (globalThis as GlobalBridge).__paperclipPluginBridge__; +} + +function missingBridgeValueError(name: string): Error { + return new Error( + `Paperclip plugin UI runtime is not initialized for "${name}". ` + + 'Ensure the host loaded the plugin bridge before rendering this UI module.', + ); +} + +export function getSdkUiRuntimeValue(name: string): T { + const value = getBridgeRegistry()?.sdkUi?.[name]; + if (value === undefined) { + throw missingBridgeValueError(name); + } + return value as T; +} + +export function renderSdkUiComponent( + name: string, + props: TProps, +): unknown { + const registry = getBridgeRegistry(); + const component = registry?.sdkUi?.[name]; + if (component === undefined) { + throw missingBridgeValueError(name); + } + + const createElement = registry?.react?.createElement; + if (typeof createElement === "function") { + return createElement(component, props as Record); + } + + if (typeof component === "function") { + return component(props); + } + + throw new Error(`Paperclip plugin UI component "${name}" is not callable`); +} diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts new file mode 100644 index 00000000..267b10b5 --- /dev/null +++ b/packages/plugins/sdk/src/ui/types.ts @@ -0,0 +1,358 @@ +/** + * Paperclip plugin UI SDK — types for plugin frontend components. + * + * Plugin UI bundles import from `@paperclipai/plugin-sdk/ui`. This subpath + * provides the bridge hooks, component prop interfaces, and error types that + * plugin React components use to communicate with the host. + * + * Plugin UI bundles are loaded as ES modules into designated extension slots. + * All communication with the plugin worker goes through the host bridge — plugin + * components must NOT access host internals or call host APIs directly. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + * @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK + * @see PLUGIN_SPEC.md §29.2 — SDK Versioning + */ + +import type { + PluginBridgeErrorCode, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, +} from "@paperclipai/shared"; +import type { + PluginLauncherRenderContextSnapshot, + PluginModalBoundsRequest, + PluginRenderCloseEvent, +} from "../protocol.js"; + +// Re-export PluginBridgeErrorCode for plugin UI authors +export type { + PluginBridgeErrorCode, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, +} from "@paperclipai/shared"; +export type { + PluginLauncherRenderContextSnapshot, + PluginModalBoundsRequest, + PluginRenderCloseEvent, +} from "../protocol.js"; + +// --------------------------------------------------------------------------- +// Bridge error +// --------------------------------------------------------------------------- + +/** + * Structured error returned by the bridge when a UI → worker call fails. + * + * Plugin components receive this in `usePluginData()` as the `error` field + * and may encounter it as a thrown value from `usePluginAction()`. + * + * Error codes: + * - `WORKER_UNAVAILABLE` — plugin worker is not running + * - `CAPABILITY_DENIED` — plugin lacks the required capability + * - `WORKER_ERROR` — worker returned an error from its handler + * - `TIMEOUT` — worker did not respond within the configured timeout + * - `UNKNOWN` — unexpected bridge-level failure + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export interface PluginBridgeError { + /** Machine-readable error code. */ + code: PluginBridgeErrorCode; + /** Human-readable error message. */ + message: string; + /** + * Original error details from the worker, if available. + * Only present when `code === "WORKER_ERROR"`. + */ + details?: unknown; +} + +// --------------------------------------------------------------------------- +// Host context available to all plugin components +// --------------------------------------------------------------------------- + +/** + * Read-only host context passed to every plugin component via `useHostContext()`. + * + * Plugin components use this to know which company, project, or entity is + * currently active so they can scope their data requests accordingly. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export interface PluginHostContext { + /** UUID of the currently active company, if any. */ + companyId: string | null; + /** URL prefix for the current company (e.g. `"my-company"`). */ + companyPrefix: string | null; + /** UUID of the currently active project, if any. */ + projectId: string | null; + /** UUID of the current entity (for detail tab contexts), if any. */ + entityId: string | null; + /** Type of the current entity (e.g. `"issue"`, `"agent"`). */ + entityType: string | null; + /** + * UUID of the parent entity when rendering nested slots. + * For `commentAnnotation` slots this is the issue ID containing the comment. + */ + parentEntityId?: string | null; + /** UUID of the current authenticated user. */ + userId: string | null; + /** Runtime metadata for the host container currently rendering this plugin UI. */ + renderEnvironment?: PluginRenderEnvironmentContext | null; +} + +/** + * Async-capable callback invoked during a host-managed close lifecycle. + */ +export type PluginRenderCloseHandler = ( + event: PluginRenderCloseEvent, +) => void | Promise; + +/** + * Close lifecycle hooks available when the plugin UI is rendered inside a + * host-managed launcher environment. + */ +export interface PluginRenderCloseLifecycle { + /** Register a callback before the host closes the current environment. */ + onBeforeClose?(handler: PluginRenderCloseHandler): () => void; + /** Register a callback after the host closes the current environment. */ + onClose?(handler: PluginRenderCloseHandler): () => void; +} + +/** + * Runtime information about the host container currently rendering a plugin UI. + */ +export interface PluginRenderEnvironmentContext + extends PluginLauncherRenderContextSnapshot { + /** Optional host callback for requesting new bounds while a modal is open. */ + requestModalBounds?(request: PluginModalBoundsRequest): Promise; + /** Optional close lifecycle callbacks for host-managed overlays. */ + closeLifecycle?: PluginRenderCloseLifecycle | null; +} + +// --------------------------------------------------------------------------- +// Slot component prop interfaces +// --------------------------------------------------------------------------- + +/** + * Props passed to a plugin page component. + * + * A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`. + * + * @see PLUGIN_SPEC.md §19.1 — Global Operator Routes + * @see PLUGIN_SPEC.md §19.2 — Company-Context Routes + */ +export interface PluginPageProps { + /** The current host context. */ + context: PluginHostContext; +} + +/** + * Props passed to a plugin dashboard widget component. + * + * A dashboard widget is rendered as a card or section on the main dashboard. + * + * @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets + */ +export interface PluginWidgetProps { + /** The current host context. */ + context: PluginHostContext; +} + +/** + * Props passed to a plugin detail tab component. + * + * A detail tab is rendered as an additional tab on a project, issue, agent, + * goal, or run detail page. + * + * @see PLUGIN_SPEC.md §19.3 — Detail Tabs + */ +export interface PluginDetailTabProps { + /** The current host context, always including `entityId` and `entityType`. */ + context: PluginHostContext & { + entityId: string; + entityType: string; + }; +} + +/** + * Props passed to a plugin sidebar component. + * + * A sidebar entry adds a link or section to the application sidebar. + * + * @see PLUGIN_SPEC.md §19.5 — Sidebar Entries + */ +export interface PluginSidebarProps { + /** The current host context. */ + context: PluginHostContext; +} + +/** + * Props passed to a plugin project sidebar item component. + * + * A project sidebar item is rendered **once per project** under that project's + * row in the sidebar Projects list. The host passes the current project's id + * in `context.entityId` and `context.entityType` is `"project"`. + * + * Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to + * the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`. + * + * @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items + */ +export interface PluginProjectSidebarItemProps { + /** Host context plus entityId (project id) and entityType "project". */ + context: PluginHostContext & { + entityId: string; + entityType: "project"; + }; +} + +/** + * Props passed to a plugin comment annotation component. + * + * A comment annotation is rendered below each individual comment in the + * issue detail timeline. The host passes the comment ID as `entityId` + * and `"comment"` as `entityType`, plus the parent issue ID as + * `parentEntityId` so the plugin can scope data fetches to both. + * + * Use this slot to augment comments with parsed file links, sentiment + * badges, inline actions, or any per-comment metadata. + * + * @see PLUGIN_SPEC.md §19.6 — Comment Annotations + */ +export interface PluginCommentAnnotationProps { + /** Host context with comment and parent issue identifiers. */ + context: PluginHostContext & { + /** UUID of the comment being annotated. */ + entityId: string; + /** Always `"comment"` for comment annotation slots. */ + entityType: "comment"; + /** UUID of the parent issue containing this comment. */ + parentEntityId: string; + }; +} + +/** + * Props passed to a plugin comment context menu item component. + * + * A comment context menu item is rendered in a "more" dropdown menu on + * each comment in the issue detail timeline. The host passes the comment + * ID as `entityId` and `"comment"` as `entityType`, plus the parent + * issue ID as `parentEntityId`. + * + * Use this slot to add per-comment actions such as "Create sub-issue from + * comment", "Translate", "Flag for review", or any custom plugin action. + * + * @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items + */ +export interface PluginCommentContextMenuItemProps { + /** Host context with comment and parent issue identifiers. */ + context: PluginHostContext & { + /** UUID of the comment this menu item acts on. */ + entityId: string; + /** Always `"comment"` for comment context menu item slots. */ + entityType: "comment"; + /** UUID of the parent issue containing this comment. */ + parentEntityId: string; + }; +} + +/** + * Props passed to a plugin settings page component. + * + * Overrides the auto-generated JSON Schema form when the plugin declares + * a `settingsPage` UI slot. The component is responsible for reading and + * writing config through the bridge. + * + * @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI + */ +export interface PluginSettingsPageProps { + /** The current host context. */ + context: PluginHostContext; +} + +// --------------------------------------------------------------------------- +// usePluginData hook return type +// --------------------------------------------------------------------------- + +/** + * Return value of `usePluginData(key, params)`. + * + * Mirrors a standard async data-fetching hook pattern: + * exactly one of `data` or `error` is non-null at any time (unless `loading`). + * + * @template T The type of the data returned by the worker handler + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export interface PluginDataResult { + /** The data returned by the worker's `getData` handler. `null` while loading or on error. */ + data: T | null; + /** `true` while the initial request or a refresh is in flight. */ + loading: boolean; + /** Bridge error if the request failed. `null` on success or while loading. */ + error: PluginBridgeError | null; + /** + * Manually trigger a data refresh. + * Useful for poll-based updates or post-action refreshes. + */ + refresh(): void; +} + +// --------------------------------------------------------------------------- +// usePluginAction hook return type +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// usePluginStream hook return type +// --------------------------------------------------------------------------- + +/** + * Return value of `usePluginStream(channel)`. + * + * Provides a growing array of events pushed from the plugin worker via SSE, + * plus connection status metadata. + * + * @template T The type of each event emitted by the worker + * + * @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming + */ +export interface PluginStreamResult { + /** All events received so far, in arrival order. */ + events: T[]; + /** The most recently received event, or `null` if none yet. */ + lastEvent: T | null; + /** `true` while the SSE connection is being established. */ + connecting: boolean; + /** `true` once the SSE connection is open and receiving events. */ + connected: boolean; + /** Error if the SSE connection failed or was interrupted. `null` otherwise. */ + error: Error | null; + /** Close the SSE connection and stop receiving events. */ + close(): void; +} + +// --------------------------------------------------------------------------- +// usePluginAction hook return type +// --------------------------------------------------------------------------- + +/** + * Return value of `usePluginAction(key)`. + * + * Returns an async function that, when called, sends an action request + * to the worker's `performAction` handler and returns the result. + * + * On failure, the async function throws a `PluginBridgeError`. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + * + * @example + * ```tsx + * const resync = usePluginAction("resync"); + * + * ``` + */ +export type PluginActionFn = (params?: Record) => Promise; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts new file mode 100644 index 00000000..05e5f3b5 --- /dev/null +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -0,0 +1,1221 @@ +/** + * Worker-side RPC host — runs inside the child process spawned by the host. + * + * This module is the worker-side counterpart to the server's + * `PluginWorkerManager`. It: + * + * 1. Reads newline-delimited JSON-RPC 2.0 requests from **stdin** + * 2. Dispatches them to the appropriate plugin handler (events, jobs, tools, …) + * 3. Writes JSON-RPC 2.0 responses back on **stdout** + * 4. Provides a concrete `PluginContext` whose SDK client methods (e.g. + * `ctx.state.get()`, `ctx.events.emit()`) send JSON-RPC requests to the + * host on stdout and await responses on stdin. + * + * ## Message flow + * + * ``` + * Host (parent) Worker (this module) + * | | + * |--- request(initialize) -------------> | → calls plugin.setup(ctx) + * |<-- response(ok:true) ---------------- | + * | | + * |--- request(onEvent) ----------------> | → dispatches to registered handler + * |<-- response(void) ------------------ | + * | | + * |<-- request(state.get) --------------- | ← SDK client call from plugin code + * |--- response(result) ----------------> | + * | | + * |--- request(shutdown) ---------------> | → calls plugin.onShutdown() + * |<-- response(void) ------------------ | + * | (process exits) + * ``` + * + * @see PLUGIN_SPEC.md §12 — Process Model + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + * @see PLUGIN_SPEC.md §14 — SDK Surface + */ + +import path from "node:path"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; + +import type { PaperclipPlugin } from "./define-plugin.js"; +import type { + PluginHealthDiagnostics, + PluginConfigValidationResult, + PluginWebhookInput, +} from "./define-plugin.js"; +import type { + PluginContext, + PluginEvent, + PluginJobContext, + PluginLauncherRegistration, + ScopeKey, + ToolRunContext, + ToolResult, + EventFilter, + AgentSessionEvent, +} from "./types.js"; +import type { + JsonRpcId, + JsonRpcRequest, + JsonRpcResponse, + InitializeParams, + InitializeResult, + ConfigChangedParams, + ValidateConfigParams, + OnEventParams, + RunJobParams, + GetDataParams, + PerformActionParams, + ExecuteToolParams, + WorkerToHostMethodName, + WorkerToHostMethods, +} from "./protocol.js"; +import { + JSONRPC_VERSION, + JSONRPC_ERROR_CODES, + PLUGIN_RPC_ERROR_CODES, + createRequest, + createSuccessResponse, + createErrorResponse, + createNotification, + parseMessage, + serializeMessage, + isJsonRpcRequest, + isJsonRpcResponse, + isJsonRpcNotification, + isJsonRpcSuccessResponse, + isJsonRpcErrorResponse, + JsonRpcParseError, + JsonRpcCallError, +} from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for starting the worker-side RPC host. + */ +export interface WorkerRpcHostOptions { + /** + * The plugin definition returned by `definePlugin()`. + * + * The worker entrypoint should import its plugin and pass it here. + */ + plugin: PaperclipPlugin; + + /** + * Input stream to read JSON-RPC messages from. + * Defaults to `process.stdin`. + */ + stdin?: NodeJS.ReadableStream; + + /** + * Output stream to write JSON-RPC messages to. + * Defaults to `process.stdout`. + */ + stdout?: NodeJS.WritableStream; + + /** + * Default timeout (ms) for worker→host RPC calls. + * Defaults to 30 000 ms. + */ + rpcTimeoutMs?: number; +} + +/** + * A running worker RPC host instance. + * + * Returned by `startWorkerRpcHost()`. Callers (usually just the worker + * bootstrap) hold a reference so they can inspect status or force-stop. + */ +export interface WorkerRpcHost { + /** Whether the host is currently running and listening for messages. */ + readonly running: boolean; + + /** + * Stop the RPC host immediately. Closes readline, rejects pending + * outbound calls, and does NOT call the plugin's shutdown hook (that + * should have already been called via the `shutdown` RPC method). + */ + stop(): void; +} + +// --------------------------------------------------------------------------- +// Internal: event registration +// --------------------------------------------------------------------------- + +interface EventRegistration { + name: string; + filter?: EventFilter; + fn: (event: PluginEvent) => Promise; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default timeout for worker→host RPC calls. */ +const DEFAULT_RPC_TIMEOUT_MS = 30_000; + +// --------------------------------------------------------------------------- +// startWorkerRpcHost +// --------------------------------------------------------------------------- + +/** + * Options for runWorker when testing (optional stdio to avoid using process streams). + * When both stdin and stdout are provided, the "is main module" check is skipped + * and the host is started with these streams. Used by tests. + */ +export interface RunWorkerOptions { + stdin?: NodeJS.ReadableStream; + stdout?: NodeJS.WritableStream; +} + +/** + * Start the worker when this module is the process entrypoint. + * + * Call this at the bottom of your worker file so that when the host runs + * `node dist/worker.js`, the RPC host starts and the process stays alive. + * When the module is imported (e.g. for re-exports or tests), nothing runs. + * + * When `options.stdin` and `options.stdout` are provided (e.g. in tests), + * the main-module check is skipped and the host is started with those streams. + * + * @example + * ```ts + * const plugin = definePlugin({ ... }); + * export default plugin; + * runWorker(plugin, import.meta.url); + * ``` + */ +export function runWorker( + plugin: PaperclipPlugin, + moduleUrl: string, + options?: RunWorkerOptions, +): WorkerRpcHost | void { + if ( + options?.stdin != null && + options?.stdout != null + ) { + return startWorkerRpcHost({ + plugin, + stdin: options.stdin, + stdout: options.stdout, + }); + } + const entry = process.argv[1]; + if (typeof entry !== "string") return; + const thisFile = path.resolve(fileURLToPath(moduleUrl)); + const entryPath = path.resolve(entry); + if (thisFile === entryPath) { + startWorkerRpcHost({ plugin }); + } +} + +/** + * Start the worker-side RPC host. + * + * This function is typically called from a thin bootstrap script that is the + * actual entrypoint of the child process: + * + * ```ts + * // worker-bootstrap.ts + * import plugin from "./worker.js"; + * import { startWorkerRpcHost } from "@paperclipai/plugin-sdk"; + * + * startWorkerRpcHost({ plugin }); + * ``` + * + * The host begins listening on stdin immediately. It does NOT call + * `plugin.definition.setup()` yet — that happens when the host sends the + * `initialize` RPC. + * + * @returns A handle for inspecting or stopping the RPC host + */ +export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost { + const { plugin } = options; + const stdinStream = options.stdin ?? process.stdin; + const stdoutStream = options.stdout ?? process.stdout; + const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + let running = true; + let initialized = false; + let manifest: PaperclipPluginManifestV1 | null = null; + let currentConfig: Record = {}; + + // Plugin handler registrations (populated during setup()) + const eventHandlers: EventRegistration[] = []; + const jobHandlers = new Map Promise>(); + const launcherRegistrations = new Map(); + const dataHandlers = new Map) => Promise>(); + const actionHandlers = new Map) => Promise>(); + const toolHandlers = new Map; + fn: (params: unknown, runCtx: ToolRunContext) => Promise; + }>(); + + // Agent session event callbacks (populated by sendMessage, cleared by close) + const sessionEventCallbacks = new Map void>(); + + // Pending outbound (worker→host) requests + const pendingRequests = new Map void; + timer: ReturnType; + }>(); + let nextOutboundId = 1; + const MAX_OUTBOUND_ID = Number.MAX_SAFE_INTEGER - 1; + + // ----------------------------------------------------------------------- + // Outbound messaging (worker → host) + // ----------------------------------------------------------------------- + + function sendMessage(message: unknown): void { + if (!running) return; + const serialized = serializeMessage(message as any); + stdoutStream.write(serialized); + } + + /** + * Send a typed JSON-RPC request to the host and await the response. + */ + function callHost( + method: M, + params: WorkerToHostMethods[M][0], + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (!running) { + reject(new Error(`Cannot call "${method}" — worker RPC host is not running`)); + return; + } + + if (nextOutboundId >= MAX_OUTBOUND_ID) { + nextOutboundId = 1; + } + const id = nextOutboundId++; + const timeout = timeoutMs ?? rpcTimeoutMs; + let settled = false; + + const settle = (fn: (value: T) => void, value: T): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + pendingRequests.delete(id); + fn(value); + }; + + const timer = setTimeout(() => { + settle( + reject, + new JsonRpcCallError({ + code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, + message: `Worker→host call "${method}" timed out after ${timeout}ms`, + }), + ); + }, timeout); + + pendingRequests.set(id, { + resolve: (response: JsonRpcResponse) => { + if (isJsonRpcSuccessResponse(response)) { + settle(resolve, response.result as WorkerToHostMethods[M][1]); + } else if (isJsonRpcErrorResponse(response)) { + settle(reject, new JsonRpcCallError(response.error)); + } else { + settle(reject, new Error(`Unexpected response format for "${method}"`)); + } + }, + timer, + }); + + try { + const request = createRequest(method, params, id); + sendMessage(request); + } catch (err) { + settle(reject, err instanceof Error ? err : new Error(String(err))); + } + }); + } + + /** + * Send a JSON-RPC notification to the host (fire-and-forget). + */ + function notifyHost(method: string, params: unknown): void { + try { + sendMessage(createNotification(method, params)); + } catch { + // Swallow — the host may have closed stdin + } + } + + // ----------------------------------------------------------------------- + // Build the PluginContext (SDK surface for plugin code) + // ----------------------------------------------------------------------- + + function buildContext(): PluginContext { + return { + get manifest() { + if (!manifest) throw new Error("Plugin context accessed before initialization"); + return manifest; + }, + + config: { + async get() { + return callHost("config.get", {} as Record); + }, + }, + + events: { + on( + name: string, + filterOrFn: EventFilter | ((event: PluginEvent) => Promise), + maybeFn?: (event: PluginEvent) => Promise, + ): () => void { + let registration: EventRegistration; + if (typeof filterOrFn === "function") { + registration = { name, fn: filterOrFn }; + } else { + if (!maybeFn) throw new Error("Event handler function is required"); + registration = { name, filter: filterOrFn, fn: maybeFn }; + } + eventHandlers.push(registration); + return () => { + const idx = eventHandlers.indexOf(registration); + if (idx !== -1) eventHandlers.splice(idx, 1); + }; + }, + + async emit(name: string, companyId: string, payload: unknown): Promise { + await callHost("events.emit", { name, companyId, payload }); + }, + }, + + jobs: { + register(key: string, fn: (job: PluginJobContext) => Promise): void { + jobHandlers.set(key, fn); + }, + }, + + launchers: { + register(launcher: PluginLauncherRegistration): void { + launcherRegistrations.set(launcher.id, launcher); + }, + }, + + http: { + async fetch(url: string, init?: RequestInit): Promise { + const serializedInit: Record = {}; + if (init) { + if (init.method) serializedInit.method = init.method; + if (init.headers) { + // Normalize headers to a plain object + if (init.headers instanceof Headers) { + const obj: Record = {}; + init.headers.forEach((v, k) => { obj[k] = v; }); + serializedInit.headers = obj; + } else if (Array.isArray(init.headers)) { + const obj: Record = {}; + for (const [k, v] of init.headers) obj[k] = v; + serializedInit.headers = obj; + } else { + serializedInit.headers = init.headers; + } + } + if (init.body !== undefined && init.body !== null) { + serializedInit.body = typeof init.body === "string" + ? init.body + : String(init.body); + } + } + + const result = await callHost("http.fetch", { + url, + init: Object.keys(serializedInit).length > 0 ? serializedInit : undefined, + }); + + // Reconstruct a Response-like object from the serialized result + return new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }); + }, + }, + + secrets: { + async resolve(secretRef: string): Promise { + return callHost("secrets.resolve", { secretRef }); + }, + }, + + assets: { + async upload( + filename: string, + contentType: string, + data: Buffer | Uint8Array, + ): Promise<{ assetId: string; url: string }> { + // Base64-encode binary data for JSON serialization + const base64 = Buffer.from(data).toString("base64"); + return callHost("assets.upload", { + filename, + contentType, + data: base64, + }); + }, + + async getUrl(assetId: string): Promise { + return callHost("assets.getUrl", { assetId }); + }, + }, + + activity: { + async log(entry): Promise { + await callHost("activity.log", { + companyId: entry.companyId, + message: entry.message, + entityType: entry.entityType, + entityId: entry.entityId, + metadata: entry.metadata, + }); + }, + }, + + state: { + async get(input: ScopeKey): Promise { + return callHost("state.get", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + }); + }, + + async set(input: ScopeKey, value: unknown): Promise { + await callHost("state.set", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + value, + }); + }, + + async delete(input: ScopeKey): Promise { + await callHost("state.delete", { + scopeKind: input.scopeKind, + scopeId: input.scopeId, + namespace: input.namespace, + stateKey: input.stateKey, + }); + }, + }, + + entities: { + async upsert(input) { + return callHost("entities.upsert", { + entityType: input.entityType, + scopeKind: input.scopeKind, + scopeId: input.scopeId, + externalId: input.externalId, + title: input.title, + status: input.status, + data: input.data, + }); + }, + + async list(query) { + return callHost("entities.list", { + entityType: query.entityType, + scopeKind: query.scopeKind, + scopeId: query.scopeId, + externalId: query.externalId, + limit: query.limit, + offset: query.offset, + }); + }, + }, + + projects: { + async list(input) { + return callHost("projects.list", { + companyId: input.companyId, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(projectId: string, companyId: string) { + return callHost("projects.get", { projectId, companyId }); + }, + + async listWorkspaces(projectId: string, companyId: string) { + return callHost("projects.listWorkspaces", { projectId, companyId }); + }, + + async getPrimaryWorkspace(projectId: string, companyId: string) { + return callHost("projects.getPrimaryWorkspace", { projectId, companyId }); + }, + + async getWorkspaceForIssue(issueId: string, companyId: string) { + return callHost("projects.getWorkspaceForIssue", { issueId, companyId }); + }, + }, + + companies: { + async list(input) { + return callHost("companies.list", { + limit: input?.limit, + offset: input?.offset, + }); + }, + + async get(companyId: string) { + return callHost("companies.get", { companyId }); + }, + }, + + issues: { + async list(input) { + return callHost("issues.list", { + companyId: input.companyId, + projectId: input.projectId, + assigneeAgentId: input.assigneeAgentId, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(issueId: string, companyId: string) { + return callHost("issues.get", { issueId, companyId }); + }, + + async create(input) { + return callHost("issues.create", { + companyId: input.companyId, + projectId: input.projectId, + goalId: input.goalId, + parentId: input.parentId, + title: input.title, + description: input.description, + priority: input.priority, + assigneeAgentId: input.assigneeAgentId, + }); + }, + + async update(issueId: string, patch, companyId: string) { + return callHost("issues.update", { + issueId, + patch: patch as Record, + companyId, + }); + }, + + async listComments(issueId: string, companyId: string) { + return callHost("issues.listComments", { issueId, companyId }); + }, + + async createComment(issueId: string, body: string, companyId: string) { + return callHost("issues.createComment", { issueId, body, companyId }); + }, + }, + + agents: { + async list(input) { + return callHost("agents.list", { + companyId: input.companyId, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(agentId: string, companyId: string) { + return callHost("agents.get", { agentId, companyId }); + }, + + async pause(agentId: string, companyId: string) { + return callHost("agents.pause", { agentId, companyId }); + }, + + async resume(agentId: string, companyId: string) { + return callHost("agents.resume", { agentId, companyId }); + }, + + async invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }) { + return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason }); + }, + + sessions: { + async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) { + return callHost("agents.sessions.create", { + agentId, + companyId, + taskKey: opts?.taskKey, + reason: opts?.reason, + }); + }, + + async list(agentId: string, companyId: string) { + return callHost("agents.sessions.list", { agentId, companyId }); + }, + + async sendMessage(sessionId: string, companyId: string, opts: { + prompt: string; + reason?: string; + onEvent?: (event: AgentSessionEvent) => void; + }) { + if (opts.onEvent) { + sessionEventCallbacks.set(sessionId, opts.onEvent); + } + try { + return await callHost("agents.sessions.sendMessage", { + sessionId, + companyId, + prompt: opts.prompt, + reason: opts.reason, + }); + } catch (err) { + sessionEventCallbacks.delete(sessionId); + throw err; + } + }, + + async close(sessionId: string, companyId: string) { + sessionEventCallbacks.delete(sessionId); + await callHost("agents.sessions.close", { sessionId, companyId }); + }, + }, + }, + + goals: { + async list(input) { + return callHost("goals.list", { + companyId: input.companyId, + level: input.level, + status: input.status, + limit: input.limit, + offset: input.offset, + }); + }, + + async get(goalId: string, companyId: string) { + return callHost("goals.get", { goalId, companyId }); + }, + + async create(input) { + return callHost("goals.create", { + companyId: input.companyId, + title: input.title, + description: input.description, + level: input.level, + status: input.status, + parentId: input.parentId, + ownerAgentId: input.ownerAgentId, + }); + }, + + async update(goalId: string, patch, companyId: string) { + return callHost("goals.update", { + goalId, + patch: patch as Record, + companyId, + }); + }, + }, + + data: { + register(key: string, handler: (params: Record) => Promise): void { + dataHandlers.set(key, handler); + }, + }, + + actions: { + register(key: string, handler: (params: Record) => Promise): void { + actionHandlers.set(key, handler); + }, + }, + + streams: (() => { + // Track channel → companyId so emit/close don't require companyId + const channelCompanyMap = new Map(); + return { + open(channel: string, companyId: string): void { + channelCompanyMap.set(channel, companyId); + notifyHost("streams.open", { channel, companyId }); + }, + emit(channel: string, event: unknown): void { + const companyId = channelCompanyMap.get(channel) ?? ""; + notifyHost("streams.emit", { channel, companyId, event }); + }, + close(channel: string): void { + const companyId = channelCompanyMap.get(channel) ?? ""; + channelCompanyMap.delete(channel); + notifyHost("streams.close", { channel, companyId }); + }, + }; + })(), + + tools: { + register( + name: string, + declaration: Pick, + fn: (params: unknown, runCtx: ToolRunContext) => Promise, + ): void { + toolHandlers.set(name, { declaration, fn }); + }, + }, + + metrics: { + async write(name: string, value: number, tags?: Record): Promise { + await callHost("metrics.write", { name, value, tags }); + }, + }, + + logger: { + info(message: string, meta?: Record): void { + notifyHost("log", { level: "info", message, meta }); + }, + warn(message: string, meta?: Record): void { + notifyHost("log", { level: "warn", message, meta }); + }, + error(message: string, meta?: Record): void { + notifyHost("log", { level: "error", message, meta }); + }, + debug(message: string, meta?: Record): void { + notifyHost("log", { level: "debug", message, meta }); + }, + }, + }; + } + + const ctx = buildContext(); + + // ----------------------------------------------------------------------- + // Inbound message handling (host → worker) + // ----------------------------------------------------------------------- + + /** + * Handle an incoming JSON-RPC request from the host. + * + * Dispatches to the correct handler based on the method name. + */ + async function handleHostRequest(request: JsonRpcRequest): Promise { + const { id, method, params } = request; + + try { + const result = await dispatchMethod(method, params); + sendMessage(createSuccessResponse(id, result ?? null)); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + // Propagate specific error codes from handler errors (e.g. + // METHOD_NOT_FOUND, METHOD_NOT_IMPLEMENTED) — fall back to + // WORKER_ERROR for untyped exceptions. + const errorCode = + typeof (err as any)?.code === "number" + ? (err as any).code + : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; + + sendMessage(createErrorResponse(id, errorCode, errorMessage)); + } + } + + /** + * Dispatch a host→worker method call to the appropriate handler. + */ + async function dispatchMethod(method: string, params: unknown): Promise { + switch (method) { + case "initialize": + return handleInitialize(params as InitializeParams); + + case "health": + return handleHealth(); + + case "shutdown": + return handleShutdown(); + + case "validateConfig": + return handleValidateConfig(params as ValidateConfigParams); + + case "configChanged": + return handleConfigChanged(params as ConfigChangedParams); + + case "onEvent": + return handleOnEvent(params as OnEventParams); + + case "runJob": + return handleRunJob(params as RunJobParams); + + case "handleWebhook": + return handleWebhook(params as PluginWebhookInput); + + case "getData": + return handleGetData(params as GetDataParams); + + case "performAction": + return handlePerformAction(params as PerformActionParams); + + case "executeTool": + return handleExecuteTool(params as ExecuteToolParams); + + default: + throw Object.assign( + new Error(`Unknown method: ${method}`), + { code: JSONRPC_ERROR_CODES.METHOD_NOT_FOUND }, + ); + } + } + + // ----------------------------------------------------------------------- + // Host→Worker method handlers + // ----------------------------------------------------------------------- + + async function handleInitialize(params: InitializeParams): Promise { + if (initialized) { + throw new Error("Worker already initialized"); + } + + manifest = params.manifest; + currentConfig = params.config; + + // Call the plugin's setup function + await plugin.definition.setup(ctx); + + initialized = true; + + // Report which optional methods this plugin implements + const supportedMethods: string[] = []; + if (plugin.definition.onValidateConfig) supportedMethods.push("validateConfig"); + if (plugin.definition.onConfigChanged) supportedMethods.push("configChanged"); + if (plugin.definition.onHealth) supportedMethods.push("health"); + if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); + + return { ok: true, supportedMethods }; + } + + async function handleHealth(): Promise { + if (plugin.definition.onHealth) { + return plugin.definition.onHealth(); + } + // Default: report OK if the worker is alive + return { status: "ok" }; + } + + async function handleShutdown(): Promise { + if (plugin.definition.onShutdown) { + await plugin.definition.onShutdown(); + } + + // Schedule cleanup after we send the response. + // Use setImmediate to let the response flush before exiting. + // Only call process.exit() when running with real process streams. + // When custom streams are provided (tests), just clean up. + setImmediate(() => { + cleanup(); + if (!options.stdin && !options.stdout) { + process.exit(0); + } + }); + } + + async function handleValidateConfig( + params: ValidateConfigParams, + ): Promise { + if (!plugin.definition.onValidateConfig) { + throw Object.assign( + new Error("validateConfig is not implemented by this plugin"), + { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, + ); + } + return plugin.definition.onValidateConfig(params.config); + } + + async function handleConfigChanged(params: ConfigChangedParams): Promise { + currentConfig = params.config; + + if (plugin.definition.onConfigChanged) { + await plugin.definition.onConfigChanged(params.config); + } + } + + async function handleOnEvent(params: OnEventParams): Promise { + const event = params.event; + + for (const registration of eventHandlers) { + // Check event type match + const exactMatch = registration.name === event.eventType; + const wildcardPluginAll = + registration.name === "plugin.*" && + event.eventType.startsWith("plugin."); + const wildcardPluginOne = + registration.name.endsWith(".*") && + event.eventType.startsWith(registration.name.slice(0, -1)); + + if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue; + + // Check filter + if (registration.filter && !allowsEvent(registration.filter, event)) continue; + + try { + await registration.fn(event); + } catch (err) { + // Log error but continue processing other handlers so one failing + // handler doesn't prevent the rest from running. + notifyHost("log", { + level: "error", + message: `Event handler for "${registration.name}" failed: ${ + err instanceof Error ? err.message : String(err) + }`, + meta: { eventType: event.eventType, stack: err instanceof Error ? err.stack : undefined }, + }); + } + } + } + + async function handleRunJob(params: RunJobParams): Promise { + const handler = jobHandlers.get(params.job.jobKey); + if (!handler) { + throw new Error(`No handler registered for job "${params.job.jobKey}"`); + } + await handler(params.job); + } + + async function handleWebhook(params: PluginWebhookInput): Promise { + if (!plugin.definition.onWebhook) { + throw Object.assign( + new Error("handleWebhook is not implemented by this plugin"), + { code: PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED }, + ); + } + await plugin.definition.onWebhook(params); + } + + async function handleGetData(params: GetDataParams): Promise { + const handler = dataHandlers.get(params.key); + if (!handler) { + throw new Error(`No data handler registered for key "${params.key}"`); + } + return handler( + params.renderEnvironment === undefined + ? params.params + : { ...params.params, renderEnvironment: params.renderEnvironment }, + ); + } + + async function handlePerformAction(params: PerformActionParams): Promise { + const handler = actionHandlers.get(params.key); + if (!handler) { + throw new Error(`No action handler registered for key "${params.key}"`); + } + return handler( + params.renderEnvironment === undefined + ? params.params + : { ...params.params, renderEnvironment: params.renderEnvironment }, + ); + } + + async function handleExecuteTool(params: ExecuteToolParams): Promise { + const entry = toolHandlers.get(params.toolName); + if (!entry) { + throw new Error(`No tool handler registered for "${params.toolName}"`); + } + return entry.fn(params.parameters, params.runContext); + } + + // ----------------------------------------------------------------------- + // Event filter helper + // ----------------------------------------------------------------------- + + function allowsEvent(filter: EventFilter, event: PluginEvent): boolean { + const payload = event.payload as Record | undefined; + + if (filter.companyId !== undefined) { + const companyId = event.companyId ?? String(payload?.companyId ?? ""); + if (companyId !== filter.companyId) return false; + } + + if (filter.projectId !== undefined) { + const projectId = event.entityType === "project" + ? event.entityId + : String(payload?.projectId ?? ""); + if (projectId !== filter.projectId) return false; + } + + if (filter.agentId !== undefined) { + const agentId = event.entityType === "agent" + ? event.entityId + : String(payload?.agentId ?? ""); + if (agentId !== filter.agentId) return false; + } + + return true; + } + + // ----------------------------------------------------------------------- + // Inbound response handling (host → worker, response to our outbound call) + // ----------------------------------------------------------------------- + + function handleHostResponse(response: JsonRpcResponse): void { + const id = response.id; + if (id === null || id === undefined) return; + + const pending = pendingRequests.get(id); + if (!pending) return; + + clearTimeout(pending.timer); + pendingRequests.delete(id); + pending.resolve(response); + } + + // ----------------------------------------------------------------------- + // Incoming line handler + // ----------------------------------------------------------------------- + + function handleLine(line: string): void { + if (!line.trim()) return; + + let message: unknown; + try { + message = parseMessage(line); + } catch (err) { + if (err instanceof JsonRpcParseError) { + // Send parse error response + sendMessage( + createErrorResponse( + null, + JSONRPC_ERROR_CODES.PARSE_ERROR, + `Parse error: ${err.message}`, + ), + ); + } + return; + } + + if (isJsonRpcResponse(message)) { + // This is a response to one of our outbound worker→host calls + handleHostResponse(message); + } else if (isJsonRpcRequest(message)) { + // This is a host→worker RPC call — dispatch it + handleHostRequest(message as JsonRpcRequest).catch((err) => { + // Unhandled error in the async handler — send error response + const errorMessage = err instanceof Error ? err.message : String(err); + const errorCode = (err as any)?.code ?? PLUGIN_RPC_ERROR_CODES.WORKER_ERROR; + try { + sendMessage( + createErrorResponse( + (message as JsonRpcRequest).id, + typeof errorCode === "number" ? errorCode : PLUGIN_RPC_ERROR_CODES.WORKER_ERROR, + errorMessage, + ), + ); + } catch { + // Cannot send response, stdout may be closed + } + }); + } else if (isJsonRpcNotification(message)) { + // Dispatch host→worker push notifications + const notif = message as { method: string; params?: unknown }; + if (notif.method === "agents.sessions.event" && notif.params) { + const event = notif.params as AgentSessionEvent; + const cb = sessionEventCallbacks.get(event.sessionId); + if (cb) cb(event); + } + } + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + function cleanup(): void { + running = false; + + // Close readline + if (readline) { + readline.close(); + readline = null; + } + + // Reject all pending outbound calls + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.resolve( + createErrorResponse( + id, + PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, + "Worker RPC host is shutting down", + ) as JsonRpcResponse, + ); + } + pendingRequests.clear(); + sessionEventCallbacks.clear(); + } + + // ----------------------------------------------------------------------- + // Bootstrap: wire up stdin readline + // ----------------------------------------------------------------------- + + let readline: ReadlineInterface | null = createInterface({ + input: stdinStream as NodeJS.ReadableStream, + crlfDelay: Infinity, + }); + + readline.on("line", handleLine); + + // If stdin closes, we should exit gracefully + readline.on("close", () => { + if (running) { + cleanup(); + if (!options.stdin && !options.stdout) { + process.exit(0); + } + } + }); + + // Handle uncaught errors in the worker process. + // Only install these when using the real process streams (not in tests + // where the caller provides custom streams). + if (!options.stdin && !options.stdout) { + process.on("uncaughtException", (err) => { + notifyHost("log", { + level: "error", + message: `Uncaught exception: ${err.message}`, + meta: { stack: err.stack }, + }); + // Give the notification a moment to flush, then exit + setTimeout(() => process.exit(1), 100); + }); + + process.on("unhandledRejection", (reason) => { + const message = reason instanceof Error ? reason.message : String(reason); + const stack = reason instanceof Error ? reason.stack : undefined; + notifyHost("log", { + level: "error", + message: `Unhandled rejection: ${message}`, + meta: { stack }, + }); + }); + } + + // ----------------------------------------------------------------------- + // Return the handle + // ----------------------------------------------------------------------- + + return { + get running() { + return running; + }, + + stop() { + cleanup(); + }, + }; +} diff --git a/packages/plugins/sdk/tsconfig.json b/packages/plugins/sdk/tsconfig.json new file mode 100644 index 00000000..d62ebdff --- /dev/null +++ b/packages/plugins/sdk/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node", "react"] + }, + "include": ["src"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bf2d3665..9aa3a002 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -26,7 +26,6 @@ export const AGENT_ADAPTER_TYPES = [ "http", "claude_local", "codex_local", - "gemini_local", "opencode_local", "pi_local", "cursor", @@ -213,6 +212,9 @@ export const LIVE_EVENT_TYPES = [ "heartbeat.run.log", "agent.status", "activity.logged", + "plugin.ui.updated", + "plugin.worker.crashed", + "plugin.worker.restarted", ] as const; export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; @@ -246,3 +248,311 @@ export const PERMISSION_KEYS = [ "joins:approve", ] as const; export type PermissionKey = (typeof PERMISSION_KEYS)[number]; + +// --------------------------------------------------------------------------- +// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification +// --------------------------------------------------------------------------- + +/** + * The current version of the Plugin API contract. + * + * Increment this value whenever a breaking change is made to the plugin API + * so that the host can reject incompatible plugin manifests. + * + * @see PLUGIN_SPEC.md §4 — Versioning + */ +export const PLUGIN_API_VERSION = 1 as const; + +/** + * Lifecycle statuses for an installed plugin. + * + * State machine: installed → ready | error, ready → disabled | error | upgrade_pending | uninstalled, + * disabled → ready | uninstalled, error → ready | uninstalled, + * upgrade_pending → ready | error | uninstalled, uninstalled → installed (reinstall). + * + * @see {@link PluginStatus} — inferred union type + * @see PLUGIN_SPEC.md §21.3 `plugins.status` + */ +export const PLUGIN_STATUSES = [ + "installed", + "ready", + "disabled", + "error", + "upgrade_pending", + "uninstalled", +] as const; +export type PluginStatus = (typeof PLUGIN_STATUSES)[number]; + +/** + * Plugin classification categories. A plugin declares one or more categories + * in its manifest to describe its primary purpose. + * + * @see PLUGIN_SPEC.md §6.2 + */ +export const PLUGIN_CATEGORIES = [ + "connector", + "workspace", + "automation", + "ui", +] as const; +export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number]; + +/** + * Named permissions the host grants to a plugin. Plugins declare required + * capabilities in their manifest; the host enforces them at runtime via the + * plugin capability validator. + * + * Grouped into: Data Read, Data Write, Plugin State, Runtime/Integration, + * Agent Tools, and UI. + * + * @see PLUGIN_SPEC.md §15 — Capability Model + */ +export const PLUGIN_CAPABILITIES = [ + // Data Read + "companies.read", + "projects.read", + "project.workspaces.read", + "issues.read", + "issue.comments.read", + "agents.read", + "goals.read", + "goals.create", + "goals.update", + "activity.read", + "costs.read", + // Data Write + "issues.create", + "issues.update", + "issue.comments.create", + "agents.pause", + "agents.resume", + "agents.invoke", + "agent.sessions.create", + "agent.sessions.list", + "agent.sessions.send", + "agent.sessions.close", + "assets.write", + "assets.read", + "activity.log.write", + "metrics.write", + // Plugin State + "plugin.state.read", + "plugin.state.write", + // Runtime / Integration + "events.subscribe", + "events.emit", + "jobs.schedule", + "webhooks.receive", + "http.outbound", + "secrets.read-ref", + // Agent Tools + "agent.tools.register", + // UI + "instance.settings.register", + "ui.sidebar.register", + "ui.page.register", + "ui.detailTab.register", + "ui.dashboardWidget.register", + "ui.commentAnnotation.register", + "ui.action.register", +] as const; +export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number]; + +/** + * UI extension slot types. Each slot type corresponds to a mount point in the + * Paperclip UI where plugin components can be rendered. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export const PLUGIN_UI_SLOT_TYPES = [ + "page", + "detailTab", + "taskDetailView", + "dashboardWidget", + "sidebar", + "sidebarPanel", + "projectSidebarItem", + "toolbarButton", + "contextMenuItem", + "commentAnnotation", + "commentContextMenuItem", + "settingsPage", +] as const; +export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number]; + +/** + * Launcher placement zones describe where a plugin-owned launcher can appear + * in the host UI. These are intentionally aligned with current slot surfaces + * so manifest authors can describe launch intent without coupling to a single + * component implementation detail. + */ +export const PLUGIN_LAUNCHER_PLACEMENT_ZONES = [ + "page", + "detailTab", + "taskDetailView", + "dashboardWidget", + "sidebar", + "sidebarPanel", + "projectSidebarItem", + "toolbarButton", + "contextMenuItem", + "commentAnnotation", + "commentContextMenuItem", + "settingsPage", +] as const; +export type PluginLauncherPlacementZone = (typeof PLUGIN_LAUNCHER_PLACEMENT_ZONES)[number]; + +/** + * Launcher action kinds describe what the launcher does when activated. + */ +export const PLUGIN_LAUNCHER_ACTIONS = [ + "navigate", + "openModal", + "openDrawer", + "openPopover", + "performAction", + "deepLink", +] as const; +export type PluginLauncherAction = (typeof PLUGIN_LAUNCHER_ACTIONS)[number]; + +/** + * Optional size hints the host can use when rendering plugin-owned launcher + * destinations such as overlays, drawers, or full page handoffs. + */ +export const PLUGIN_LAUNCHER_BOUNDS = [ + "inline", + "compact", + "default", + "wide", + "full", +] as const; +export type PluginLauncherBounds = (typeof PLUGIN_LAUNCHER_BOUNDS)[number]; + +/** + * Render environments describe the container a launcher expects after it is + * activated. The current host may map these to concrete UI primitives. + */ +export const PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS = [ + "hostInline", + "hostOverlay", + "hostRoute", + "external", + "iframe", +] as const; +export type PluginLauncherRenderEnvironment = + (typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number]; + +/** + * Entity types that a `detailTab` UI slot can attach to. + * + * @see PLUGIN_SPEC.md §19.3 — Detail Tabs + */ +export const PLUGIN_UI_SLOT_ENTITY_TYPES = [ + "project", + "issue", + "agent", + "goal", + "run", + "comment", +] as const; +export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number]; + +/** + * Scope kinds for plugin state storage. Determines the granularity at which + * a plugin stores key-value state data. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state.scope_kind` + */ +export const PLUGIN_STATE_SCOPE_KINDS = [ + "instance", + "company", + "project", + "project_workspace", + "agent", + "issue", + "goal", + "run", +] as const; +export type PluginStateScopeKind = (typeof PLUGIN_STATE_SCOPE_KINDS)[number]; + +/** Statuses for a plugin's scheduled job definition. */ +export const PLUGIN_JOB_STATUSES = [ + "active", + "paused", + "failed", +] as const; +export type PluginJobStatus = (typeof PLUGIN_JOB_STATUSES)[number]; + +/** Statuses for individual job run executions. */ +export const PLUGIN_JOB_RUN_STATUSES = [ + "pending", + "queued", + "running", + "succeeded", + "failed", + "cancelled", +] as const; +export type PluginJobRunStatus = (typeof PLUGIN_JOB_RUN_STATUSES)[number]; + +/** What triggered a particular job run. */ +export const PLUGIN_JOB_RUN_TRIGGERS = [ + "schedule", + "manual", + "retry", +] as const; +export type PluginJobRunTrigger = (typeof PLUGIN_JOB_RUN_TRIGGERS)[number]; + +/** Statuses for inbound webhook deliveries. */ +export const PLUGIN_WEBHOOK_DELIVERY_STATUSES = [ + "pending", + "success", + "failed", +] as const; +export type PluginWebhookDeliveryStatus = (typeof PLUGIN_WEBHOOK_DELIVERY_STATUSES)[number]; + +/** + * Core domain event types that plugins can subscribe to via the + * `events.subscribe` capability. + * + * @see PLUGIN_SPEC.md §16 — Event System + */ +export const PLUGIN_EVENT_TYPES = [ + "company.created", + "company.updated", + "project.created", + "project.updated", + "project.workspace_created", + "project.workspace_updated", + "project.workspace_deleted", + "issue.created", + "issue.updated", + "issue.comment.created", + "agent.created", + "agent.updated", + "agent.status_changed", + "agent.run.started", + "agent.run.finished", + "agent.run.failed", + "agent.run.cancelled", + "goal.created", + "goal.updated", + "approval.created", + "approval.decided", + "cost_event.created", + "activity.logged", +] as const; +export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number]; + +/** + * Error codes returned by the plugin bridge when a UI → worker call fails. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export const PLUGIN_BRIDGE_ERROR_CODES = [ + "WORKER_UNAVAILABLE", + "CAPABILITY_DENIED", + "WORKER_ERROR", + "TIMEOUT", + "UNKNOWN", +] as const; +export type PluginBridgeErrorCode = (typeof PLUGIN_BRIDGE_ERROR_CODES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1a222f27..99315943 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,6 +31,23 @@ export { JOIN_REQUEST_TYPES, JOIN_REQUEST_STATUSES, PERMISSION_KEYS, + PLUGIN_API_VERSION, + PLUGIN_STATUSES, + PLUGIN_CATEGORIES, + PLUGIN_CAPABILITIES, + PLUGIN_UI_SLOT_TYPES, + PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_LAUNCHER_PLACEMENT_ZONES, + PLUGIN_LAUNCHER_ACTIONS, + PLUGIN_LAUNCHER_BOUNDS, + PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS, + PLUGIN_STATE_SCOPE_KINDS, + PLUGIN_JOB_STATUSES, + PLUGIN_JOB_RUN_STATUSES, + PLUGIN_JOB_RUN_TRIGGERS, + PLUGIN_WEBHOOK_DELIVERY_STATUSES, + PLUGIN_EVENT_TYPES, + PLUGIN_BRIDGE_ERROR_CODES, type CompanyStatus, type DeploymentMode, type DeploymentExposure, @@ -61,6 +78,22 @@ export { type JoinRequestType, type JoinRequestStatus, type PermissionKey, + type PluginStatus, + type PluginCategory, + type PluginCapability, + type PluginUiSlotType, + type PluginUiSlotEntityType, + type PluginLauncherPlacementZone, + type PluginLauncherAction, + type PluginLauncherBounds, + type PluginLauncherRenderEnvironment, + type PluginStateScopeKind, + type PluginJobStatus, + type PluginJobRunStatus, + type PluginJobRunTrigger, + type PluginWebhookDeliveryStatus, + type PluginEventType, + type PluginBridgeErrorCode, } from "./constants.js"; export type { @@ -129,6 +162,28 @@ export type { AgentEnvConfig, CompanySecret, SecretProviderDescriptor, + JsonSchema, + PluginJobDeclaration, + PluginWebhookDeclaration, + PluginToolDeclaration, + PluginUiSlotDeclaration, + PluginLauncherActionDeclaration, + PluginLauncherRenderDeclaration, + PluginLauncherRenderContextSnapshot, + PluginLauncherDeclaration, + PluginMinimumHostVersion, + PluginUiDeclaration, + PaperclipPluginManifestV1, + PluginRecord, + PluginStateRecord, + PluginConfig, + PluginCompanySettings, + CompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, } from "./types/index.js"; export { @@ -238,6 +293,45 @@ export { type CompanyPortabilityExport, type CompanyPortabilityPreview, type CompanyPortabilityImport, + jsonSchemaSchema, + pluginJobDeclarationSchema, + pluginWebhookDeclarationSchema, + pluginToolDeclarationSchema, + pluginUiSlotDeclarationSchema, + pluginLauncherActionDeclarationSchema, + pluginLauncherRenderDeclarationSchema, + pluginLauncherDeclarationSchema, + pluginManifestV1Schema, + installPluginSchema, + upsertPluginConfigSchema, + patchPluginConfigSchema, + upsertPluginCompanySettingsSchema, + updateCompanyPluginAvailabilitySchema, + listCompanyPluginAvailabilitySchema, + updatePluginStatusSchema, + uninstallPluginSchema, + pluginStateScopeKeySchema, + setPluginStateSchema, + listPluginStateSchema, + type PluginJobDeclarationInput, + type PluginWebhookDeclarationInput, + type PluginToolDeclarationInput, + type PluginUiSlotDeclarationInput, + type PluginLauncherActionDeclarationInput, + type PluginLauncherRenderDeclarationInput, + type PluginLauncherDeclarationInput, + type PluginManifestV1Input, + type InstallPlugin, + type UpsertPluginConfig, + type PatchPluginConfig, + type UpsertPluginCompanySettings, + type UpdateCompanyPluginAvailability, + type ListCompanyPluginAvailability, + type UpdatePluginStatus, + type UninstallPlugin, + type PluginStateScopeKey, + type SetPluginState, + type ListPluginState, } from "./validators/index.js"; export { API_PREFIX, API } from "./api.js"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 07862c58..7dfb0a33 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -79,3 +79,27 @@ export type { CompanyPortabilityImportResult, CompanyPortabilityExportRequest, } from "./company-portability.js"; +export type { + JsonSchema, + PluginJobDeclaration, + PluginWebhookDeclaration, + PluginToolDeclaration, + PluginUiSlotDeclaration, + PluginLauncherActionDeclaration, + PluginLauncherRenderDeclaration, + PluginLauncherRenderContextSnapshot, + PluginLauncherDeclaration, + PluginMinimumHostVersion, + PluginUiDeclaration, + PaperclipPluginManifestV1, + PluginRecord, + PluginStateRecord, + PluginConfig, + PluginCompanySettings, + CompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, +} from "./plugin.js"; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts new file mode 100644 index 00000000..6254a7bb --- /dev/null +++ b/packages/shared/src/types/plugin.ts @@ -0,0 +1,545 @@ +import type { + PluginStatus, + PluginCategory, + PluginCapability, + PluginUiSlotType, + PluginUiSlotEntityType, + PluginStateScopeKind, + PluginLauncherPlacementZone, + PluginLauncherAction, + PluginLauncherBounds, + PluginLauncherRenderEnvironment, +} from "../constants.js"; + +// --------------------------------------------------------------------------- +// JSON Schema placeholder – plugins declare config schemas as JSON Schema +// --------------------------------------------------------------------------- + +/** + * A JSON Schema object used for plugin config schemas and tool parameter schemas. + * Plugins provide these as plain JSON Schema compatible objects. + */ +export type JsonSchema = Record; + +// --------------------------------------------------------------------------- +// Manifest sub-types — nested declarations within PaperclipPluginManifestV1 +// --------------------------------------------------------------------------- + +/** + * Declares a scheduled job a plugin can run. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + */ +export interface PluginJobDeclaration { + /** Stable identifier for this job, unique within the plugin. */ + jobKey: string; + /** Human-readable name shown in the operator UI. */ + displayName: string; + /** Optional description of what the job does. */ + description?: string; + /** Cron expression for the schedule (e.g. "star/15 star star star star" or "0 * * * *"). */ + schedule?: string; +} + +/** + * Declares a webhook endpoint the plugin can receive. + * Route: `POST /api/plugins/:pluginId/webhooks/:endpointKey` + * + * @see PLUGIN_SPEC.md §18 — Webhooks + */ +export interface PluginWebhookDeclaration { + /** Stable identifier for this endpoint, unique within the plugin. */ + endpointKey: string; + /** Human-readable name shown in the operator UI. */ + displayName: string; + /** Optional description of what this webhook handles. */ + description?: string; +} + +/** + * Declares an agent tool contributed by the plugin. Tools are namespaced + * by plugin ID at runtime (e.g. `linear:search-issues`). + * + * Requires the `agent.tools.register` capability. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + */ +export interface PluginToolDeclaration { + /** Tool name, unique within the plugin. Namespaced by plugin ID at runtime. */ + name: string; + /** Human-readable name shown to agents and in the UI. */ + displayName: string; + /** Description provided to the agent so it knows when to use this tool. */ + description: string; + /** JSON Schema describing the tool's input parameters. */ + parametersSchema: JsonSchema; +} + +/** + * Declares a UI extension slot the plugin fills with a React component. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export interface PluginUiSlotDeclaration { + /** The type of UI mount point (page, detailTab, taskDetailView, toolbarButton, etc.). */ + type: PluginUiSlotType; + /** Unique slot identifier within the plugin. */ + id: string; + /** Human-readable name shown in navigation or tab labels. */ + displayName: string; + /** Which export name in the UI bundle provides this component. */ + exportName: string; + /** + * Entity targets for context-sensitive slots. + * Required for `detailTab`, `taskDetailView`, and `contextMenuItem`. + */ + entityTypes?: PluginUiSlotEntityType[]; + /** + * Optional ordering hint within a slot surface. Lower numbers appear first. + * Defaults to host-defined ordering if omitted. + */ + order?: number; +} + +/** + * Describes the action triggered by a plugin launcher surface. + */ +export interface PluginLauncherActionDeclaration { + /** What kind of launch behavior the host should perform. */ + type: PluginLauncherAction; + /** + * Stable target identifier or URL. The meaning depends on `type` + * (for example a route, tab key, action key, or external URL). + */ + target: string; + /** Optional arbitrary parameters passed along to the target. */ + params?: Record; +} + +/** + * Optional render metadata for the destination opened by a launcher. + */ +export interface PluginLauncherRenderDeclaration { + /** High-level container the launcher expects the host to use. */ + environment: PluginLauncherRenderEnvironment; + /** Optional size hint for the destination surface. */ + bounds?: PluginLauncherBounds; +} + +/** + * Serializable runtime snapshot of the host launcher/container environment. + */ +export interface PluginLauncherRenderContextSnapshot { + /** The current launcher/container environment selected by the host. */ + environment: PluginLauncherRenderEnvironment | null; + /** Launcher id that opened this surface, if any. */ + launcherId: string | null; + /** Current host-applied bounds hint for the environment, if any. */ + bounds: PluginLauncherBounds | null; +} + +/** + * Declares a plugin launcher surface independent of the low-level slot + * implementation that mounts it. + */ +export interface PluginLauncherDeclaration { + /** Stable identifier for this launcher, unique within the plugin. */ + id: string; + /** Human-readable label shown for the launcher. */ + displayName: string; + /** Optional description for operator-facing docs or future UI affordances. */ + description?: string; + /** Where in the host UI this launcher should be placed. */ + placementZone: PluginLauncherPlacementZone; + /** Optional export name in the UI bundle when the launcher has custom UI. */ + exportName?: string; + /** + * Optional entity targeting for context-sensitive launcher zones. + * Reuses the same entity union as UI slots for consistency. + */ + entityTypes?: PluginUiSlotEntityType[]; + /** Optional ordering hint within the placement zone. */ + order?: number; + /** What should happen when the launcher is activated. */ + action: PluginLauncherActionDeclaration; + /** Optional render/container hints for the launched destination. */ + render?: PluginLauncherRenderDeclaration; +} + +/** + * Lower-bound semver requirement for the Paperclip host. + * + * The host should reject installation when its running version is lower than + * the declared minimum. + */ +export type PluginMinimumHostVersion = string; + +/** + * Groups plugin UI declarations that are served from the shared UI bundle + * root declared in `entrypoints.ui`. + */ +export interface PluginUiDeclaration { + /** UI extension slots this plugin fills. */ + slots?: PluginUiSlotDeclaration[]; + /** Declarative launcher metadata for host-mounted plugin entry points. */ + launchers?: PluginLauncherDeclaration[]; +} + +// --------------------------------------------------------------------------- +// Plugin Manifest V1 +// --------------------------------------------------------------------------- + +/** + * The manifest shape every plugin package must export. + * See PLUGIN_SPEC.md §10.1 for the normative definition. + */ +export interface PaperclipPluginManifestV1 { + /** Globally unique plugin identifier (e.g. `"acme.linear-sync"`). Must be lowercase alphanumeric with dots, hyphens, or underscores. */ + id: string; + /** Plugin API version. Must be `1` for the current spec. */ + apiVersion: 1; + /** Semver version of the plugin package (e.g. `"1.2.0"`). */ + version: string; + /** Human-readable name (max 100 chars). */ + displayName: string; + /** Short description (max 500 chars). */ + description: string; + /** Author name (max 200 chars). May include email in angle brackets, e.g. `"Jane Doe "`. */ + author: string; + /** One or more categories classifying this plugin. */ + categories: PluginCategory[]; + /** + * Minimum host version required (semver lower bound). + * Preferred generic field for new manifests. + */ + minimumHostVersion?: PluginMinimumHostVersion; + /** + * Legacy alias for `minimumHostVersion`. + * Kept for backwards compatibility with existing manifests and docs. + */ + minimumPaperclipVersion?: PluginMinimumHostVersion; + /** Capabilities this plugin requires from the host. Enforced at runtime. */ + capabilities: PluginCapability[]; + /** Entrypoint paths relative to the package root. */ + entrypoints: { + /** Path to the worker entrypoint (required). */ + worker: string; + /** Path to the UI bundle directory (required when `ui.slots` is declared). */ + ui?: string; + }; + /** JSON Schema for operator-editable instance configuration. */ + instanceConfigSchema?: JsonSchema; + /** Scheduled jobs this plugin declares. Requires `jobs.schedule` capability. */ + jobs?: PluginJobDeclaration[]; + /** Webhook endpoints this plugin declares. Requires `webhooks.receive` capability. */ + webhooks?: PluginWebhookDeclaration[]; + /** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */ + tools?: PluginToolDeclaration[]; + /** + * Legacy top-level launcher declarations. + * Prefer `ui.launchers` for new manifests. + */ + launchers?: PluginLauncherDeclaration[]; + /** UI bundle declarations. Requires `entrypoints.ui` when populated. */ + ui?: PluginUiDeclaration; +} + +// --------------------------------------------------------------------------- +// Plugin Record – represents a row in the `plugins` table +// --------------------------------------------------------------------------- + +/** + * Domain type for an installed plugin as persisted in the `plugins` table. + * See PLUGIN_SPEC.md §21.3 for the schema definition. + */ +export interface PluginRecord { + /** UUID primary key. */ + id: string; + /** Unique key derived from `manifest.id`. Used for lookups. */ + pluginKey: string; + /** npm package name (e.g. `"@acme/plugin-linear"`). */ + packageName: string; + /** Installed semver version. */ + version: string; + /** Plugin API version from the manifest. */ + apiVersion: number; + /** Plugin categories from the manifest. */ + categories: PluginCategory[]; + /** Full manifest snapshot persisted at install/upgrade time. */ + manifestJson: PaperclipPluginManifestV1; + /** Current lifecycle status. */ + status: PluginStatus; + /** Deterministic load order (null if not yet assigned). */ + installOrder: number | null; + /** Resolved package path for local-path installs; used to find worker entrypoint. */ + packagePath: string | null; + /** Most recent error message, or operator-provided disable reason. */ + lastError: string | null; + /** Timestamp when the plugin was first installed. */ + installedAt: Date; + /** Timestamp of the most recent status or metadata change. */ + updatedAt: Date; +} + +// --------------------------------------------------------------------------- +// Plugin State – represents a row in the `plugin_state` table +// --------------------------------------------------------------------------- + +/** + * Domain type for a single scoped key-value entry in the `plugin_state` table. + * Plugins read and write these entries through `ctx.state` in the SDK. + * + * The five-part composite key `(pluginId, scopeKind, scopeId, namespace, stateKey)` + * uniquely identifies a state entry. + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` + */ +export interface PluginStateRecord { + /** UUID primary key. */ + id: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Granularity of the scope. */ + scopeKind: PluginStateScopeKind; + /** + * UUID or text identifier for the scoped object. + * `null` for `instance` scope (no associated entity). + */ + scopeId: string | null; + /** + * Sub-namespace within the scope to avoid key collisions. + * Defaults to `"default"` if not explicitly set by the plugin. + */ + namespace: string; + /** The key for this state entry within the namespace. */ + stateKey: string; + /** Stored JSON value. May be any JSON-serializable type. */ + valueJson: unknown; + /** Timestamp of the most recent write. */ + updatedAt: Date; +} + +// --------------------------------------------------------------------------- +// Plugin Config – represents a row in the `plugin_config` table +// --------------------------------------------------------------------------- + +/** + * Domain type for a plugin's instance configuration as persisted in the + * `plugin_config` table. + * See PLUGIN_SPEC.md §21.3 for the schema definition. + */ +export interface PluginConfig { + /** UUID primary key. */ + id: string; + /** FK to `plugins.id`. Unique — each plugin has at most one config row. */ + pluginId: string; + /** Operator-provided configuration values (validated against `instanceConfigSchema`). */ + configJson: Record; + /** Most recent config validation error, if any. */ + lastError: string | null; + /** Timestamp when the config row was created. */ + createdAt: Date; + /** Timestamp of the most recent config update. */ + updatedAt: Date; +} + +// --------------------------------------------------------------------------- +// Company Plugin Availability / Settings +// --------------------------------------------------------------------------- + +/** + * Domain type for a plugin's company-scoped settings row as persisted in the + * `plugin_company_settings` table. + * + * This is separate from instance-wide `PluginConfig`: the plugin remains + * installed globally, while each company can store its own plugin settings and + * availability state independently. + */ +export interface PluginCompanySettings { + /** UUID primary key. */ + id: string; + /** FK to `companies.id`. */ + companyId: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Explicit availability override for this company/plugin pair. */ + enabled: boolean; + /** Company-scoped plugin settings payload. */ + settingsJson: Record; + /** Most recent company-scoped validation or availability error, if any. */ + lastError: string | null; + /** Timestamp when the settings row was created. */ + createdAt: Date; + /** Timestamp of the most recent settings update. */ + updatedAt: Date; +} + +/** + * API response shape describing whether a plugin is available to a specific + * company and, when present, the company-scoped settings row backing that + * availability. + */ +export interface CompanyPluginAvailability { + companyId: string; + pluginId: string; + /** Stable manifest/plugin key for display and route generation. */ + pluginKey: string; + /** Human-readable plugin name. */ + pluginDisplayName: string; + /** Current instance-wide plugin lifecycle status. */ + pluginStatus: PluginStatus; + /** + * Whether the plugin is currently available to the company. + * When no `plugin_company_settings` row exists yet, the plugin is enabled + * by default for the company. + */ + available: boolean; + /** Company-scoped settings, defaulting to an empty object when unavailable. */ + settingsJson: Record; + /** Most recent company-scoped error, if any. */ + lastError: string | null; + /** Present when availability is backed by a persisted settings row. */ + createdAt: Date | null; + /** Present when availability is backed by a persisted settings row. */ + updatedAt: Date | null; +} + +/** + * Query filter for `ctx.entities.list`. + */ +export interface PluginEntityQuery { + /** Optional filter by entity type (e.g. 'project', 'issue'). */ + entityType?: string; + /** Optional filter by external system identifier. */ + externalId?: string; + /** Maximum number of records to return. Defaults to 100. */ + limit?: number; + /** Number of records to skip. Defaults to 0. */ + offset?: number; +} + +// --------------------------------------------------------------------------- +// Plugin Entity – represents a row in the `plugin_entities` table +// --------------------------------------------------------------------------- + +/** + * Domain type for an external entity mapping as persisted in the `plugin_entities` table. + */ +export interface PluginEntityRecord { + /** UUID primary key. */ + id: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Plugin-defined entity type. */ + entityType: string; + /** Scope where this entity lives. */ + scopeKind: PluginStateScopeKind; + /** UUID or text identifier for the scoped object. */ + scopeId: string | null; + /** External identifier in the remote system. */ + externalId: string | null; + /** Human-readable title. */ + title: string | null; + /** Optional status string. */ + status: string | null; + /** Full entity data blob. */ + data: Record; + /** ISO 8601 creation timestamp. */ + createdAt: Date; + /** ISO 8601 last-updated timestamp. */ + updatedAt: Date; +} + +// --------------------------------------------------------------------------- +// Plugin Job – represents a row in the `plugin_jobs` table +// --------------------------------------------------------------------------- + +/** + * Domain type for a registered plugin job as persisted in the `plugin_jobs` table. + */ +export interface PluginJobRecord { + /** UUID primary key. */ + id: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Job key matching the manifest declaration. */ + jobKey: string; + /** Cron expression for the schedule. */ + schedule: string; + /** Current job status. */ + status: "active" | "paused" | "failed"; + /** Last time the job was executed. */ + lastRunAt: Date | null; + /** Next scheduled execution time. */ + nextRunAt: Date | null; + /** ISO 8601 creation timestamp. */ + createdAt: Date; + /** ISO 8601 last-updated timestamp. */ + updatedAt: Date; +} + +// --------------------------------------------------------------------------- +// Plugin Job Run – represents a row in the `plugin_job_runs` table +// --------------------------------------------------------------------------- + +/** + * Domain type for a job execution history record. + */ +export interface PluginJobRunRecord { + /** UUID primary key. */ + id: string; + /** FK to `plugin_jobs.id`. */ + jobId: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** What triggered this run. */ + trigger: "schedule" | "manual" | "retry"; + /** Current run status. */ + status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; + /** Run duration in milliseconds. */ + durationMs: number | null; + /** Error message if the run failed. */ + error: string | null; + /** Run logs. */ + logs: string[]; + /** ISO 8601 start timestamp. */ + startedAt: Date | null; + /** ISO 8601 finish timestamp. */ + finishedAt: Date | null; + /** ISO 8601 creation timestamp. */ + createdAt: Date; +} + +// --------------------------------------------------------------------------- +// Plugin Webhook Delivery – represents a row in the `plugin_webhook_deliveries` table +// --------------------------------------------------------------------------- + +/** + * Domain type for an inbound webhook delivery record. + */ +export interface PluginWebhookDeliveryRecord { + /** UUID primary key. */ + id: string; + /** FK to `plugins.id`. */ + pluginId: string; + /** Webhook endpoint key matching the manifest. */ + webhookKey: string; + /** External identifier from the remote system. */ + externalId: string | null; + /** Delivery status. */ + status: "pending" | "success" | "failed"; + /** Processing duration in milliseconds. */ + durationMs: number | null; + /** Error message if processing failed. */ + error: string | null; + /** Webhook payload. */ + payload: Record; + /** Webhook headers. */ + headers: Record; + /** ISO 8601 start timestamp. */ + startedAt: Date | null; + /** ISO 8601 finish timestamp. */ + finishedAt: Date | null; + /** ISO 8601 creation timestamp. */ + createdAt: Date; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ad74a1e8..687dd1ad 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -137,3 +137,45 @@ export { type UpdateMemberPermissions, type UpdateUserCompanyAccess, } from "./access.js"; + +export { + jsonSchemaSchema, + pluginJobDeclarationSchema, + pluginWebhookDeclarationSchema, + pluginToolDeclarationSchema, + pluginUiSlotDeclarationSchema, + pluginLauncherActionDeclarationSchema, + pluginLauncherRenderDeclarationSchema, + pluginLauncherDeclarationSchema, + pluginManifestV1Schema, + installPluginSchema, + upsertPluginConfigSchema, + patchPluginConfigSchema, + upsertPluginCompanySettingsSchema, + updateCompanyPluginAvailabilitySchema, + listCompanyPluginAvailabilitySchema, + updatePluginStatusSchema, + uninstallPluginSchema, + pluginStateScopeKeySchema, + setPluginStateSchema, + listPluginStateSchema, + type PluginJobDeclarationInput, + type PluginWebhookDeclarationInput, + type PluginToolDeclarationInput, + type PluginUiSlotDeclarationInput, + type PluginLauncherActionDeclarationInput, + type PluginLauncherRenderDeclarationInput, + type PluginLauncherDeclarationInput, + type PluginManifestV1Input, + type InstallPlugin, + type UpsertPluginConfig, + type PatchPluginConfig, + type UpsertPluginCompanySettings, + type UpdateCompanyPluginAvailability, + type ListCompanyPluginAvailability, + type UpdatePluginStatus, + type UninstallPlugin, + type PluginStateScopeKey, + type SetPluginState, + type ListPluginState, +} from "./plugin.js"; diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts new file mode 100644 index 00000000..be9b2d5b --- /dev/null +++ b/packages/shared/src/validators/plugin.ts @@ -0,0 +1,694 @@ +import { z } from "zod"; +import { + PLUGIN_STATUSES, + PLUGIN_CATEGORIES, + PLUGIN_CAPABILITIES, + PLUGIN_UI_SLOT_TYPES, + PLUGIN_UI_SLOT_ENTITY_TYPES, + PLUGIN_LAUNCHER_PLACEMENT_ZONES, + PLUGIN_LAUNCHER_ACTIONS, + PLUGIN_LAUNCHER_BOUNDS, + PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS, + PLUGIN_STATE_SCOPE_KINDS, +} from "../constants.js"; + +// --------------------------------------------------------------------------- +// JSON Schema placeholder – a permissive validator for JSON Schema objects +// --------------------------------------------------------------------------- + +/** + * Permissive validator for JSON Schema objects. Accepts any `Record` + * that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`). + * Empty objects are also accepted. + * + * Used to validate `instanceConfigSchema` and `parametersSchema` fields in the + * plugin manifest without fully parsing JSON Schema. + * + * @see PLUGIN_SPEC.md §10.1 — Manifest shape + */ +export const jsonSchemaSchema = z.record(z.unknown()).refine( + (val) => { + // Must have a "type" field if non-empty, or be a valid JSON Schema object + if (Object.keys(val).length === 0) return true; + return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined; + }, + { message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" }, +); + +// --------------------------------------------------------------------------- +// Manifest sub-type schemas +// --------------------------------------------------------------------------- + +/** + * Validates a {@link PluginJobDeclaration} — a scheduled job declared in the + * plugin manifest. Requires `jobKey` and `displayName`; `description` and + * `schedule` (cron expression) are optional. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + */ +/** + * Validates a cron expression has exactly 5 whitespace-separated fields, + * each containing only valid cron characters (digits, *, /, -, ,). + * + * Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists. + */ +const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/; + +function isValidCronExpression(expression: string): boolean { + const trimmed = expression.trim(); + if (!trimmed) return false; + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((f) => CRON_FIELD_PATTERN.test(f)); +} + +export const pluginJobDeclarationSchema = z.object({ + jobKey: z.string().min(1), + displayName: z.string().min(1), + description: z.string().optional(), + schedule: z.string().refine( + (val) => isValidCronExpression(val), + { message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" }, + ).optional(), +}); + +export type PluginJobDeclarationInput = z.infer; + +/** + * Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared + * in the plugin manifest. Requires `endpointKey` and `displayName`. + * + * @see PLUGIN_SPEC.md §18 — Webhooks + */ +export const pluginWebhookDeclarationSchema = z.object({ + endpointKey: z.string().min(1), + displayName: z.string().min(1), + description: z.string().optional(), +}); + +export type PluginWebhookDeclarationInput = z.infer; + +/** + * Validates a {@link PluginToolDeclaration} — an agent tool contributed by the + * plugin. Requires `name`, `displayName`, `description`, and a valid + * `parametersSchema`. Requires the `agent.tools.register` capability. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + */ +export const pluginToolDeclarationSchema = z.object({ + name: z.string().min(1), + displayName: z.string().min(1), + description: z.string().min(1), + parametersSchema: jsonSchemaSchema, +}); + +export type PluginToolDeclarationInput = z.infer; + +/** + * Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin + * fills with a React component. Includes `superRefine` checks for slot-specific + * requirements such as `entityTypes` for context-sensitive slots. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export const pluginUiSlotDeclarationSchema = z.object({ + type: z.enum(PLUGIN_UI_SLOT_TYPES), + id: z.string().min(1), + displayName: z.string().min(1), + exportName: z.string().min(1), + entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(), + order: z.number().int().optional(), +}).superRefine((value, ctx) => { + // context-sensitive slots require explicit entity targeting. + const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"]; + if ( + entityScopedTypes.includes(value.type) + && (!value.entityTypes || value.entityTypes.length === 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.type} slots require at least one entityType`, + path: ["entityTypes"], + }); + } + // projectSidebarItem only makes sense for entityType "project". + if (value.type === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "projectSidebarItem slots require entityTypes to include \"project\"", + path: ["entityTypes"], + }); + } + // commentAnnotation only makes sense for entityType "comment". + if (value.type === "commentAnnotation" && value.entityTypes && !value.entityTypes.includes("comment")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "commentAnnotation slots require entityTypes to include \"comment\"", + path: ["entityTypes"], + }); + } + // commentContextMenuItem only makes sense for entityType "comment". + if (value.type === "commentContextMenuItem" && value.entityTypes && !value.entityTypes.includes("comment")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "commentContextMenuItem slots require entityTypes to include \"comment\"", + path: ["entityTypes"], + }); + } +}); + +export type PluginUiSlotDeclarationInput = z.infer; + +const entityScopedLauncherPlacementZones = [ + "detailTab", + "taskDetailView", + "contextMenuItem", + "commentAnnotation", + "commentContextMenuItem", + "projectSidebarItem", +] as const; + +const launcherBoundsByEnvironment: Record< + (typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number], + readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][] +> = { + hostInline: ["inline", "compact", "default"], + hostOverlay: ["compact", "default", "wide", "full"], + hostRoute: ["default", "wide", "full"], + external: [], + iframe: ["compact", "default", "wide", "full"], +}; + +/** + * Validates the action payload for a declarative plugin launcher. + */ +export const pluginLauncherActionDeclarationSchema = z.object({ + type: z.enum(PLUGIN_LAUNCHER_ACTIONS), + target: z.string().min(1), + params: z.record(z.unknown()).optional(), +}).superRefine((value, ctx) => { + if (value.type === "performAction" && value.target.includes("/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "performAction launchers must target an action key, not a route or URL", + path: ["target"], + }); + } + + if (value.type === "navigate" && /^https?:\/\//.test(value.target)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "navigate launchers must target a host route, not an absolute URL", + path: ["target"], + }); + } +}); + +export type PluginLauncherActionDeclarationInput = + z.infer; + +/** + * Validates optional render hints for a plugin launcher destination. + */ +export const pluginLauncherRenderDeclarationSchema = z.object({ + environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS), + bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(), +}).superRefine((value, ctx) => { + if (!value.bounds) { + return; + } + + const supportedBounds = launcherBoundsByEnvironment[value.environment]; + if (!supportedBounds.includes(value.bounds)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`, + path: ["bounds"], + }); + } +}); + +export type PluginLauncherRenderDeclarationInput = + z.infer; + +/** + * Validates declarative launcher metadata in a plugin manifest. + */ +export const pluginLauncherDeclarationSchema = z.object({ + id: z.string().min(1), + displayName: z.string().min(1), + description: z.string().optional(), + placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES), + exportName: z.string().min(1).optional(), + entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(), + order: z.number().int().optional(), + action: pluginLauncherActionDeclarationSchema, + render: pluginLauncherRenderDeclarationSchema.optional(), +}).superRefine((value, ctx) => { + if ( + entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone) + && (!value.entityTypes || value.entityTypes.length === 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.placementZone} launchers require at least one entityType`, + path: ["entityTypes"], + }); + } + + if ( + value.placementZone === "projectSidebarItem" + && value.entityTypes + && !value.entityTypes.includes("project") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "projectSidebarItem launchers require entityTypes to include \"project\"", + path: ["entityTypes"], + }); + } + + if (value.action.type === "performAction" && value.render) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "performAction launchers cannot declare render hints", + path: ["render"], + }); + } + + if ( + ["openModal", "openDrawer", "openPopover"].includes(value.action.type) + && !value.render + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.action.type} launchers require render metadata`, + path: ["render"], + }); + } + + if (value.action.type === "openModal" && value.render?.environment === "hostInline") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "openModal launchers cannot use the hostInline render environment", + path: ["render", "environment"], + }); + } + + if ( + value.action.type === "openDrawer" + && value.render + && !["hostOverlay", "iframe"].includes(value.render.environment) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "openDrawer launchers must use hostOverlay or iframe render environments", + path: ["render", "environment"], + }); + } + + if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "openPopover launchers cannot use the hostRoute render environment", + path: ["render", "environment"], + }); + } +}); + +export type PluginLauncherDeclarationInput = z.infer; + +// --------------------------------------------------------------------------- +// Plugin Manifest V1 schema +// --------------------------------------------------------------------------- + +/** + * Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime + * validator for plugin manifests read at install time. + * + * Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules): + * + * | Field | Type | Constraints | + * |--------------------------|------------|----------------------------------------------| + * | `id` | string | `^[a-z0-9][a-z0-9._-]*$` | + * | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` | + * | `version` | string | semver (`\d+\.\d+\.\d+`) | + * | `displayName` | string | 1–100 chars | + * | `description` | string | 1–500 chars | + * | `author` | string | 1–200 chars | + * | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES | + * | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`| + * | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` | + * | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES| + * | `entrypoints.worker` | string | min 1 char | + * | `entrypoints.ui` | string? | required when `ui.slots` is declared | + * + * Cross-field rules enforced via `superRefine`: + * - `entrypoints.ui` required when `ui.slots` declared + * - `agent.tools.register` capability required when `tools` declared + * - `jobs.schedule` capability required when `jobs` declared + * - `webhooks.receive` capability required when `webhooks` declared + * - duplicate `jobs[].jobKey` values are rejected + * - duplicate `webhooks[].endpointKey` values are rejected + * - duplicate `tools[].name` values are rejected + * - duplicate `ui.slots[].id` values are rejected + * + * @see PLUGIN_SPEC.md §10.1 — Manifest shape + * @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type + */ +export const pluginManifestV1Schema = z.object({ + id: z.string().min(1).regex( + /^[a-z0-9][a-z0-9._-]*$/, + "Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores", + ), + apiVersion: z.literal(1), + version: z.string().min(1).regex( + /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, + "Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)", + ), + displayName: z.string().min(1).max(100), + description: z.string().min(1).max(500), + author: z.string().min(1).max(200), + categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1), + minimumHostVersion: z.string().regex( + /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, + "minimumHostVersion must follow semver (e.g. 1.0.0)", + ).optional(), + minimumPaperclipVersion: z.string().regex( + /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, + "minimumPaperclipVersion must follow semver (e.g. 1.0.0)", + ).optional(), + capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1), + entrypoints: z.object({ + worker: z.string().min(1), + ui: z.string().min(1).optional(), + }), + instanceConfigSchema: jsonSchemaSchema.optional(), + jobs: z.array(pluginJobDeclarationSchema).optional(), + webhooks: z.array(pluginWebhookDeclarationSchema).optional(), + tools: z.array(pluginToolDeclarationSchema).optional(), + launchers: z.array(pluginLauncherDeclarationSchema).optional(), + ui: z.object({ + slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), + launchers: z.array(pluginLauncherDeclarationSchema).optional(), + }).optional(), +}).superRefine((manifest, ctx) => { + // ── Entrypoint ↔ UI slot consistency ────────────────────────────────── + // Plugins that declare UI slots must also declare a UI entrypoint so the + // host knows where to load the bundle from (PLUGIN_SPEC.md §10.1). + const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0; + const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0; + if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "entrypoints.ui is required when ui.slots or ui.launchers are declared", + path: ["entrypoints", "ui"], + }); + } + + if ( + manifest.minimumHostVersion + && manifest.minimumPaperclipVersion + && manifest.minimumHostVersion !== manifest.minimumPaperclipVersion + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared", + path: ["minimumHostVersion"], + }); + } + + // ── Capability ↔ feature declaration consistency ─────────────────────── + // The host enforces capabilities at install and runtime. A plugin must + // declare every capability it needs up-front; silently having more features + // than capabilities would cause runtime rejections. + + // tools require agent.tools.register (PLUGIN_SPEC.md §11) + if (manifest.tools && manifest.tools.length > 0) { + if (!manifest.capabilities.includes("agent.tools.register")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'agent.tools.register' is required when tools are declared", + path: ["capabilities"], + }); + } + } + + // jobs require jobs.schedule (PLUGIN_SPEC.md §17) + if (manifest.jobs && manifest.jobs.length > 0) { + if (!manifest.capabilities.includes("jobs.schedule")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'jobs.schedule' is required when jobs are declared", + path: ["capabilities"], + }); + } + } + + // webhooks require webhooks.receive (PLUGIN_SPEC.md §18) + if (manifest.webhooks && manifest.webhooks.length > 0) { + if (!manifest.capabilities.includes("webhooks.receive")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'webhooks.receive' is required when webhooks are declared", + path: ["capabilities"], + }); + } + } + + // ── Uniqueness checks ────────────────────────────────────────────────── + // Duplicate keys within a plugin's own manifest are always a bug. The host + // would not know which declaration takes precedence, so we reject early. + + // job keys must be unique within the plugin (used as identifiers in the DB) + if (manifest.jobs) { + const jobKeys = manifest.jobs.map((j) => j.jobKey); + const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["jobs"], + }); + } + } + + // webhook endpoint keys must be unique within the plugin (used in routes) + if (manifest.webhooks) { + const endpointKeys = manifest.webhooks.map((w) => w.endpointKey); + const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["webhooks"], + }); + } + } + + // tool names must be unique within the plugin (namespaced at runtime) + if (manifest.tools) { + const toolNames = manifest.tools.map((t) => t.name); + const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`, + path: ["tools"], + }); + } + } + + // UI slot ids must be unique within the plugin (namespaced at runtime) + if (manifest.ui) { + if (manifest.ui.slots) { + const slotIds = manifest.ui.slots.map((s) => s.id); + const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`, + path: ["ui", "slots"], + }); + } + } + } + + // launcher ids must be unique within the plugin + const allLaunchers = [ + ...(manifest.launchers ?? []), + ...(manifest.ui?.launchers ?? []), + ]; + if (allLaunchers.length > 0) { + const launcherIds = allLaunchers.map((launcher) => launcher.id); + const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`, + path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"], + }); + } + } +}); + +export type PluginManifestV1Input = z.infer; + +// --------------------------------------------------------------------------- +// Plugin installation / registration request +// --------------------------------------------------------------------------- + +/** + * Schema for installing (registering) a plugin. + * The server receives the packageName and resolves the manifest from the + * installed package. + */ +export const installPluginSchema = z.object({ + packageName: z.string().min(1), + version: z.string().min(1).optional(), + /** Set by loader for local-path installs so the worker can be resolved. */ + packagePath: z.string().min(1).optional(), +}); + +export type InstallPlugin = z.infer; + +// --------------------------------------------------------------------------- +// Plugin config (instance configuration) schemas +// --------------------------------------------------------------------------- + +/** + * Schema for creating or updating a plugin's instance configuration. + * configJson is validated permissively here; runtime validation against + * the plugin's instanceConfigSchema is done at the service layer. + */ +export const upsertPluginConfigSchema = z.object({ + configJson: z.record(z.unknown()), +}); + +export type UpsertPluginConfig = z.infer; + +/** + * Schema for partially updating a plugin's instance configuration. + * Allows a partial merge of config values. + */ +export const patchPluginConfigSchema = z.object({ + configJson: z.record(z.unknown()), +}); + +export type PatchPluginConfig = z.infer; + +// --------------------------------------------------------------------------- +// Company plugin availability / settings schemas +// --------------------------------------------------------------------------- + +/** + * Schema for creating or replacing company-scoped plugin settings. + * + * Company-specific settings are stored separately from instance-level + * `plugin_config`, allowing the host to expose a company availability toggle + * without changing the global install state of the plugin. + */ +export const upsertPluginCompanySettingsSchema = z.object({ + settingsJson: z.record(z.unknown()).optional().default({}), + lastError: z.string().nullable().optional(), +}); + +export type UpsertPluginCompanySettings = z.infer; + +/** + * Schema for mutating a plugin's availability for a specific company. + * + * `available=false` lets callers disable access without uninstalling the + * plugin globally. Optional `settingsJson` supports carrying company-specific + * configuration alongside the availability update. + */ +export const updateCompanyPluginAvailabilitySchema = z.object({ + available: z.boolean(), + settingsJson: z.record(z.unknown()).optional(), + lastError: z.string().nullable().optional(), +}); + +export type UpdateCompanyPluginAvailability = z.infer; + +/** + * Query schema for company plugin availability list endpoints. + */ +export const listCompanyPluginAvailabilitySchema = z.object({ + available: z.boolean().optional(), +}); + +export type ListCompanyPluginAvailability = z.infer; + +// --------------------------------------------------------------------------- +// Plugin status update +// --------------------------------------------------------------------------- + +/** + * Schema for updating a plugin's lifecycle status. Used by the lifecycle + * manager to persist state transitions. + * + * @see {@link PLUGIN_STATUSES} for the valid status values + */ +export const updatePluginStatusSchema = z.object({ + status: z.enum(PLUGIN_STATUSES), + lastError: z.string().nullable().optional(), +}); + +export type UpdatePluginStatus = z.infer; + +// --------------------------------------------------------------------------- +// Plugin uninstall +// --------------------------------------------------------------------------- + +/** Schema for the uninstall request. `removeData` controls hard vs soft delete. */ +export const uninstallPluginSchema = z.object({ + removeData: z.boolean().optional().default(false), +}); + +export type UninstallPlugin = z.infer; + +// --------------------------------------------------------------------------- +// Plugin state (key-value storage) schemas +// --------------------------------------------------------------------------- + +/** + * Schema for a plugin state scope key — identifies the exact location where + * state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and + * `ctx.state.delete()` SDK methods. + * + * @see PLUGIN_SPEC.md §21.3 `plugin_state` + */ +export const pluginStateScopeKeySchema = z.object({ + scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS), + scopeId: z.string().min(1).optional(), + namespace: z.string().min(1).optional(), + stateKey: z.string().min(1), +}); + +export type PluginStateScopeKey = z.infer; + +/** + * Schema for setting a plugin state value. + */ +export const setPluginStateSchema = z.object({ + scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS), + scopeId: z.string().min(1).optional(), + namespace: z.string().min(1).optional(), + stateKey: z.string().min(1), + /** JSON-serializable value to store. */ + value: z.unknown(), +}); + +export type SetPluginState = z.infer; + +/** + * Schema for querying plugin state entries. All fields are optional to allow + * flexible list queries (e.g. all state for a plugin within a scope). + */ +export const listPluginStateSchema = z.object({ + scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(), + scopeId: z.string().min(1).optional(), + namespace: z.string().min(1).optional(), +}); + +export type ListPluginState = z.infer; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 014e1412..4ac3cfcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - packages/* - packages/adapters/* + - packages/plugins/* + - packages/plugins/examples/* - server - ui - cli diff --git a/scripts/ensure-plugin-build-deps.mjs b/scripts/ensure-plugin-build-deps.mjs new file mode 100644 index 00000000..f8470da1 --- /dev/null +++ b/scripts/ensure-plugin-build-deps.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); +const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc"); + +const buildTargets = [ + { + name: "@paperclipai/shared", + output: path.join(rootDir, "packages/shared/dist/index.js"), + tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"), + }, + { + name: "@paperclipai/plugin-sdk", + output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"), + tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"), + }, +]; + +if (!fs.existsSync(tscCliPath)) { + throw new Error(`TypeScript CLI not found at ${tscCliPath}`); +} + +for (const target of buildTargets) { + if (fs.existsSync(target.output)) { + continue; + } + + const result = spawnSync(process.execPath, [tscCliPath, "-p", target.tsconfig], { + cwd: rootDir, + stdio: "inherit", + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/server/package.json b/server/package.json index cd30cf13..2e56ae5d 100644 --- a/server/package.json +++ b/server/package.json @@ -30,7 +30,7 @@ "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", @@ -43,7 +43,10 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", + "@paperclipai/plugin-sdk": "workspace:*", "@paperclipai/shared": "workspace:*", + "ajv": "^8.18.0", + "ajv-formats": "^3.0.1", "better-auth": "1.4.18", "detect-port": "^2.1.0", "dotenv": "^17.0.1", diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts new file mode 100644 index 00000000..53ed57bb --- /dev/null +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { appendStderrExcerpt, formatWorkerFailureMessage } from "../services/plugin-worker-manager.js"; + +describe("plugin-worker-manager stderr failure context", () => { + it("appends worker stderr context to failure messages", () => { + expect( + formatWorkerFailureMessage( + "Worker process exited (code=1, signal=null)", + "TypeError: Unknown file extension \".ts\"", + ), + ).toBe( + "Worker process exited (code=1, signal=null)\n\nWorker stderr:\nTypeError: Unknown file extension \".ts\"", + ); + }); + + it("does not duplicate stderr that is already present", () => { + const message = [ + "Worker process exited (code=1, signal=null)", + "", + "Worker stderr:", + "TypeError: Unknown file extension \".ts\"", + ].join("\n"); + + expect( + formatWorkerFailureMessage(message, "TypeError: Unknown file extension \".ts\""), + ).toBe(message); + }); + + it("keeps only the latest stderr excerpt", () => { + let excerpt = ""; + excerpt = appendStderrExcerpt(excerpt, "first line"); + excerpt = appendStderrExcerpt(excerpt, "second line"); + + expect(excerpt).toContain("first line"); + expect(excerpt).toContain("second line"); + + excerpt = appendStderrExcerpt(excerpt, "x".repeat(9_000)); + + expect(excerpt).not.toContain("first line"); + expect(excerpt).not.toContain("second line"); + expect(excerpt.length).toBeLessThanOrEqual(8_000); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 6871552a..52e06782 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -24,7 +24,23 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { llmRoutes } from "./routes/llms.js"; import { assetRoutes } from "./routes/assets.js"; import { accessRoutes } from "./routes/access.js"; +import { pluginRoutes } from "./routes/plugins.js"; +import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; +import { logger } from "./middleware/logger.js"; +import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; +import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; +import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; +import { pluginJobStore } from "./services/plugin-job-store.js"; +import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js"; +import { pluginLifecycleManager } from "./services/plugin-lifecycle.js"; +import { createPluginJobCoordinator } from "./services/plugin-job-coordinator.js"; +import { buildHostServices, flushPluginLogBuffer } from "./services/plugin-host-services.js"; +import { createPluginEventBus } from "./services/plugin-event-bus.js"; +import { createPluginDevWatcher } from "./services/plugin-dev-watcher.js"; +import { createPluginHostServiceCleanup } from "./services/plugin-host-service-cleanup.js"; +import { pluginRegistryService } from "./services/plugin-registry.js"; +import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; type UiMode = "none" | "static" | "vite-dev"; @@ -41,13 +57,20 @@ export async function createApp( bindHost: string; authReady: boolean; companyDeletionEnabled: boolean; + instanceId?: string; + hostVersion?: string; + localPluginDir?: string; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; }, ) { const app = express(); - app.use(express.json()); + app.use(express.json({ + verify: (req, _res, buf) => { + (req as unknown as { rawBody: Buffer }).rawBody = buf; + }, + })); app.use(httpLogger); const privateHostnameGateEnabled = opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; @@ -114,6 +137,75 @@ export async function createApp( api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); + const hostServicesDisposers = new Map void>(); + const workerManager = createPluginWorkerManager(); + const pluginRegistry = pluginRegistryService(db); + const eventBus = createPluginEventBus({ + async isPluginEnabledForCompany(pluginKey, companyId) { + const plugin = await pluginRegistry.getByKey(pluginKey); + if (!plugin) return false; + const availability = await pluginRegistry.getCompanyAvailability(companyId, plugin.id); + return availability?.available ?? true; + }, + }); + const jobStore = pluginJobStore(db); + const lifecycle = pluginLifecycleManager(db, { workerManager }); + const scheduler = createPluginJobScheduler({ + db, + jobStore, + workerManager, + }); + const toolDispatcher = createPluginToolDispatcher({ + workerManager, + lifecycleManager: lifecycle, + db, + }); + const jobCoordinator = createPluginJobCoordinator({ + db, + lifecycle, + scheduler, + jobStore, + }); + const hostServiceCleanup = createPluginHostServiceCleanup(lifecycle, hostServicesDisposers); + const loader = pluginLoader( + db, + { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR }, + { + workerManager, + eventBus, + jobScheduler: scheduler, + jobStore, + toolDispatcher, + lifecycleManager: lifecycle, + instanceInfo: { + instanceId: opts.instanceId ?? "default", + hostVersion: opts.hostVersion ?? "0.0.0", + }, + buildHostHandlers: (pluginId, manifest) => { + const notifyWorker = (method: string, params: unknown) => { + const handle = workerManager.getWorker(pluginId); + if (handle) handle.notify(method, params); + }; + const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker); + hostServicesDisposers.set(pluginId, () => services.dispose()); + return createHostClientHandlers({ + pluginId, + capabilities: manifest.capabilities, + services, + }); + }, + }, + ); + api.use( + pluginRoutes( + db, + loader, + { scheduler, jobStore }, + { workerManager }, + { toolDispatcher }, + { workerManager }, + ), + ); api.use( accessRoutes(db, { deploymentMode: opts.deploymentMode, @@ -126,6 +218,9 @@ export async function createApp( app.use("/api", (_req, res) => { res.status(404).json({ error: "API route not found" }); }); + app.use(pluginUiStaticRoutes(db, { + localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, + })); const __dirname = path.dirname(fileURLToPath(import.meta.url)); if (opts.uiMode === "static") { @@ -179,5 +274,33 @@ export async function createApp( app.use(errorHandler); + jobCoordinator.start(); + scheduler.start(); + void toolDispatcher.initialize().catch((err) => { + logger.error({ err }, "Failed to initialize plugin tool dispatcher"); + }); + const devWatcher = createPluginDevWatcher( + lifecycle, + async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, + ); + void loader.loadAll().then((result) => { + if (!result) return; + for (const loaded of result.results) { + if (loaded.success && loaded.plugin.packagePath) { + devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath); + } + } + }).catch((err) => { + logger.error({ err }, "Failed to load ready plugins on startup"); + }); + process.once("exit", () => { + devWatcher.close(); + hostServiceCleanup.disposeAll(); + hostServiceCleanup.teardown(); + }); + process.once("beforeExit", () => { + void flushPluginLogBuffer(); + }); + return app; } diff --git a/server/src/routes/plugin-ui-static.ts b/server/src/routes/plugin-ui-static.ts new file mode 100644 index 00000000..2784f593 --- /dev/null +++ b/server/src/routes/plugin-ui-static.ts @@ -0,0 +1,496 @@ +/** + * @fileoverview Plugin UI static file serving route + * + * Serves plugin UI bundles from the plugin's dist/ui/ directory under the + * `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md + * §19.0.3 (Bundle Serving). + * + * Plugin UI bundles are pre-built ESM that the host serves as static assets. + * The host dynamically imports the plugin's UI entry module from this path, + * resolves the named export declared in `ui.slots[].exportName`, and mounts + * it into the extension slot. + * + * Security: + * - Path traversal is prevented by resolving the requested path and verifying + * it stays within the plugin's UI directory. + * - Only plugins in 'ready' status have their UI served. + * - Only plugins that declare `entrypoints.ui` serve UI bundles. + * + * Cache Headers: + * - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`) + * receive `Cache-Control: public, max-age=31536000, immutable`. + * - Other files receive `Cache-Control: public, max-age=0, must-revalidate` + * with ETag-based conditional request support. + * + * @module server/routes/plugin-ui-static + * @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving + * @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation + */ + +import { Router } from "express"; +import path from "node:path"; +import fs from "node:fs"; +import crypto from "node:crypto"; +import type { Db } from "@paperclipai/db"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Regex to detect content-hashed filenames. + * + * Matches patterns like: + * - `index-a1b2c3d4.js` + * - `styles.abc123def.css` + * - `chunk-ABCDEF01.mjs` + * + * The hash portion must be at least 8 hex characters to avoid false positives. + */ +const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/; + +/** + * Cache-Control header for content-hashed files. + * These files are immutable by definition (the hash changes when content changes). + */ +/** 1 year in seconds — standard for content-hashed immutable resources. */ +const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000 +const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`; + +/** + * Cache-Control header for non-hashed files. + * These files must be revalidated on each request (ETag-based). + */ +const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate"; + +/** + * MIME types for common plugin UI bundle file extensions. + */ +const MIME_TYPES: Record = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", + ".ico": "image/x-icon", + ".txt": "text/plain; charset=utf-8", +}; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/** + * Resolve a plugin's UI directory from its package location. + * + * The plugin's `packageName` is stored in the DB. We resolve the package path + * from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in + * `node_modules`. If the plugin was installed from a local path, the manifest + * `entrypoints.ui` path is resolved relative to the package directory. + * + * @param localPluginDir - The plugin installation directory + * @param packageName - The npm package name + * @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/") + * @returns Absolute path to the UI directory, or null if not found + */ +export function resolvePluginUiDir( + localPluginDir: string, + packageName: string, + entrypointsUi: string, + packagePath?: string | null, +): string | null { + // For local-path installs, prefer the persisted package path. + if (packagePath) { + const resolvedPackagePath = path.resolve(packagePath); + if (fs.existsSync(resolvedPackagePath)) { + const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi); + if ( + uiDirFromPackagePath.startsWith(resolvedPackagePath) + && fs.existsSync(uiDirFromPackagePath) + ) { + return uiDirFromPackagePath; + } + } + } + + // Resolve the package root within the local plugin directory's node_modules. + // npm installs go to /node_modules// + let packageRoot: string; + if (packageName.startsWith("@")) { + // Scoped package: @scope/name -> node_modules/@scope/name + packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/")); + } else { + packageRoot = path.join(localPluginDir, "node_modules", packageName); + } + + // If the standard location doesn't exist, the plugin may have been installed + // from a local path. Try to check if the package.json is accessible at the + // computed path or if the package is found elsewhere. + if (!fs.existsSync(packageRoot)) { + // For local-path installs, the packageName may be a directory that doesn't + // live inside node_modules. Check if the package exists directly at the + // localPluginDir level. + const directPath = path.join(localPluginDir, packageName); + if (fs.existsSync(directPath)) { + packageRoot = directPath; + } else { + return null; + } + } + + // Resolve the UI directory relative to the package root + const uiDir = path.resolve(packageRoot, entrypointsUi); + + // Verify the resolved UI directory exists and is actually inside the package + if (!fs.existsSync(uiDir)) { + return null; + } + + return uiDir; +} + +/** + * Compute an ETag from file stat (size + mtime). + * This is a lightweight approach that avoids reading the file content. + */ +function computeETag(size: number, mtimeMs: number): string { + const ETAG_VERSION = "v2"; + const hash = crypto + .createHash("md5") + .update(`${ETAG_VERSION}:${size}-${mtimeMs}`) + .digest("hex") + .slice(0, 16); + return `"${hash}"`; +} + +// --------------------------------------------------------------------------- +// Route factory +// --------------------------------------------------------------------------- + +/** + * Options for the plugin UI static route. + */ +export interface PluginUiStaticRouteOptions { + /** + * The local plugin installation directory. + * This is where plugins are installed via `npm install --prefix`. + * Defaults to the standard `~/.paperclip/plugins/` location. + */ + localPluginDir: string; +} + +/** + * Create an Express router that serves plugin UI static files. + * + * This route handles `GET /_plugins/:pluginId/ui/*` requests by: + * 1. Looking up the plugin in the registry by ID or key + * 2. Verifying the plugin is in 'ready' status with UI declared + * 3. Resolving the file path within the plugin's dist/ui/ directory + * 4. Serving the file with appropriate cache headers + * + * @param db - Database connection for plugin registry lookups + * @param options - Configuration options + * @returns Express router + */ +export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) { + const router = Router(); + const registry = pluginRegistryService(db); + const log = logger.child({ service: "plugin-ui-static" }); + + /** + * GET /_plugins/:pluginId/ui/* + * + * Serve a static file from a plugin's UI bundle directory. + * + * The :pluginId parameter accepts either: + * - Database UUID + * - Plugin key (e.g., "acme.linear") + * + * The wildcard captures the relative file path within the UI directory. + * + * Cache strategy: + * - Content-hashed filenames → immutable, 1-year max-age + * - Other files → must-revalidate with ETag + */ + router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => { + const { pluginId } = req.params; + + // Extract the relative file path from the named wildcard. + // In Express 5 with path-to-regexp v8, named wildcards may return + // an array of path segments or a single string. + const rawParam = req.params.filePath; + const rawFilePath = Array.isArray(rawParam) + ? rawParam.join("/") + : rawParam as string | undefined; + + if (!rawFilePath || rawFilePath.length === 0) { + res.status(400).json({ error: "File path is required" }); + return; + } + + // Step 1: Look up the plugin + let plugin = null; + try { + plugin = await registry.getById(pluginId); + } catch (error) { + const maybeCode = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (maybeCode !== "22P02") { + throw error; + } + } + if (!plugin) { + plugin = await registry.getByKey(pluginId); + } + + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Step 2: Verify the plugin is ready and has UI declared + if (plugin.status !== "ready") { + res.status(403).json({ + error: `Plugin UI is not available (status: ${plugin.status})`, + }); + return; + } + + const manifest = plugin.manifestJson; + if (!manifest?.entrypoints?.ui) { + res.status(404).json({ error: "Plugin does not declare a UI bundle" }); + return; + } + + // Step 2b: Check for devUiUrl in plugin config — proxy to local dev server + // when a plugin author has configured a dev server URL for hot-reload. + // See PLUGIN_SPEC.md §27.2 — Local Development Workflow + try { + const configRow = await registry.getConfig(plugin.id); + const devUiUrl = + configRow && + typeof configRow === "object" && + "configJson" in configRow && + (configRow as { configJson: Record }).configJson?.devUiUrl; + + if (typeof devUiUrl === "string" && devUiUrl.length > 0) { + // Dev proxy is only available in development mode + if (process.env.NODE_ENV === "production") { + log.warn( + { pluginId: plugin.id }, + "plugin-ui-static: devUiUrl ignored in production", + ); + // Fall through to static file serving below + } else { + // Guard against rawFilePath overriding the base URL via protocol + // scheme (e.g. "https://evil.com/x") or protocol-relative paths + // (e.g. "//evil.com/x") which cause `new URL(path, base)` to + // ignore the base entirely. + // Normalize percent-encoding so encoded slashes (%2F) can't bypass + // the protocol/path checks below. + let decodedPath: string; + try { + decodedPath = decodeURIComponent(rawFilePath); + } catch { + res.status(400).json({ error: "Invalid file path" }); + return; + } + if ( + decodedPath.includes("://") || + decodedPath.startsWith("//") || + decodedPath.startsWith("\\\\") + ) { + res.status(400).json({ error: "Invalid file path" }); + return; + } + + // Proxy the request to the dev server + const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/"); + + // SSRF protection: only allow http/https and localhost targets for dev proxy + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + res.status(400).json({ error: "devUiUrl must use http or https protocol" }); + return; + } + + // Dev proxy is restricted to loopback addresses only. + // Validate the *constructed* targetUrl hostname (not the base) to + // catch any path-based override that slipped past the checks above. + const devHost = targetUrl.hostname; + const isLoopback = + devHost === "localhost" || + devHost === "127.0.0.1" || + devHost === "::1" || + devHost === "[::1]"; + if (!isLoopback) { + log.warn( + { pluginId: plugin.id, devUiUrl, host: devHost }, + "plugin-ui-static: devUiUrl must target localhost, rejecting proxy", + ); + res.status(400).json({ error: "devUiUrl must target localhost" }); + return; + } + + log.debug( + { pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href }, + "plugin-ui-static: proxying to devUiUrl", + ); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const upstream = await fetch(targetUrl.href, { signal: controller.signal }); + if (!upstream.ok) { + res.status(upstream.status).json({ + error: `Dev server returned ${upstream.status}`, + }); + return; + } + + const contentType = upstream.headers.get("content-type"); + if (contentType) res.set("Content-Type", contentType); + res.set("Cache-Control", "no-cache, no-store, must-revalidate"); + + const body = await upstream.arrayBuffer(); + res.send(Buffer.from(body)); + return; + } finally { + clearTimeout(timeout); + } + } catch (proxyErr) { + log.warn( + { + pluginId: plugin.id, + devUiUrl, + err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr), + }, + "plugin-ui-static: failed to proxy to devUiUrl, falling back to static", + ); + // Fall through to static serving below + } + } + } + } catch { + // Config lookup failure is non-fatal — fall through to static serving + } + + // Step 3: Resolve the plugin's UI directory + const uiDir = resolvePluginUiDir( + options.localPluginDir, + plugin.packageName, + manifest.entrypoints.ui, + plugin.packagePath, + ); + + if (!uiDir) { + log.warn( + { pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName }, + "plugin-ui-static: UI directory not found on disk", + ); + res.status(404).json({ error: "Plugin UI directory not found" }); + return; + } + + // Step 4: Resolve the requested file path and prevent traversal (including symlinks) + const resolvedFilePath = path.resolve(uiDir, rawFilePath); + + // Step 5: Check that the file exists and is a regular file + let fileStat: fs.Stats; + try { + fileStat = fs.statSync(resolvedFilePath); + } catch { + res.status(404).json({ error: "File not found" }); + return; + } + + // Security: resolve symlinks via realpathSync and verify containment. + // This prevents symlink-based traversal that string-based startsWith misses. + let realFilePath: string; + let realUiDir: string; + try { + realFilePath = fs.realpathSync(resolvedFilePath); + realUiDir = fs.realpathSync(uiDir); + } catch { + res.status(404).json({ error: "File not found" }); + return; + } + + const relative = path.relative(realUiDir, realFilePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + res.status(403).json({ error: "Access denied" }); + return; + } + + if (!fileStat.isFile()) { + res.status(404).json({ error: "File not found" }); + return; + } + + // Step 6: Determine cache strategy based on filename + const basename = path.basename(resolvedFilePath); + const isContentHashed = CONTENT_HASH_PATTERN.test(basename); + + // Step 7: Set cache headers + if (isContentHashed) { + res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE); + } else { + res.set("Cache-Control", CACHE_CONTROL_REVALIDATE); + + // Compute and set ETag for conditional request support + const etag = computeETag(fileStat.size, fileStat.mtimeMs); + res.set("ETag", etag); + + // Check If-None-Match for 304 Not Modified + const ifNoneMatch = req.headers["if-none-match"]; + if (ifNoneMatch === etag) { + res.status(304).end(); + return; + } + } + + // Step 8: Set Content-Type + const ext = path.extname(resolvedFilePath).toLowerCase(); + const contentType = MIME_TYPES[ext]; + if (contentType) { + res.set("Content-Type", contentType); + } + + // Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev) + res.set("Access-Control-Allow-Origin", "*"); + + // Step 10: Send the file + // The plugin source can live in Git worktrees (e.g. ".worktrees/..."). + // `send` defaults to dotfiles:"ignore", which treats dot-directories as + // not found. We already enforce traversal safety above, so allow dot paths. + res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => { + if (err) { + log.error( + { err, pluginId: plugin.id, filePath: resolvedFilePath }, + "plugin-ui-static: error sending file", + ); + // Only send error if headers haven't been sent yet + if (!res.headersSent) { + res.status(500).json({ error: "Failed to serve file" }); + } + } + }); + }); + + return router; +} diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts new file mode 100644 index 00000000..2d6382a9 --- /dev/null +++ b/server/src/routes/plugins.ts @@ -0,0 +1,2417 @@ +/** + * @fileoverview Plugin management REST API routes + * + * This module provides Express routes for managing the complete plugin lifecycle: + * - Listing and filtering plugins by status + * - Installing plugins from npm or local paths + * - Uninstalling plugins (soft delete or hard purge) + * - Enabling/disabling plugins + * - Running health diagnostics + * - Upgrading plugins + * - Retrieving UI slot contributions for frontend rendering + * - Discovering and executing plugin-contributed agent tools + * + * All routes require board-level authentication (assertBoard middleware). + * + * @module server/routes/plugins + * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { Router } from "express"; +import type { Request, Response } from "express"; +import { and, desc, eq, gte } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db"; +import type { + PluginStatus, + PaperclipPluginManifestV1, + PluginBridgeErrorCode, + PluginLauncherRenderContextSnapshot, + UpdateCompanyPluginAvailability, +} from "@paperclipai/shared"; +import { + PLUGIN_STATUSES, + updateCompanyPluginAvailabilitySchema, +} from "@paperclipai/shared"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; +import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; +import { logActivity } from "../services/activity-log.js"; +import { publishGlobalLiveEvent } from "../services/live-events.js"; +import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js"; +import type { PluginJobStore } from "../services/plugin-job-store.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; +import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; +import type { ToolRunContext } from "@paperclipai/plugin-sdk"; +import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { validateInstanceConfig } from "../services/plugin-config-validator.js"; + +/** UI slot declaration extracted from plugin manifest */ +type PluginUiSlotDeclaration = NonNullable["slots"]>[number]; +/** Launcher declaration extracted from plugin manifest */ +type PluginLauncherDeclaration = NonNullable[number]; + +/** + * Normalized UI contribution for frontend slot host consumption. + * Only includes plugins in 'ready' state with non-empty slot declarations. + */ +type PluginUiContribution = { + pluginId: string; + pluginKey: string; + displayName: string; + version: string; + updatedAt: string; + /** + * Relative path within the plugin's UI directory to the entry module + * (e.g. `"index.js"`). The frontend constructs the full import URL as + * `/_plugins/${pluginId}/ui/${uiEntryFile}`. + */ + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +}; + +/** Request body for POST /api/plugins/install */ +interface PluginInstallRequest { + /** npm package name (e.g., @paperclip/plugin-linear) or local path */ + packageName: string; + /** Target version for npm packages (optional, defaults to latest) */ + version?: string; + /** True if packageName is a local filesystem path */ + isLocalPath?: boolean; +} + +interface AvailablePluginExample { + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: "example"; +} + +/** Response body for GET /api/plugins/:pluginId/health */ +interface PluginHealthCheckResult { + pluginId: string; + status: string; + healthy: boolean; + checks: Array<{ + name: string; + passed: boolean; + message?: string; + }>; + lastError?: string; +} + +/** UUID v4 regex used for plugin ID route resolution. */ +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../../.."); + +const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ + { + packageName: "@paperclipai/plugin-hello-world-example", + pluginKey: "paperclip.hello-world-example", + displayName: "Hello World Widget (Example)", + description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", + localPath: "packages/plugins/examples/plugin-hello-world-example", + tag: "example", + }, + { + packageName: "@paperclipai/plugin-file-browser-example", + pluginKey: "paperclip-file-browser-example", + displayName: "File Browser (Example)", + description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.", + localPath: "packages/plugins/examples/plugin-file-browser-example", + tag: "example", + }, +]; + +function listBundledPluginExamples(): AvailablePluginExample[] { + return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => { + const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath); + if (!existsSync(absoluteLocalPath)) return []; + return [{ ...plugin, localPath: absoluteLocalPath }]; + }); +} + +/** + * Resolve a plugin by either database ID or plugin key. + * + * Lookup order: + * - UUID-like IDs: getById first, then getByKey. + * - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById. + * - Other non-UUID IDs: try getById first (test/memory registries may allow this), + * then fallback to getByKey. Any UUID parse error from getById is ignored. + * + * @param registry - The plugin registry service instance + * @param pluginId - Either a database UUID or plugin key (manifest id) + * @returns Plugin record or null if not found + */ +async function resolvePlugin( + registry: ReturnType, + pluginId: string, +) { + const isUuid = UUID_REGEX.test(pluginId); + const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/"); + + // Scoped package IDs are valid plugin keys but invalid UUIDs. + // Skip getById() entirely to avoid Postgres uuid parse errors. + if (isScopedPackageKey && !isUuid) { + return registry.getByKey(pluginId); + } + + try { + const byId = await registry.getById(pluginId); + if (byId) return byId; + } catch (error) { + const maybeCode = + typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + // Ignore invalid UUID cast errors and continue with key lookup. + if (maybeCode !== "22P02") { + throw error; + } + } + + return registry.getByKey(pluginId); +} + +async function isPluginAvailableForCompany( + registry: ReturnType, + companyId: string, + pluginId: string, +): Promise { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + return availability?.available === true; +} + +/** + * Optional dependencies for plugin job scheduling routes. + * + * When provided, job-related routes (list jobs, list runs, trigger job) are + * mounted. When omitted, the routes return 501 Not Implemented. + */ +export interface PluginRouteJobDeps { + /** The job scheduler instance. */ + scheduler: PluginJobScheduler; + /** The job persistence store. */ + jobStore: PluginJobStore; +} + +/** + * Optional dependencies for plugin webhook routes. + * + * When provided, the webhook ingestion route is enabled. When omitted, + * webhook POST requests return 501 Not Implemented. + */ +export interface PluginRouteWebhookDeps { + /** The worker manager for dispatching handleWebhook RPC calls. */ + workerManager: PluginWorkerManager; +} + +/** + * Optional dependencies for plugin tool routes. + * + * When provided, tool discovery and execution routes are enabled. + * When omitted, the tool routes return 501 Not Implemented. + */ +export interface PluginRouteToolDeps { + /** The tool dispatcher for listing and executing plugin tools. */ + toolDispatcher: PluginToolDispatcher; +} + +/** + * Optional dependencies for plugin UI bridge routes. + * + * When provided, the getData and performAction bridge proxy routes are enabled, + * allowing plugin UI components to communicate with their worker backend via + * `usePluginData()` and `usePluginAction()` hooks. + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ +export interface PluginRouteBridgeDeps { + /** The worker manager for dispatching getData/performAction RPC calls. */ + workerManager: PluginWorkerManager; + /** Optional stream bus for SSE push from worker to UI. */ + streamBus?: PluginStreamBus; +} + +/** Request body for POST /api/plugins/tools/execute */ +interface PluginToolExecuteRequest { + /** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */ + tool: string; + /** Parameters matching the tool's declared JSON Schema. */ + parameters?: unknown; + /** Agent run context. */ + runContext: ToolRunContext; +} + +/** + * Create Express router for plugin management API. + * + * Routes provided: + * + * | Method | Path | Description | + * |--------|------|-------------| + * | GET | /plugins | List all plugins (optional ?status= filter) | + * | GET | /plugins/ui-contributions | Get UI slots from ready plugins | + * | GET | /plugins/:pluginId | Get single plugin by ID or key | + * | POST | /plugins/install | Install from npm or local path | + * | DELETE | /plugins/:pluginId | Uninstall (optional ?purge=true) | + * | POST | /plugins/:pluginId/enable | Enable a plugin | + * | POST | /plugins/:pluginId/disable | Disable a plugin | + * | GET | /plugins/:pluginId/health | Run health diagnostics | + * | POST | /plugins/:pluginId/upgrade | Upgrade to newer version | + * | GET | /plugins/:pluginId/jobs | List jobs for a plugin | + * | GET | /plugins/:pluginId/jobs/:jobId/runs | List runs for a job | + * | POST | /plugins/:pluginId/jobs/:jobId/trigger | Manually trigger a job | + * | POST | /plugins/:pluginId/webhooks/:endpointKey | Receive inbound webhook | + * | GET | /plugins/tools | List all available plugin tools | + * | GET | /plugins/tools?pluginId=... | List tools for a specific plugin | + * | POST | /plugins/tools/execute | Execute a plugin tool | + * | GET | /plugins/:pluginId/config | Get current plugin config | + * | POST | /plugins/:pluginId/config | Save (upsert) plugin config | + * | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC | + * | GET | /companies/:companyId/plugins | List company-scoped plugin availability | + * | GET | /companies/:companyId/plugins/:pluginId | Get company-scoped plugin availability | + * | PUT | /companies/:companyId/plugins/:pluginId | Save company-scoped plugin availability/settings | + * | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker | + * | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker | + * | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) | + * | POST | /plugins/:pluginId/actions/:key | Proxy performAction to plugin worker (key in URL) | + * | GET | /plugins/:pluginId/bridge/stream/:channel | SSE stream from worker to UI | + * | GET | /plugins/:pluginId/dashboard | Aggregated health dashboard data | + * + * **Route Ordering Note:** Static routes (like /ui-contributions, /tools) must be + * registered before parameterized routes (like /:pluginId) to prevent Express from + * matching them as a plugin ID. + * + * @param db - Database connection instance + * @param jobDeps - Optional job scheduling dependencies + * @param webhookDeps - Optional webhook ingestion dependencies + * @param toolDeps - Optional tool dispatcher dependencies + * @param bridgeDeps - Optional bridge proxy dependencies for getData/performAction + * @returns Express router with plugin routes mounted + */ +export function pluginRoutes( + db: Db, + loader: ReturnType, + jobDeps?: PluginRouteJobDeps, + webhookDeps?: PluginRouteWebhookDeps, + toolDeps?: PluginRouteToolDeps, + bridgeDeps?: PluginRouteBridgeDeps, +) { + const router = Router(); + const registry = pluginRegistryService(db); + const lifecycle = pluginLifecycleManager(db, { + loader, + workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, + }); + + async function resolvePluginAuditCompanyIds(req: Request): Promise { + if (typeof (db as { select?: unknown }).select === "function") { + const rows = await db + .select({ id: companies.id }) + .from(companies); + return rows.map((row) => row.id); + } + + if (req.actor.type === "agent" && req.actor.companyId) { + return [req.actor.companyId]; + } + + if (req.actor.type === "board") { + return req.actor.companyIds ?? []; + } + + return []; + } + + async function logPluginMutationActivity( + req: Request, + action: string, + entityId: string, + details: Record, + ): Promise { + const companyIds = await resolvePluginAuditCompanyIds(req); + if (companyIds.length === 0) return; + + const actor = getActorInfo(req); + await Promise.all(companyIds.map((companyId) => + logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action, + entityType: "plugin", + entityId, + details, + }))); + } + + /** + * GET /api/plugins + * + * List all installed plugins, optionally filtered by lifecycle status. + * + * Query params: + * - `status` (optional): Filter by lifecycle status. Must be one of the + * values in `PLUGIN_STATUSES` (`installed`, `ready`, `error`, + * `upgrade_pending`, `uninstalled`). Returns HTTP 400 if the value is + * not a recognised status string. + * + * Response: `PluginRecord[]` + */ + router.get("/plugins", async (req, res) => { + assertBoard(req); + const rawStatus = req.query.status; + if (rawStatus !== undefined) { + if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { + res.status(400).json({ + error: `Invalid status '${String(rawStatus)}'. Must be one of: ${PLUGIN_STATUSES.join(", ")}`, + }); + return; + } + } + const status = rawStatus as PluginStatus | undefined; + const plugins = status + ? await registry.listByStatus(status) + : await registry.listInstalled(); + res.json(plugins); + }); + + /** + * GET /api/plugins/examples + * + * Return first-party example plugins bundled in this repo, if present. + * These can be installed through the normal local-path install flow. + */ + router.get("/plugins/examples", async (req, res) => { + assertBoard(req); + res.json(listBundledPluginExamples()); + }); + + // IMPORTANT: Static routes must come before parameterized routes + // to avoid Express matching "ui-contributions" as a :pluginId + + /** + * GET /api/plugins/ui-contributions + * + * Return UI contributions from all plugins in 'ready' state. + * Used by the frontend to discover plugin UI slots and launcher metadata. + * + * The response is normalized for the frontend slot host: + * - Only includes plugins with at least one declared UI slot or launcher + * - Excludes plugins with null/missing manifestJson (defensive) + * - Slots are extracted from manifest.ui.slots + * - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers + * + * Query params: + * - `companyId` (optional): filters out plugins disabled for the target + * company and applies `assertCompanyAccess` + * + * Example response: + * ```json + * [ + * { + * "pluginId": "plg_123", + * "pluginKey": "paperclip.claude-usage", + * "displayName": "Claude Usage", + * "version": "1.0.0", + * "uiEntryFile": "index.js", + * "slots": [], + * "launchers": [ + * { + * "id": "claude-usage-toolbar", + * "displayName": "Claude Usage", + * "placementZone": "toolbarButton", + * "action": { "type": "openModal", "target": "ClaudeUsageView" }, + * "render": { "environment": "hostOverlay", "bounds": "wide" } + * } + * ] + * } + * ] + * ``` + * + * Response: PluginUiContribution[] + */ + router.get("/plugins/ui-contributions", async (req, res) => { + assertBoard(req); + const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; + if (companyId) { + assertCompanyAccess(req, companyId); + } + + const plugins = await registry.listByStatus("ready"); + const availablePluginIds = companyId + ? new Set( + (await registry.listCompanyAvailability(companyId, { available: true })) + .map((entry) => entry.pluginId), + ) + : null; + + const contributions: PluginUiContribution[] = plugins + .filter((plugin) => availablePluginIds === null || availablePluginIds.has(plugin.id)) + .map((plugin) => { + // Safety check: manifestJson should always exist for ready plugins, but guard against null + const manifest = plugin.manifestJson; + if (!manifest) return null; + + const uiMetadata = getPluginUiContributionMetadata(manifest); + if (!uiMetadata) return null; + + return { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + displayName: manifest.displayName, + version: plugin.version, + updatedAt: plugin.updatedAt.toISOString(), + uiEntryFile: uiMetadata.uiEntryFile, + slots: uiMetadata.slots, + launchers: uiMetadata.launchers, + }; + }) + .filter((item): item is PluginUiContribution => item !== null); + res.json(contributions); + }); + + // =========================================================================== + // Company-scoped plugin settings / availability routes + // =========================================================================== + + /** + * GET /api/companies/:companyId/plugins + * + * List every installed plugin as it applies to a specific company. Plugins + * are enabled by default; rows in `plugin_company_settings` only store + * explicit overrides and any company-scoped settings payload. + * + * Query params: + * - `available` (optional): `true` or `false` filter + */ + router.get("/companies/:companyId/plugins", async (req, res) => { + assertBoard(req); + const { companyId } = req.params; + assertCompanyAccess(req, companyId); + + let available: boolean | undefined; + const rawAvailable = req.query.available; + if (rawAvailable !== undefined) { + if (rawAvailable === "true") available = true; + else if (rawAvailable === "false") available = false; + else { + res.status(400).json({ error: '"available" must be "true" or "false"' }); + return; + } + } + + const result = await registry.listCompanyAvailability(companyId, { available }); + res.json(result); + }); + + /** + * GET /api/companies/:companyId/plugins/:pluginId + * + * Resolve one plugin's effective availability for a company, whether that + * result comes from the default-enabled baseline or a persisted override row. + */ + router.get("/companies/:companyId/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { companyId, pluginId } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin || plugin.status === "uninstalled") { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const result = await registry.getCompanyAvailability(companyId, plugin.id); + if (!result) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + res.json(result); + }); + + /** + * PUT /api/companies/:companyId/plugins/:pluginId + * + * Persist a company-scoped availability override. This never changes the + * instance-wide install state of the plugin; it only controls whether the + * selected company can see UI contributions and invoke plugin-backed actions. + */ + router.put("/companies/:companyId/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { companyId, pluginId } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin || plugin.status === "uninstalled") { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const parsed = updateCompanyPluginAvailabilitySchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Invalid request body" }); + return; + } + + try { + const result = await registry.updateCompanyAvailability( + companyId, + plugin.id, + parsed.data as UpdateCompanyPluginAvailability, + ); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "plugin.company_settings.updated", + entityType: "plugin_company_settings", + entityId: `${companyId}:${plugin.id}`, + details: { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + available: result.available, + settingsJson: result.settingsJson, + lastError: result.lastError, + }, + }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Tool discovery and execution routes + // =========================================================================== + + /** + * GET /api/plugins/tools + * + * List all available plugin-contributed tools in an agent-friendly format. + * + * Query params: + * - `pluginId` (optional): Filter to tools from a specific plugin + * + * Response: `AgentToolDescriptor[]` + * Errors: 501 if tool dispatcher is not configured + */ + router.get("/plugins/tools", async (req, res) => { + assertBoard(req); + + if (!toolDeps) { + res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); + return; + } + + const pluginId = req.query.pluginId as string | undefined; + const companyId = typeof req.query.companyId === "string" ? req.query.companyId : undefined; + if (companyId) { + assertCompanyAccess(req, companyId); + } + + const filter = pluginId ? { pluginId } : undefined; + const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); + if (!companyId) { + res.json(tools); + return; + } + + const availablePluginIds = new Set( + (await registry.listCompanyAvailability(companyId, { available: true })) + .map((entry) => entry.pluginId), + ); + res.json(tools.filter((tool) => availablePluginIds.has(tool.pluginId))); + }); + + /** + * Reject company-scoped plugin access when the plugin is disabled for the + * target company. This guard is reused across UI bridge and tool execution + * endpoints so every runtime surface honors the same availability rule. + */ + async function enforceCompanyPluginAvailability( + companyId: string, + pluginId: string, + res: Response, + ): Promise { + if (!await isPluginAvailableForCompany(registry, companyId, pluginId)) { + res.status(403).json({ + error: `Plugin "${pluginId}" is not enabled for company "${companyId}"`, + }); + return false; + } + + return true; + } + + /** + * POST /api/plugins/tools/execute + * + * Execute a plugin-contributed tool by its namespaced name. + * + * This is the primary endpoint used by the agent service to invoke + * plugin tools during an agent run. + * + * Request body: + * - `tool`: Fully namespaced tool name (e.g., "acme.linear:search-issues") + * - `parameters`: Parameters matching the tool's declared JSON Schema + * - `runContext`: Agent run context with agentId, runId, companyId, projectId + * + * Response: `ToolExecutionResult` + * Errors: + * - 400 if request validation fails + * - 404 if tool is not found + * - 501 if tool dispatcher is not configured + * - 502 if the plugin worker is unavailable or the RPC call fails + */ + router.post("/plugins/tools/execute", async (req, res) => { + assertBoard(req); + + if (!toolDeps) { + res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); + return; + } + + const body = (req.body as PluginToolExecuteRequest | undefined); + if (!body) { + res.status(400).json({ error: "Request body is required" }); + return; + } + + const { tool, parameters, runContext } = body; + + // Validate required fields + if (!tool || typeof tool !== "string") { + res.status(400).json({ error: '"tool" is required and must be a string' }); + return; + } + + if (!runContext || typeof runContext !== "object") { + res.status(400).json({ error: '"runContext" is required and must be an object' }); + return; + } + + if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) { + res.status(400).json({ + error: '"runContext" must include agentId, runId, companyId, and projectId', + }); + return; + } + + assertCompanyAccess(req, runContext.companyId); + + // Verify the tool exists + const registeredTool = toolDeps.toolDispatcher.getTool(tool); + if (!registeredTool) { + res.status(404).json({ error: `Tool "${tool}" not found` }); + return; + } + + if (!await enforceCompanyPluginAvailability(runContext.companyId, registeredTool.pluginDbId, res)) { + return; + } + + try { + const result = await toolDeps.toolDispatcher.executeTool( + tool, + parameters ?? {}, + runContext, + ); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + // Distinguish between "worker not running" (502) and other errors (500) + if (message.includes("not running") || message.includes("worker")) { + res.status(502).json({ error: message }); + } else { + res.status(500).json({ error: message }); + } + } + }); + + /** + * POST /api/plugins/install + * + * Install a plugin from npm or a local filesystem path. + * + * Request body: + * - packageName: npm package name or local path (required) + * - version: Target version for npm packages (optional) + * - isLocalPath: Set true if packageName is a local path + * + * The installer: + * 1. Downloads from npm or loads from local path + * 2. Validates the manifest (schema + capability consistency) + * 3. Registers in the database + * 4. Transitions to `ready` state if no new capability approval is needed + * + * Response: `PluginRecord` + * + * Errors: + * - `400` — validation failure or install error (package not found, bad manifest, etc.) + * - `500` — installation succeeded but manifest is missing (indicates a loader bug) + */ + router.post("/plugins/install", async (req, res) => { + assertBoard(req); + const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; + + // Input validation + if (!packageName || typeof packageName !== "string") { + res.status(400).json({ error: "packageName is required and must be a string" }); + return; + } + + if (version !== undefined && typeof version !== "string") { + res.status(400).json({ error: "version must be a string if provided" }); + return; + } + + if (isLocalPath !== undefined && typeof isLocalPath !== "boolean") { + res.status(400).json({ error: "isLocalPath must be a boolean if provided" }); + return; + } + + // Validate package name format + const trimmedPackage = packageName.trim(); + if (trimmedPackage.length === 0) { + res.status(400).json({ error: "packageName cannot be empty" }); + return; + } + + // Basic security check for package name (prevent injection) + if (!isLocalPath && /[<>:"|?*]/.test(trimmedPackage)) { + res.status(400).json({ error: "packageName contains invalid characters" }); + return; + } + + try { + const installOptions = isLocalPath + ? { localPath: trimmedPackage } + : { packageName: trimmedPackage, version: version?.trim() }; + + const discovered = await loader.installPlugin(installOptions); + + if (!discovered.manifest) { + res.status(500).json({ error: "Plugin installed but manifest is missing" }); + return; + } + + // Transition to ready state + const existingPlugin = await registry.getByKey(discovered.manifest.id); + if (existingPlugin) { + await lifecycle.load(existingPlugin.id); + // Plugins should be enabled by default for all companies after install. + // Best-effort: default behavior is still enabled when no row exists. + try { + await registry.seedEnabledForAllCompanies(existingPlugin.id); + } catch { + // no-op + } + const updated = await registry.getById(existingPlugin.id); + await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { + pluginId: existingPlugin.id, + pluginKey: existingPlugin.pluginKey, + packageName: updated?.packageName ?? existingPlugin.packageName, + version: updated?.version ?? existingPlugin.version, + source: isLocalPath ? "local_path" : "npm", + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: existingPlugin.id, action: "installed" } }); + res.json(updated); + } else { + // This shouldn't happen since installPlugin already registers in the DB + res.status(500).json({ error: "Plugin installed but not found in registry" }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // UI Bridge proxy routes (getData / performAction) + // =========================================================================== + + /** Request body for POST /api/plugins/:pluginId/bridge/data */ + interface PluginBridgeDataRequest { + /** Plugin-defined data key (e.g. `"sync-health"`). */ + key: string; + /** Optional company scope for enforcing company plugin availability. */ + companyId?: string; + /** Optional context and query parameters from the UI. */ + params?: Record; + /** Optional host launcher/render metadata for the worker bridge call. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } + + /** Request body for POST /api/plugins/:pluginId/bridge/action */ + interface PluginBridgeActionRequest { + /** Plugin-defined action key (e.g. `"resync"`). */ + key: string; + /** Optional company scope for enforcing company plugin availability. */ + companyId?: string; + /** Optional parameters from the UI. */ + params?: Record; + /** Optional host launcher/render metadata for the worker bridge call. */ + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } + + /** Response envelope for bridge errors. */ + interface PluginBridgeErrorResponse { + code: PluginBridgeErrorCode; + message: string; + details?: unknown; + } + + /** + * Map a worker RPC error to a bridge-level error code. + * + * JsonRpcCallError carries numeric codes from the plugin RPC error code space. + * This helper maps them to the string error codes defined in PluginBridgeErrorCode. + * + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + function mapRpcErrorToBridgeError(err: unknown): PluginBridgeErrorResponse { + if (err instanceof JsonRpcCallError) { + switch (err.code) { + case PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE: + return { + code: "WORKER_UNAVAILABLE", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED: + return { + code: "CAPABILITY_DENIED", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.TIMEOUT: + return { + code: "TIMEOUT", + message: err.message, + details: err.data, + }; + case PLUGIN_RPC_ERROR_CODES.WORKER_ERROR: + return { + code: "WORKER_ERROR", + message: err.message, + details: err.data, + }; + default: + return { + code: "UNKNOWN", + message: err.message, + details: err.data, + }; + } + } + + const message = err instanceof Error ? err.message : String(err); + + // Worker not running — surface as WORKER_UNAVAILABLE + if (message.includes("not running") || message.includes("not registered")) { + return { + code: "WORKER_UNAVAILABLE", + message, + }; + } + + return { + code: "UNKNOWN", + message, + }; + } + + /** + * POST /api/plugins/:pluginId/bridge/data + * + * Proxy a `getData` call from the plugin UI to the plugin worker. + * + * This is the server-side half of the `usePluginData(key, params)` bridge hook. + * The frontend sends a POST with the data key and optional params; the host + * forwards the call to the worker via the `getData` RPC method and returns + * the result. + * + * Request body: + * - `key`: Plugin-defined data key (e.g. `"sync-health"`) + * - `params`: Optional query parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `getData` handler + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/bridge/data", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + // Validate request body + const body = req.body as PluginBridgeDataRequest | undefined; + if (!body || !body.key || typeof body.key !== "string") { + res.status(400).json({ error: '"key" is required and must be a string' }); + return; + } + + if (body.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "getData", + { + key: body.key, + params: body.params ?? {}, + renderEnvironment: body.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + /** + * POST /api/plugins/:pluginId/bridge/action + * + * Proxy a `performAction` call from the plugin UI to the plugin worker. + * + * This is the server-side half of the `usePluginAction(key)` bridge hook. + * The frontend sends a POST with the action key and optional params; the host + * forwards the call to the worker via the `performAction` RPC method and + * returns the result. + * + * Request body: + * - `key`: Plugin-defined action key (e.g. `"resync"`) + * - `params`: Optional parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `performAction` handler + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/bridge/action", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + // Validate request body + const body = req.body as PluginBridgeActionRequest | undefined; + if (!body || !body.key || typeof body.key !== "string") { + res.status(400).json({ error: '"key" is required and must be a string' }); + return; + } + + if (body.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "performAction", + { + key: body.key, + params: body.params ?? {}, + renderEnvironment: body.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // URL-keyed bridge routes (key as path parameter) + // =========================================================================== + + /** + * POST /api/plugins/:pluginId/data/:key + * + * Proxy a `getData` call from the plugin UI to the plugin worker, with the + * data key specified as a URL path parameter instead of in the request body. + * + * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/data`. + * The frontend bridge hooks use this endpoint for cleaner URLs. + * + * Request body (optional): + * - `params`: Optional query parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `getData` handler wrapped as `{ data: T }` + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/data/:key", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId, key } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + const body = req.body as { + companyId?: string; + params?: Record; + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } | undefined; + + if (body?.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "getData", + { + key, + params: body?.params ?? {}, + renderEnvironment: body?.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + /** + * POST /api/plugins/:pluginId/actions/:key + * + * Proxy a `performAction` call from the plugin UI to the plugin worker, with + * the action key specified as a URL path parameter instead of in the request body. + * + * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/action`. + * The frontend bridge hooks use this endpoint for cleaner URLs. + * + * Request body (optional): + * - `params`: Optional parameters forwarded to the worker handler + * + * Response: The raw result from the worker's `performAction` handler wrapped as `{ data: T }` + * + * Error response body follows the `PluginBridgeError` shape: + * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` + * + * Errors: + * - 404 if plugin not found + * - 501 if bridge deps are not configured + * - 502 if the worker is unavailable or returns an error + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + router.post("/plugins/:pluginId/actions/:key", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId, key } = req.params; + + // Resolve plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Validate plugin is in ready state + if (plugin.status !== "ready") { + const bridgeError: PluginBridgeErrorResponse = { + code: "WORKER_UNAVAILABLE", + message: `Plugin is not ready (current status: ${plugin.status})`, + }; + res.status(502).json(bridgeError); + return; + } + + const body = req.body as { + companyId?: string; + params?: Record; + renderEnvironment?: PluginLauncherRenderContextSnapshot | null; + } | undefined; + + if (body?.companyId) { + assertCompanyAccess(req, body.companyId); + if (!await enforceCompanyPluginAvailability(body.companyId, plugin.id, res)) { + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "performAction", + { + key, + params: body?.params ?? {}, + renderEnvironment: body?.renderEnvironment ?? null, + }, + ); + res.json({ data: result }); + } catch (err) { + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // SSE stream bridge route + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/bridge/stream/:channel + * + * Server-Sent Events endpoint for real-time streaming from plugin worker to UI. + * + * The worker pushes events via `ctx.streams.emit(channel, event)` which arrive + * as JSON-RPC notifications to the host, get published on the PluginStreamBus, + * and are fanned out to all connected SSE clients matching (pluginId, channel, + * companyId). + * + * Query parameters: + * - `companyId` (required): Scope events to a specific company + * + * SSE event types: + * - `message`: A data event from the worker (default) + * - `open`: The worker opened the stream channel + * - `close`: The worker closed the stream channel — client should disconnect + * + * Errors: + * - 400 if companyId is missing + * - 404 if plugin not found + * - 501 if bridge deps or stream bus are not configured + */ + router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps?.streamBus) { + res.status(501).json({ error: "Plugin stream bridge is not enabled" }); + return; + } + + const { pluginId, channel } = req.params; + const companyId = req.query.companyId as string | undefined; + + if (!companyId) { + res.status(400).json({ error: '"companyId" query parameter is required' }); + return; + } + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + assertCompanyAccess(req, companyId); + + if (!await enforceCompanyPluginAvailability(companyId, plugin.id, res)) { + return; + } + + // Set SSE headers + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + + // Send initial comment to establish the connection + res.write(":ok\n\n"); + + let unsubscribed = false; + const safeUnsubscribe = () => { + if (!unsubscribed) { + unsubscribed = true; + unsubscribe(); + } + }; + + const unsubscribe = bridgeDeps.streamBus.subscribe( + plugin.id, + channel, + companyId, + (event, eventType) => { + if (unsubscribed || !res.writable) return; + try { + if (eventType !== "message") { + res.write(`event: ${eventType}\n`); + } + res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch { + // Connection closed or write error — stop delivering + safeUnsubscribe(); + } + }, + ); + + req.on("close", safeUnsubscribe); + res.on("error", safeUnsubscribe); + }); + + /** + * GET /api/plugins/:pluginId + * + * Get detailed information about a single plugin. + * + * The :pluginId parameter accepts either: + * - Database UUID (e.g., "abc123-def456") + * - Plugin key (e.g., "acme.linear") + * + * Response: PluginRecord + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Enrich with worker capabilities when available + const worker = bridgeDeps?.workerManager.getWorker(plugin.id); + const supportsConfigTest = worker + ? worker.supportedMethods.includes("validateConfig") + : false; + + res.json({ ...plugin, supportsConfigTest }); + }); + + /** + * DELETE /api/plugins/:pluginId + * + * Uninstall a plugin. + * + * Query params: + * - purge: If "true", permanently delete all plugin data (hard delete) + * Otherwise, soft-delete with 30-day data retention + * + * Response: PluginRecord (the deleted record) + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.delete("/plugins/:pluginId", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const purge = req.query.purge === "true"; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.unload(plugin.id, purge); + await logPluginMutationActivity(req, "plugin.uninstalled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + purge, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "uninstalled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/enable + * + * Enable a plugin that is currently disabled or in error state. + * + * Transitions the plugin to 'ready' state after loading and validation. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/enable", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.enable(plugin.id); + await logPluginMutationActivity(req, "plugin.enabled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + version: result?.version ?? plugin.version, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "enabled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/disable + * + * Disable a running plugin. + * + * Request body (optional): + * - reason: Human-readable reason for disabling + * + * The plugin transitions to 'installed' state and stops processing events. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/disable", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const body = req.body as { reason?: string } | undefined; + const reason = body?.reason; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + const result = await lifecycle.disable(plugin.id, reason); + await logPluginMutationActivity(req, "plugin.disabled", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + reason: reason ?? null, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "disabled" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * GET /api/plugins/:pluginId/health + * + * Run health diagnostics on a plugin. + * + * Performs the following checks: + * 1. Registry: Plugin is registered in the database + * 2. Manifest: Manifest is valid and parseable + * 3. Status: Plugin is in 'ready' state + * 4. Error state: Plugin has no unhandled errors + * + * Response: PluginHealthCheckResult + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/health", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const checks: PluginHealthCheckResult["checks"] = []; + + // Check 1: Plugin is registered + checks.push({ + name: "registry", + passed: true, + message: "Plugin found in registry", + }); + + // Check 2: Manifest is valid + const hasValidManifest = Boolean(plugin.manifestJson?.id); + checks.push({ + name: "manifest", + passed: hasValidManifest, + message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", + }); + + // Check 3: Plugin status + const isHealthy = plugin.status === "ready"; + checks.push({ + name: "status", + passed: isHealthy, + message: `Current status: ${plugin.status}`, + }); + + // Check 4: No last error + const hasNoError = !plugin.lastError; + if (!hasNoError) { + checks.push({ + name: "error_state", + passed: false, + message: plugin.lastError ?? undefined, + }); + } + + const result: PluginHealthCheckResult = { + pluginId: plugin.id, + status: plugin.status, + healthy: isHealthy && hasValidManifest && hasNoError, + checks, + lastError: plugin.lastError ?? undefined, + }; + + res.json(result); + }); + + /** + * GET /api/plugins/:pluginId/logs + * + * Query recent log entries for a plugin. + * + * Query params: + * - limit: Maximum number of entries (default 25, max 500) + * - level: Filter by log level (info, warn, error, debug) + * - since: ISO timestamp to filter logs newer than this time + * + * Response: Array of log entries, newest first. + */ + router.get("/plugins/:pluginId/logs", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500); + const level = req.query.level as string | undefined; + const since = req.query.since as string | undefined; + + const conditions = [eq(pluginLogs.pluginId, plugin.id)]; + if (level) { + conditions.push(eq(pluginLogs.level, level)); + } + if (since) { + const sinceDate = new Date(since); + if (!isNaN(sinceDate.getTime())) { + conditions.push(gte(pluginLogs.createdAt, sinceDate)); + } + } + + const rows = await db + .select() + .from(pluginLogs) + .where(and(...conditions)) + .orderBy(desc(pluginLogs.createdAt)) + .limit(limit); + + res.json(rows); + }); + + /** + * POST /api/plugins/:pluginId/upgrade + * + * Upgrade a plugin to a newer version. + * + * Request body (optional): + * - version: Target version (defaults to latest) + * + * If the upgrade adds new capabilities, the plugin transitions to + * 'upgrade_pending' state for board approval. Otherwise, it goes + * directly to 'ready'. + * + * Response: PluginRecord + * Errors: 404 if plugin not found, 400 for lifecycle errors + */ + router.post("/plugins/:pluginId/upgrade", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + const body = req.body as { version?: string } | undefined; + const version = body?.version; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + try { + // Upgrade the plugin - this would typically: + // 1. Download the new version + // 2. Compare capabilities + // 3. If new capabilities, mark as upgrade_pending + // 4. Otherwise, transition to ready + const result = await lifecycle.upgrade(plugin.id, version); + await logPluginMutationActivity(req, "plugin.upgraded", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + previousVersion: plugin.version, + version: result?.version ?? plugin.version, + targetVersion: version ?? null, + }); + publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "upgraded" } }); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Plugin configuration routes + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/config + * + * Retrieve the current instance configuration for a plugin. + * + * Returns the `PluginConfig` record if one exists, or `null` if the plugin + * has not yet been configured. + * + * Response: `PluginConfig | null` + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/config", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const config = await registry.getConfig(plugin.id); + res.json(config); + }); + + /** + * POST /api/plugins/:pluginId/config + * + * Save (create or replace) the instance configuration for a plugin. + * + * The caller provides the full `configJson` object. The server persists it + * via `registry.upsertConfig()`. + * + * Request body: + * - `configJson`: Configuration values matching the plugin's `instanceConfigSchema` + * + * Response: `PluginConfig` + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + */ + router.post("/plugins/:pluginId/config", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const body = req.body as { configJson?: Record } | undefined; + if (!body?.configJson || typeof body.configJson !== "object") { + res.status(400).json({ error: '"configJson" is required and must be an object' }); + return; + } + + // Strip devUiUrl unless the caller is an instance admin. devUiUrl activates + // a dev-proxy in the static file route that could be abused for SSRF if any + // board-level user were allowed to set it. + if ( + "devUiUrl" in body.configJson && + !(req.actor.type === "board" && req.actor.isInstanceAdmin) + ) { + delete body.configJson.devUiUrl; + } + + // Validate configJson against the plugin's instanceConfigSchema (if declared). + // This ensures CLI/API callers get the same validation the UI performs client-side. + const schema = plugin.manifestJson?.instanceConfigSchema; + if (schema && Object.keys(schema).length > 0) { + const validation = validateInstanceConfig(body.configJson, schema); + if (!validation.valid) { + res.status(400).json({ + error: "Configuration does not match the plugin's instanceConfigSchema", + fieldErrors: validation.errors, + }); + return; + } + } + + try { + const result = await registry.upsertConfig(plugin.id, { + configJson: body.configJson, + }); + await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + configKeyCount: Object.keys(body.configJson).length, + }); + + // Notify the running worker about the config change (PLUGIN_SPEC §25.4.4). + // If the worker implements onConfigChanged, send the new config via RPC. + // If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks + // up the new config on re-initialize. If no worker is running, skip. + if (bridgeDeps?.workerManager.isRunning(plugin.id)) { + try { + await bridgeDeps.workerManager.call( + plugin.id, + "configChanged", + { config: body.configJson }, + ); + } catch (rpcErr) { + if ( + rpcErr instanceof JsonRpcCallError && + rpcErr.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED + ) { + // Worker doesn't handle live config — restart it. + try { + await lifecycle.restartWorker(plugin.id); + } catch { + // Restart failure is non-fatal for the config save response. + } + } + // Other RPC errors (timeout, unavailable) are non-fatal — config is + // already persisted and will take effect on next worker restart. + } + } + + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/config/test + * + * Test a plugin configuration without persisting it by calling the plugin + * worker's `validateConfig` RPC method. + * + * Only works when the plugin's worker implements `onValidateConfig`. + * If the worker does not implement the method, returns + * `{ valid: false, supported: false, message: "..." }` with HTTP 200. + * + * Request body: + * - `configJson`: Configuration values to validate + * + * Response: `{ valid: boolean; message?: string; supported?: boolean }` + * Errors: + * - 400 if request validation fails + * - 404 if plugin not found + * - 501 if bridge deps (worker manager) are not configured + * - 502 if the worker is unavailable + */ + router.post("/plugins/:pluginId/config/test", async (req, res) => { + assertBoard(req); + + if (!bridgeDeps) { + res.status(501).json({ error: "Plugin bridge is not enabled" }); + return; + } + + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + if (plugin.status !== "ready") { + res.status(400).json({ + error: `Plugin is not ready (current status: ${plugin.status})`, + }); + return; + } + + const body = req.body as { configJson?: Record } | undefined; + if (!body?.configJson || typeof body.configJson !== "object") { + res.status(400).json({ error: '"configJson" is required and must be an object' }); + return; + } + + // Fast schema-level rejection before hitting the worker RPC. + const schema = plugin.manifestJson?.instanceConfigSchema; + if (schema && Object.keys(schema).length > 0) { + const validation = validateInstanceConfig(body.configJson, schema); + if (!validation.valid) { + res.status(400).json({ + error: "Configuration does not match the plugin's instanceConfigSchema", + fieldErrors: validation.errors, + }); + return; + } + } + + try { + const result = await bridgeDeps.workerManager.call( + plugin.id, + "validateConfig", + { config: body.configJson }, + ); + + // The worker returns PluginConfigValidationResult { ok, warnings?, errors? } + // Map to the frontend-expected shape { valid, message? } + if (result.ok) { + const warningText = result.warnings?.length + ? `Warnings: ${result.warnings.join("; ")}` + : undefined; + res.json({ valid: true, message: warningText }); + } else { + const errorText = result.errors?.length + ? result.errors.join("; ") + : "Configuration validation failed."; + res.json({ valid: false, message: errorText }); + } + } catch (err) { + // If the worker does not implement validateConfig, return a structured response + if ( + err instanceof JsonRpcCallError && + err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED + ) { + res.json({ + valid: false, + supported: false, + message: "This plugin does not support configuration testing.", + }); + return; + } + + // Worker unavailable or other RPC errors + const bridgeError = mapRpcErrorToBridgeError(err); + res.status(502).json(bridgeError); + } + }); + + // =========================================================================== + // Job scheduling routes + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/jobs + * + * List all scheduled jobs for a plugin. + * + * Query params: + * - `status` (optional): Filter by job status (`active`, `paused`, `failed`) + * + * Response: PluginJobRecord[] + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/jobs", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const rawStatus = req.query.status as string | undefined; + const validStatuses = ["active", "paused", "failed"]; + if (rawStatus !== undefined && !validStatuses.includes(rawStatus)) { + res.status(400).json({ + error: `Invalid status '${rawStatus}'. Must be one of: ${validStatuses.join(", ")}`, + }); + return; + } + + try { + const jobs = await jobDeps.jobStore.listJobs( + plugin.id, + rawStatus as "active" | "paused" | "failed" | undefined, + ); + res.json(jobs); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } + }); + + /** + * GET /api/plugins/:pluginId/jobs/:jobId/runs + * + * List execution history for a specific job. + * + * Query params: + * - `limit` (optional): Maximum number of runs to return (default: 50) + * + * Response: PluginJobRunRecord[] + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId, jobId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); + if (!job) { + res.status(404).json({ error: "Job not found" }); + return; + } + + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 25; + if (isNaN(limit) || limit < 1 || limit > 500) { + res.status(400).json({ error: "limit must be a number between 1 and 500" }); + return; + } + + try { + const runs = await jobDeps.jobStore.listRunsByJob(jobId, limit); + res.json(runs); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } + }); + + /** + * POST /api/plugins/:pluginId/jobs/:jobId/trigger + * + * Manually trigger a job execution outside its cron schedule. + * + * Creates a run with `trigger: "manual"` and dispatches immediately. + * The response returns before the job completes (non-blocking). + * + * Response: `{ runId: string, jobId: string }` + * Errors: + * - 404 if plugin not found + * - 400 if job not found, not active, already running, or worker unavailable + */ + router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { + assertBoard(req); + if (!jobDeps) { + res.status(501).json({ error: "Job scheduling is not enabled" }); + return; + } + + const { pluginId, jobId } = req.params; + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); + if (!job) { + res.status(404).json({ error: "Job not found" }); + return; + } + + try { + const result = await jobDeps.scheduler.triggerJob(jobId, "manual"); + res.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: message }); + } + }); + + // =========================================================================== + // Webhook ingestion route + // =========================================================================== + + /** + * POST /api/plugins/:pluginId/webhooks/:endpointKey + * + * Receive an inbound webhook delivery for a plugin. + * + * This route is called by external systems (e.g. GitHub, Linear, Stripe) to + * deliver webhook payloads to a plugin. The host validates that: + * 1. The plugin exists and is in 'ready' state + * 2. The plugin declares the `webhooks.receive` capability + * 3. The `endpointKey` matches a declared webhook in the manifest + * + * The delivery is recorded in the `plugin_webhook_deliveries` table and + * dispatched to the worker via the `handleWebhook` RPC method. + * + * **Note:** This route does NOT require board authentication — webhook + * endpoints must be publicly accessible for external callers. Signature + * verification is the plugin's responsibility. + * + * Response: `{ deliveryId: string, status: string }` + * Errors: + * - 404 if plugin not found or endpointKey not declared + * - 400 if plugin is not in ready state or lacks webhooks.receive capability + * - 502 if the worker is unavailable or the RPC call fails + */ + router.post("/plugins/:pluginId/webhooks/:endpointKey", async (req, res) => { + if (!webhookDeps) { + res.status(501).json({ error: "Webhook ingestion is not enabled" }); + return; + } + + const { pluginId, endpointKey } = req.params; + + // Step 1: Resolve the plugin + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // Step 2: Validate the plugin is in 'ready' state + if (plugin.status !== "ready") { + res.status(400).json({ + error: `Plugin is not ready (current status: ${plugin.status})`, + }); + return; + } + + // Step 3: Validate the plugin has webhooks.receive capability + const manifest = plugin.manifestJson; + if (!manifest) { + res.status(400).json({ error: "Plugin manifest is missing" }); + return; + } + + const capabilities = manifest.capabilities ?? []; + if (!capabilities.includes("webhooks.receive")) { + res.status(400).json({ + error: "Plugin does not have the webhooks.receive capability", + }); + return; + } + + // Step 4: Validate the endpointKey exists in the manifest's webhook declarations + const declaredWebhooks = manifest.webhooks ?? []; + const webhookDecl = declaredWebhooks.find( + (w) => w.endpointKey === endpointKey, + ); + if (!webhookDecl) { + res.status(404).json({ + error: `Webhook endpoint '${endpointKey}' is not declared by this plugin`, + }); + return; + } + + // Step 5: Extract request data + const requestId = randomUUID(); + const rawHeaders: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + rawHeaders[key] = value; + } else if (Array.isArray(value)) { + rawHeaders[key] = value.join(", "); + } + } + + // Use the raw buffer stashed by the express.json() `verify` callback. + // This preserves the exact bytes the provider signed, whereas + // JSON.stringify(req.body) would re-serialize and break HMAC verification. + const stashedRaw = (req as unknown as { rawBody?: Buffer }).rawBody; + const rawBody = stashedRaw ? stashedRaw.toString("utf-8") : ""; + const parsedBody = req.body as unknown; + const payload = (req.body as Record | undefined) ?? {}; + + // Step 6: Record the delivery in the database + const startedAt = new Date(); + const [delivery] = await db + .insert(pluginWebhookDeliveries) + .values({ + pluginId: plugin.id, + webhookKey: endpointKey, + status: "pending", + payload, + headers: rawHeaders, + startedAt, + }) + .returning({ id: pluginWebhookDeliveries.id }); + + // Step 7: Dispatch to the worker via handleWebhook RPC + try { + await webhookDeps.workerManager.call(plugin.id, "handleWebhook", { + endpointKey, + headers: req.headers as Record, + rawBody, + parsedBody, + requestId, + }); + + // Step 8: Update delivery record to success + const finishedAt = new Date(); + const durationMs = finishedAt.getTime() - startedAt.getTime(); + await db + .update(pluginWebhookDeliveries) + .set({ + status: "success", + durationMs, + finishedAt, + }) + .where(eq(pluginWebhookDeliveries.id, delivery.id)); + + res.status(200).json({ + deliveryId: delivery.id, + status: "success", + }); + } catch (err) { + // Step 8 (error): Update delivery record to failed + const finishedAt = new Date(); + const durationMs = finishedAt.getTime() - startedAt.getTime(); + const errorMessage = err instanceof Error ? err.message : String(err); + + await db + .update(pluginWebhookDeliveries) + .set({ + status: "failed", + durationMs, + error: errorMessage, + finishedAt, + }) + .where(eq(pluginWebhookDeliveries.id, delivery.id)); + + res.status(502).json({ + deliveryId: delivery.id, + status: "failed", + error: errorMessage, + }); + } + }); + + // =========================================================================== + // Plugin health dashboard — aggregated diagnostics for the settings page + // =========================================================================== + + /** + * GET /api/plugins/:pluginId/dashboard + * + * Aggregated health dashboard data for a plugin's settings page. + * + * Returns worker diagnostics (status, uptime, crash history), recent job + * runs, recent webhook deliveries, and the current health check result — + * all in a single response to avoid multiple round-trips. + * + * Response: PluginDashboardData + * Errors: 404 if plugin not found + */ + router.get("/plugins/:pluginId/dashboard", async (req, res) => { + assertBoard(req); + const { pluginId } = req.params; + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + // --- Worker diagnostics --- + let worker: { + status: string; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; + } | null = null; + + // Try bridgeDeps first (primary source for worker manager), fallback to webhookDeps + const wm = bridgeDeps?.workerManager ?? webhookDeps?.workerManager ?? null; + if (wm) { + const handle = wm.getWorker(plugin.id); + if (handle) { + const diag = handle.diagnostics(); + worker = { + status: diag.status, + pid: diag.pid, + uptime: diag.uptime, + consecutiveCrashes: diag.consecutiveCrashes, + totalCrashes: diag.totalCrashes, + pendingRequests: diag.pendingRequests, + lastCrashAt: diag.lastCrashAt, + nextRestartAt: diag.nextRestartAt, + }; + } + } + + // --- Recent job runs (last 10, newest first) --- + let recentJobRuns: Array<{ + id: string; + jobId: string; + jobKey?: string; + trigger: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + }> = []; + + if (jobDeps) { + try { + const runs = await jobDeps.jobStore.listRunsByPlugin(plugin.id, undefined, 10); + // Also fetch job definitions so we can include jobKey + const jobs = await jobDeps.jobStore.listJobs(plugin.id); + const jobKeyMap = new Map(jobs.map((j) => [j.id, j.jobKey])); + + recentJobRuns = runs + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((r) => ({ + id: r.id, + jobId: r.jobId, + jobKey: jobKeyMap.get(r.jobId) ?? undefined, + trigger: r.trigger, + status: r.status, + durationMs: r.durationMs, + error: r.error, + startedAt: r.startedAt ? new Date(r.startedAt).toISOString() : null, + finishedAt: r.finishedAt ? new Date(r.finishedAt).toISOString() : null, + createdAt: new Date(r.createdAt).toISOString(), + })); + } catch { + // Job data unavailable — leave empty + } + } + + // --- Recent webhook deliveries (last 10, newest first) --- + let recentWebhookDeliveries: Array<{ + id: string; + webhookKey: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; + }> = []; + + try { + const deliveries = await db + .select({ + id: pluginWebhookDeliveries.id, + webhookKey: pluginWebhookDeliveries.webhookKey, + status: pluginWebhookDeliveries.status, + durationMs: pluginWebhookDeliveries.durationMs, + error: pluginWebhookDeliveries.error, + startedAt: pluginWebhookDeliveries.startedAt, + finishedAt: pluginWebhookDeliveries.finishedAt, + createdAt: pluginWebhookDeliveries.createdAt, + }) + .from(pluginWebhookDeliveries) + .where(eq(pluginWebhookDeliveries.pluginId, plugin.id)) + .orderBy(desc(pluginWebhookDeliveries.createdAt)) + .limit(10); + + recentWebhookDeliveries = deliveries.map((d) => ({ + id: d.id, + webhookKey: d.webhookKey, + status: d.status, + durationMs: d.durationMs, + error: d.error, + startedAt: d.startedAt ? d.startedAt.toISOString() : null, + finishedAt: d.finishedAt ? d.finishedAt.toISOString() : null, + createdAt: d.createdAt.toISOString(), + })); + } catch { + // Webhook data unavailable — leave empty + } + + // --- Health check (same logic as GET /health) --- + const checks: PluginHealthCheckResult["checks"] = []; + + checks.push({ + name: "registry", + passed: true, + message: "Plugin found in registry", + }); + + const hasValidManifest = Boolean(plugin.manifestJson?.id); + checks.push({ + name: "manifest", + passed: hasValidManifest, + message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", + }); + + const isHealthy = plugin.status === "ready"; + checks.push({ + name: "status", + passed: isHealthy, + message: `Current status: ${plugin.status}`, + }); + + const hasNoError = !plugin.lastError; + if (!hasNoError) { + checks.push({ + name: "error_state", + passed: false, + message: plugin.lastError ?? undefined, + }); + } + + const health: PluginHealthCheckResult = { + pluginId: plugin.id, + status: plugin.status, + healthy: isHealthy && hasValidManifest && hasNoError, + checks, + lastError: plugin.lastError ?? undefined, + }; + + res.json({ + pluginId: plugin.id, + worker, + recentJobRuns, + recentWebhookDeliveries, + health, + checkedAt: new Date().toISOString(), + }); + }); + + return router; +} diff --git a/server/src/services/cron.ts b/server/src/services/cron.ts new file mode 100644 index 00000000..2b9e09e3 --- /dev/null +++ b/server/src/services/cron.ts @@ -0,0 +1,373 @@ +/** + * Lightweight cron expression parser and next-run calculator. + * + * Supports standard 5-field cron expressions: + * + * ┌────────────── minute (0–59) + * │ ┌──────────── hour (0–23) + * │ │ ┌────────── day of month (1–31) + * │ │ │ ┌──────── month (1–12) + * │ │ │ │ ┌────── day of week (0–6, Sun=0) + * │ │ │ │ │ + * * * * * * + * + * Supported syntax per field: + * - `*` — any value + * - `N` — exact value + * - `N-M` — range (inclusive) + * - `N/S` — start at N, step S (within field bounds) + * - `* /S` — every S (from field min) [no space — shown to avoid comment termination] + * - `N-M/S` — range with step + * - `N,M,...` — list of values, ranges, or steps + * + * @module + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A parsed cron schedule. Each field is a sorted array of valid integer values + * for that field. + */ +export interface ParsedCron { + minutes: number[]; + hours: number[]; + daysOfMonth: number[]; + months: number[]; + daysOfWeek: number[]; +} + +// --------------------------------------------------------------------------- +// Field bounds +// --------------------------------------------------------------------------- + +interface FieldSpec { + min: number; + max: number; + name: string; +} + +const FIELD_SPECS: FieldSpec[] = [ + { min: 0, max: 59, name: "minute" }, + { min: 0, max: 23, name: "hour" }, + { min: 1, max: 31, name: "day of month" }, + { min: 1, max: 12, name: "month" }, + { min: 0, max: 6, name: "day of week" }, +]; + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a single cron field token (e.g. `"5"`, `"1-3"`, `"* /10"`, `"1,3,5"`). + * + * @returns Sorted deduplicated array of matching integer values within bounds. + * @throws {Error} on invalid syntax or out-of-range values. + */ +function parseField(token: string, spec: FieldSpec): number[] { + const values = new Set(); + + // Split on commas first — each part can be a value, range, or step + const parts = token.split(","); + + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed === "") { + throw new Error(`Empty element in cron ${spec.name} field`); + } + + // Check for step syntax: "X/S" where X is "*" or a range or a number + const slashIdx = trimmed.indexOf("/"); + if (slashIdx !== -1) { + const base = trimmed.slice(0, slashIdx); + const stepStr = trimmed.slice(slashIdx + 1); + const step = parseInt(stepStr, 10); + if (isNaN(step) || step <= 0) { + throw new Error( + `Invalid step "${stepStr}" in cron ${spec.name} field`, + ); + } + + let rangeStart = spec.min; + let rangeEnd = spec.max; + + if (base === "*") { + // */S — every S from field min + } else if (base.includes("-")) { + // N-M/S — range with step + const [a, b] = base.split("-").map((s) => parseInt(s, 10)); + if (isNaN(a!) || isNaN(b!)) { + throw new Error( + `Invalid range "${base}" in cron ${spec.name} field`, + ); + } + rangeStart = a!; + rangeEnd = b!; + } else { + // N/S — start at N, step S + const start = parseInt(base, 10); + if (isNaN(start)) { + throw new Error( + `Invalid start "${base}" in cron ${spec.name} field`, + ); + } + rangeStart = start; + } + + validateBounds(rangeStart, spec); + validateBounds(rangeEnd, spec); + + for (let i = rangeStart; i <= rangeEnd; i += step) { + values.add(i); + } + continue; + } + + // Check for range syntax: "N-M" + if (trimmed.includes("-")) { + const [aStr, bStr] = trimmed.split("-"); + const a = parseInt(aStr!, 10); + const b = parseInt(bStr!, 10); + if (isNaN(a) || isNaN(b)) { + throw new Error( + `Invalid range "${trimmed}" in cron ${spec.name} field`, + ); + } + validateBounds(a, spec); + validateBounds(b, spec); + if (a > b) { + throw new Error( + `Invalid range ${a}-${b} in cron ${spec.name} field (start > end)`, + ); + } + for (let i = a; i <= b; i++) { + values.add(i); + } + continue; + } + + // Wildcard + if (trimmed === "*") { + for (let i = spec.min; i <= spec.max; i++) { + values.add(i); + } + continue; + } + + // Single value + const val = parseInt(trimmed, 10); + if (isNaN(val)) { + throw new Error( + `Invalid value "${trimmed}" in cron ${spec.name} field`, + ); + } + validateBounds(val, spec); + values.add(val); + } + + if (values.size === 0) { + throw new Error(`Empty result for cron ${spec.name} field`); + } + + return [...values].sort((a, b) => a - b); +} + +function validateBounds(value: number, spec: FieldSpec): void { + if (value < spec.min || value > spec.max) { + throw new Error( + `Value ${value} out of range [${spec.min}–${spec.max}] for cron ${spec.name} field`, + ); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Parse a cron expression string into a structured {@link ParsedCron}. + * + * @param expression — A standard 5-field cron expression. + * @returns Parsed cron with sorted valid values for each field. + * @throws {Error} on invalid syntax. + * + * @example + * ```ts + * const parsed = parseCron("0 * * * *"); // every hour at minute 0 + * // parsed.minutes === [0] + * // parsed.hours === [0,1,2,...,23] + * ``` + */ +export function parseCron(expression: string): ParsedCron { + const trimmed = expression.trim(); + if (!trimmed) { + throw new Error("Cron expression must not be empty"); + } + + const tokens = trimmed.split(/\s+/); + if (tokens.length !== 5) { + throw new Error( + `Cron expression must have exactly 5 fields, got ${tokens.length}: "${trimmed}"`, + ); + } + + return { + minutes: parseField(tokens[0]!, FIELD_SPECS[0]!), + hours: parseField(tokens[1]!, FIELD_SPECS[1]!), + daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!), + months: parseField(tokens[3]!, FIELD_SPECS[3]!), + daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!), + }; +} + +/** + * Validate a cron expression string. Returns `null` if valid, or an error + * message string if invalid. + * + * @param expression — A cron expression string to validate. + * @returns `null` on success, error message on failure. + */ +export function validateCron(expression: string): string | null { + try { + parseCron(expression); + return null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +/** + * Calculate the next run time after `after` for the given parsed cron schedule. + * + * Starts from the minute immediately following `after` and walks forward + * until a matching minute is found (up to a safety limit of ~4 years to + * prevent infinite loops on impossible schedules). + * + * @param cron — Parsed cron schedule. + * @param after — The reference date. The returned date will be strictly after this. + * @returns The next matching `Date`, or `null` if no match found within the search window. + */ +export function nextCronTick(cron: ParsedCron, after: Date): Date | null { + // Work in local minutes — start from the minute after `after` + const d = new Date(after.getTime()); + // Advance to the next whole minute + d.setUTCSeconds(0, 0); + d.setUTCMinutes(d.getUTCMinutes() + 1); + + // Safety: search up to 4 years worth of minutes (~2.1M iterations max). + // Uses 366 to account for leap years. + const MAX_CRON_SEARCH_YEARS = 4; + const maxIterations = MAX_CRON_SEARCH_YEARS * 366 * 24 * 60; + + for (let i = 0; i < maxIterations; i++) { + const month = d.getUTCMonth() + 1; // 1-12 + const dayOfMonth = d.getUTCDate(); // 1-31 + const dayOfWeek = d.getUTCDay(); // 0-6 + const hour = d.getUTCHours(); // 0-23 + const minute = d.getUTCMinutes(); // 0-59 + + // Check month + if (!cron.months.includes(month)) { + // Skip to the first day of the next matching month + advanceToNextMonth(d, cron.months); + continue; + } + + // Check day of month AND day of week (both must match) + if (!cron.daysOfMonth.includes(dayOfMonth) || !cron.daysOfWeek.includes(dayOfWeek)) { + // Advance one day + d.setUTCDate(d.getUTCDate() + 1); + d.setUTCHours(0, 0, 0, 0); + continue; + } + + // Check hour + if (!cron.hours.includes(hour)) { + // Advance to next matching hour within the day + const nextHour = findNext(cron.hours, hour); + if (nextHour !== null) { + d.setUTCHours(nextHour, 0, 0, 0); + } else { + // No matching hour left today — advance to next day + d.setUTCDate(d.getUTCDate() + 1); + d.setUTCHours(0, 0, 0, 0); + } + continue; + } + + // Check minute + if (!cron.minutes.includes(minute)) { + const nextMin = findNext(cron.minutes, minute); + if (nextMin !== null) { + d.setUTCMinutes(nextMin, 0, 0); + } else { + // No matching minute left this hour — advance to next hour + d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0); + } + continue; + } + + // All fields match! + return new Date(d.getTime()); + } + + // No match found within the search window + return null; +} + +/** + * Convenience: parse a cron expression and compute the next run time. + * + * @param expression — 5-field cron expression string. + * @param after — Reference date (defaults to `new Date()`). + * @returns The next matching Date, or `null` if no match within 4 years. + * @throws {Error} if the cron expression is invalid. + */ +export function nextCronTickFromExpression( + expression: string, + after: Date = new Date(), +): Date | null { + const cron = parseCron(expression); + return nextCronTick(cron, after); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Find the next value in `sortedValues` that is greater than `current`. + * Returns `null` if no such value exists. + */ +function findNext(sortedValues: number[], current: number): number | null { + for (const v of sortedValues) { + if (v > current) return v; + } + return null; +} + +/** + * Advance `d` (mutated in place) to midnight UTC of the first day of the next + * month whose 1-based month number is in `months`. + */ +function advanceToNextMonth(d: Date, months: number[]): void { + let year = d.getUTCFullYear(); + let month = d.getUTCMonth() + 1; // 1-based + + // Walk months forward until we find one in the set (max 48 iterations = 4 years) + for (let i = 0; i < 48; i++) { + month++; + if (month > 12) { + month = 1; + year++; + } + if (months.includes(month)) { + d.setUTCFullYear(year, month - 1, 1); + d.setUTCHours(0, 0, 0, 0); + return; + } + } +} diff --git a/server/src/services/live-events.ts b/server/src/services/live-events.ts index 1421d07d..7db40d49 100644 --- a/server/src/services/live-events.ts +++ b/server/src/services/live-events.ts @@ -34,7 +34,21 @@ export function publishLiveEvent(input: { return event; } +export function publishGlobalLiveEvent(input: { + type: LiveEventType; + payload?: LiveEventPayload; +}) { + const event = toLiveEvent({ companyId: "*", type: input.type, payload: input.payload }); + emitter.emit("*", event); + return event; +} + export function subscribeCompanyLiveEvents(companyId: string, listener: LiveEventListener) { emitter.on(companyId, listener); return () => emitter.off(companyId, listener); } + +export function subscribeGlobalLiveEvents(listener: LiveEventListener) { + emitter.on("*", listener); + return () => emitter.off("*", listener); +} diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts new file mode 100644 index 00000000..56b22f58 --- /dev/null +++ b/server/src/services/plugin-capability-validator.ts @@ -0,0 +1,451 @@ +/** + * PluginCapabilityValidator — enforces the capability model at both + * install-time and runtime. + * + * Every plugin declares the capabilities it requires in its manifest + * (`manifest.capabilities`). This service checks those declarations + * against a mapping of operations → required capabilities so that: + * + * 1. **Install-time validation** — `validateManifestCapabilities()` + * ensures that declared features (tools, jobs, webhooks, UI slots) + * have matching capability entries, giving operators clear feedback + * before a plugin is activated. + * + * 2. **Runtime gating** — `checkOperation()` / `assertOperation()` are + * called on every worker→host bridge call to enforce least-privilege + * access. If a plugin attempts an operation it did not declare, the + * call is rejected with a 403 error. + * + * @see PLUGIN_SPEC.md §15 — Capability Model + * @see host-client-factory.ts — SDK-side capability gating + */ +import type { + PluginCapability, + PaperclipPluginManifestV1, + PluginUiSlotType, + PluginLauncherPlacementZone, +} from "@paperclipai/shared"; +import { forbidden } from "../errors.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Capability requirement mappings +// --------------------------------------------------------------------------- + +/** + * Maps high-level operations to the capabilities they require. + * + * When the bridge receives a call from a plugin worker, the host looks up + * the operation in this map and checks the plugin's declared capabilities. + * If any required capability is missing, the call is rejected. + * + * @see PLUGIN_SPEC.md §15 — Capability Model + */ +const OPERATION_CAPABILITIES: Record = { + // Data read operations + "companies.list": ["companies.read"], + "companies.get": ["companies.read"], + "projects.list": ["projects.read"], + "projects.get": ["projects.read"], + "project.workspaces.list": ["project.workspaces.read"], + "project.workspaces.get": ["project.workspaces.read"], + "issues.list": ["issues.read"], + "issues.get": ["issues.read"], + "issue.comments.list": ["issue.comments.read"], + "issue.comments.get": ["issue.comments.read"], + "agents.list": ["agents.read"], + "agents.get": ["agents.read"], + "goals.list": ["goals.read"], + "goals.get": ["goals.read"], + "activity.list": ["activity.read"], + "activity.get": ["activity.read"], + "costs.list": ["costs.read"], + "costs.get": ["costs.read"], + "assets.list": ["assets.read"], + "assets.get": ["assets.read"], + + // Data write operations + "issues.create": ["issues.create"], + "issues.update": ["issues.update"], + "issue.comments.create": ["issue.comments.create"], + "assets.upload": ["assets.write"], + "assets.delete": ["assets.write"], + "activity.log": ["activity.log.write"], + "metrics.write": ["metrics.write"], + + // Plugin state operations + "plugin.state.get": ["plugin.state.read"], + "plugin.state.list": ["plugin.state.read"], + "plugin.state.set": ["plugin.state.write"], + "plugin.state.delete": ["plugin.state.write"], + + // Runtime / Integration operations + "events.subscribe": ["events.subscribe"], + "events.emit": ["events.emit"], + "jobs.schedule": ["jobs.schedule"], + "jobs.cancel": ["jobs.schedule"], + "webhooks.receive": ["webhooks.receive"], + "http.request": ["http.outbound"], + "secrets.resolve": ["secrets.read-ref"], + + // Agent tools + "agent.tools.register": ["agent.tools.register"], + "agent.tools.execute": ["agent.tools.register"], +}; + +/** + * Maps UI slot types to the capability required to register them. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +const UI_SLOT_CAPABILITIES: Record = { + sidebar: "ui.sidebar.register", + sidebarPanel: "ui.sidebar.register", + projectSidebarItem: "ui.sidebar.register", + page: "ui.page.register", + detailTab: "ui.detailTab.register", + taskDetailView: "ui.detailTab.register", + dashboardWidget: "ui.dashboardWidget.register", + toolbarButton: "ui.action.register", + contextMenuItem: "ui.action.register", + commentAnnotation: "ui.commentAnnotation.register", + commentContextMenuItem: "ui.action.register", + settingsPage: "instance.settings.register", +}; + +/** + * Launcher placement zones align with host UI surfaces and therefore inherit + * the same capability requirements as the equivalent slot type. + */ +const LAUNCHER_PLACEMENT_CAPABILITIES: Record< + PluginLauncherPlacementZone, + PluginCapability +> = { + page: "ui.page.register", + detailTab: "ui.detailTab.register", + taskDetailView: "ui.detailTab.register", + dashboardWidget: "ui.dashboardWidget.register", + sidebar: "ui.sidebar.register", + sidebarPanel: "ui.sidebar.register", + projectSidebarItem: "ui.sidebar.register", + toolbarButton: "ui.action.register", + contextMenuItem: "ui.action.register", + commentAnnotation: "ui.commentAnnotation.register", + commentContextMenuItem: "ui.action.register", + settingsPage: "instance.settings.register", +}; + +/** + * Maps feature declarations in the manifest to their required capabilities. + */ +const FEATURE_CAPABILITIES: Record = { + tools: "agent.tools.register", + jobs: "jobs.schedule", + webhooks: "webhooks.receive", +}; + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +/** + * Result of a capability check. When `allowed` is false, `missing` contains + * the capabilities that the plugin does not declare but the operation requires. + */ +export interface CapabilityCheckResult { + allowed: boolean; + missing: PluginCapability[]; + operation?: string; + pluginId?: string; +} + +// --------------------------------------------------------------------------- +// PluginCapabilityValidator interface +// --------------------------------------------------------------------------- + +export interface PluginCapabilityValidator { + /** + * Check whether a plugin has a specific capability. + */ + hasCapability( + manifest: PaperclipPluginManifestV1, + capability: PluginCapability, + ): boolean; + + /** + * Check whether a plugin has all of the specified capabilities. + */ + hasAllCapabilities( + manifest: PaperclipPluginManifestV1, + capabilities: PluginCapability[], + ): CapabilityCheckResult; + + /** + * Check whether a plugin has at least one of the specified capabilities. + */ + hasAnyCapability( + manifest: PaperclipPluginManifestV1, + capabilities: PluginCapability[], + ): boolean; + + /** + * Check whether a plugin is allowed to perform the named operation. + * + * Operations are mapped to required capabilities via OPERATION_CAPABILITIES. + * Unknown operations are rejected by default. + */ + checkOperation( + manifest: PaperclipPluginManifestV1, + operation: string, + ): CapabilityCheckResult; + + /** + * Assert that a plugin is allowed to perform an operation. + * Throws a 403 HttpError if the capability check fails. + */ + assertOperation( + manifest: PaperclipPluginManifestV1, + operation: string, + ): void; + + /** + * Assert that a plugin has a specific capability. + * Throws a 403 HttpError if the capability is missing. + */ + assertCapability( + manifest: PaperclipPluginManifestV1, + capability: PluginCapability, + ): void; + + /** + * Check whether a plugin can register the given UI slot type. + */ + checkUiSlot( + manifest: PaperclipPluginManifestV1, + slotType: PluginUiSlotType, + ): CapabilityCheckResult; + + /** + * Validate that a manifest's declared capabilities are consistent with its + * declared features (tools, jobs, webhooks, UI slots). + * + * Returns all missing capabilities rather than failing on the first one. + * This is useful for install-time validation to give comprehensive feedback. + */ + validateManifestCapabilities( + manifest: PaperclipPluginManifestV1, + ): CapabilityCheckResult; + + /** + * Get the capabilities required for a named operation. + * Returns an empty array if the operation is unknown. + */ + getRequiredCapabilities(operation: string): readonly PluginCapability[]; + + /** + * Get the capability required for a UI slot type. + */ + getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a PluginCapabilityValidator. + * + * This service enforces capability gates for plugin operations. The host + * uses it to verify that a plugin's declared capabilities permit the + * operation it is attempting, both at install time (manifest validation) + * and at runtime (bridge call gating). + * + * Usage: + * ```ts + * const validator = pluginCapabilityValidator(); + * + * // Runtime: gate a bridge call + * validator.assertOperation(plugin.manifestJson, "issues.create"); + * + * // Install time: validate manifest consistency + * const result = validator.validateManifestCapabilities(manifest); + * if (!result.allowed) { + * throw badRequest("Missing capabilities", result.missing); + * } + * ``` + */ +export function pluginCapabilityValidator(): PluginCapabilityValidator { + const log = logger.child({ service: "plugin-capability-validator" }); + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + function capabilitySet(manifest: PaperclipPluginManifestV1): Set { + return new Set(manifest.capabilities); + } + + function buildForbiddenMessage( + manifest: PaperclipPluginManifestV1, + operation: string, + missing: PluginCapability[], + ): string { + return ( + `Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` + + `Missing required capabilities: ${missing.join(", ")}` + ); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + hasCapability(manifest, capability) { + return manifest.capabilities.includes(capability); + }, + + hasAllCapabilities(manifest, capabilities) { + const declared = capabilitySet(manifest); + const missing = capabilities.filter((cap) => !declared.has(cap)); + return { + allowed: missing.length === 0, + missing, + pluginId: manifest.id, + }; + }, + + hasAnyCapability(manifest, capabilities) { + const declared = capabilitySet(manifest); + return capabilities.some((cap) => declared.has(cap)); + }, + + checkOperation(manifest, operation) { + const required = OPERATION_CAPABILITIES[operation]; + + if (!required) { + log.warn( + { pluginId: manifest.id, operation }, + "capability check for unknown operation – rejecting by default", + ); + return { + allowed: false, + missing: [], + operation, + pluginId: manifest.id, + }; + } + + const declared = capabilitySet(manifest); + const missing = required.filter((cap) => !declared.has(cap)); + + if (missing.length > 0) { + log.debug( + { pluginId: manifest.id, operation, missing }, + "capability check failed", + ); + } + + return { + allowed: missing.length === 0, + missing, + operation, + pluginId: manifest.id, + }; + }, + + assertOperation(manifest, operation) { + const result = this.checkOperation(manifest, operation); + if (!result.allowed) { + const msg = result.missing.length > 0 + ? buildForbiddenMessage(manifest, operation, result.missing) + : `Plugin '${manifest.id}' attempted unknown operation '${operation}'`; + throw forbidden(msg); + } + }, + + assertCapability(manifest, capability) { + if (!this.hasCapability(manifest, capability)) { + throw forbidden( + `Plugin '${manifest.id}' lacks required capability '${capability}'`, + ); + } + }, + + checkUiSlot(manifest, slotType) { + const required = UI_SLOT_CAPABILITIES[slotType]; + if (!required) { + return { + allowed: false, + missing: [], + operation: `ui.${slotType}.register`, + pluginId: manifest.id, + }; + } + + const has = manifest.capabilities.includes(required); + return { + allowed: has, + missing: has ? [] : [required], + operation: `ui.${slotType}.register`, + pluginId: manifest.id, + }; + }, + + validateManifestCapabilities(manifest) { + const declared = capabilitySet(manifest); + const allMissing: PluginCapability[] = []; + + // Check feature declarations → required capabilities + for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) { + const featureValue = manifest[feature as keyof PaperclipPluginManifestV1]; + if (Array.isArray(featureValue) && featureValue.length > 0) { + if (!declared.has(requiredCap)) { + allMissing.push(requiredCap); + } + } + } + + // Check UI slots → required capabilities + const uiSlots = manifest.ui?.slots ?? []; + if (uiSlots.length > 0) { + for (const slot of uiSlots) { + const requiredCap = UI_SLOT_CAPABILITIES[slot.type]; + if (requiredCap && !declared.has(requiredCap)) { + if (!allMissing.includes(requiredCap)) { + allMissing.push(requiredCap); + } + } + } + } + + // Check launcher declarations → required capabilities + const launchers = [ + ...(manifest.launchers ?? []), + ...(manifest.ui?.launchers ?? []), + ]; + if (launchers.length > 0) { + for (const launcher of launchers) { + const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone]; + if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) { + allMissing.push(requiredCap); + } + } + } + + return { + allowed: allMissing.length === 0, + missing: allMissing, + pluginId: manifest.id, + }; + }, + + getRequiredCapabilities(operation) { + return OPERATION_CAPABILITIES[operation] ?? []; + }, + + getUiSlotCapability(slotType) { + return UI_SLOT_CAPABILITIES[slotType]; + }, + }; +} diff --git a/server/src/services/plugin-config-validator.ts b/server/src/services/plugin-config-validator.ts new file mode 100644 index 00000000..9e064572 --- /dev/null +++ b/server/src/services/plugin-config-validator.ts @@ -0,0 +1,50 @@ +/** + * @fileoverview Validates plugin instance configuration against its JSON Schema. + * + * Uses Ajv to validate `configJson` values against the `instanceConfigSchema` + * declared in a plugin's manifest. This ensures that invalid configuration is + * rejected at the API boundary, not discovered later at worker startup. + * + * @module server/services/plugin-config-validator + */ + +import Ajv, { type ErrorObject } from "ajv"; +import addFormats from "ajv-formats"; +import type { JsonSchema } from "@paperclipai/shared"; + +export interface ConfigValidationResult { + valid: boolean; + errors?: { field: string; message: string }[]; +} + +/** + * Validate a config object against a JSON Schema. + * + * @param configJson - The configuration values to validate. + * @param schema - The JSON Schema from the plugin manifest's `instanceConfigSchema`. + * @returns Validation result with structured field errors on failure. + */ +export function validateInstanceConfig( + configJson: Record, + schema: JsonSchema, +): ConfigValidationResult { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const AjvCtor = (Ajv as any).default ?? Ajv; + const ajv = new AjvCtor({ allErrors: true }); + // ajv-formats v3 default export is a FormatsPlugin object; call it as a plugin. + const applyFormats = (addFormats as any).default ?? addFormats; + applyFormats(ajv); + const validate = ajv.compile(schema); + const valid = validate(configJson); + + if (valid) { + return { valid: true }; + } + + const errors = (validate.errors ?? []).map((err: ErrorObject) => ({ + field: err.instancePath || "/", + message: err.message ?? "validation failed", + })); + + return { valid: false, errors }; +} diff --git a/server/src/services/plugin-dev-watcher.ts b/server/src/services/plugin-dev-watcher.ts new file mode 100644 index 00000000..156b2368 --- /dev/null +++ b/server/src/services/plugin-dev-watcher.ts @@ -0,0 +1,189 @@ +/** + * PluginDevWatcher — watches local-path plugin directories for file changes + * and triggers worker restarts so plugin authors get a fast rebuild-and-reload + * cycle without manually restarting the server. + * + * Only plugins installed from a local path (i.e. those with a non-null + * `packagePath` in the DB) are watched. File changes in the plugin's package + * directory trigger a debounced worker restart via the lifecycle manager. + * + * @see PLUGIN_SPEC.md §27.2 — Local Development Workflow + */ +import { watch, type FSWatcher } from "node:fs"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { logger } from "../middleware/logger.js"; +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; + +const log = logger.child({ service: "plugin-dev-watcher" }); + +/** Debounce interval for file changes (ms). */ +const DEBOUNCE_MS = 500; + +export interface PluginDevWatcher { + /** Start watching a local-path plugin directory. */ + watch(pluginId: string, packagePath: string): void; + /** Stop watching a specific plugin. */ + unwatch(pluginId: string): void; + /** Stop all watchers and clean up. */ + close(): void; +} + +export type ResolvePluginPackagePath = ( + pluginId: string, +) => Promise; + +export interface PluginDevWatcherFsDeps { + existsSync?: typeof existsSync; + watch?: typeof watch; +} + +/** + * Create a PluginDevWatcher that monitors local plugin directories and + * restarts workers on file changes. + */ +export function createPluginDevWatcher( + lifecycle: PluginLifecycleManager, + resolvePluginPackagePath?: ResolvePluginPackagePath, + fsDeps?: PluginDevWatcherFsDeps, +): PluginDevWatcher { + const watchers = new Map(); + const debounceTimers = new Map>(); + const fileExists = fsDeps?.existsSync ?? existsSync; + const watchFs = fsDeps?.watch ?? watch; + + function watchPlugin(pluginId: string, packagePath: string): void { + // Don't double-watch + if (watchers.has(pluginId)) return; + + const absPath = path.resolve(packagePath); + if (!fileExists(absPath)) { + log.warn( + { pluginId, packagePath: absPath }, + "plugin-dev-watcher: package path does not exist, skipping watch", + ); + return; + } + + try { + const watcher = watchFs(absPath, { recursive: true }, (_event, filename) => { + // Ignore node_modules and hidden files inside the plugin dir + if ( + filename && + (filename.includes("node_modules") || filename.startsWith(".")) + ) { + return; + } + + // Debounce: multiple rapid file changes collapse into one restart + const existing = debounceTimers.get(pluginId); + if (existing) clearTimeout(existing); + + debounceTimers.set( + pluginId, + setTimeout(() => { + debounceTimers.delete(pluginId); + log.info( + { pluginId, changedFile: filename }, + "plugin-dev-watcher: file change detected, restarting worker", + ); + + lifecycle.restartWorker(pluginId).catch((err) => { + log.warn( + { + pluginId, + err: err instanceof Error ? err.message : String(err), + }, + "plugin-dev-watcher: failed to restart worker after file change", + ); + }); + }, DEBOUNCE_MS), + ); + }); + + watchers.set(pluginId, watcher); + log.info( + { pluginId, packagePath: absPath }, + "plugin-dev-watcher: watching local plugin for changes", + ); + } catch (err) { + log.warn( + { + pluginId, + packagePath: absPath, + err: err instanceof Error ? err.message : String(err), + }, + "plugin-dev-watcher: failed to start file watcher", + ); + } + } + + function unwatchPlugin(pluginId: string): void { + const watcher = watchers.get(pluginId); + if (watcher) { + watcher.close(); + watchers.delete(pluginId); + } + const timer = debounceTimers.get(pluginId); + if (timer) { + clearTimeout(timer); + debounceTimers.delete(pluginId); + } + } + + function close(): void { + lifecycle.off("plugin.loaded", handlePluginLoaded); + lifecycle.off("plugin.enabled", handlePluginEnabled); + lifecycle.off("plugin.disabled", handlePluginDisabled); + lifecycle.off("plugin.unloaded", handlePluginUnloaded); + + for (const [pluginId] of watchers) { + unwatchPlugin(pluginId); + } + } + + async function watchLocalPluginById(pluginId: string): Promise { + if (!resolvePluginPackagePath) return; + + try { + const packagePath = await resolvePluginPackagePath(pluginId); + if (!packagePath) return; + watchPlugin(pluginId, packagePath); + } catch (err) { + log.warn( + { + pluginId, + err: err instanceof Error ? err.message : String(err), + }, + "plugin-dev-watcher: failed to resolve plugin package path", + ); + } + } + + function handlePluginLoaded(payload: { pluginId: string }): void { + void watchLocalPluginById(payload.pluginId); + } + + function handlePluginEnabled(payload: { pluginId: string }): void { + void watchLocalPluginById(payload.pluginId); + } + + function handlePluginDisabled(payload: { pluginId: string }): void { + unwatchPlugin(payload.pluginId); + } + + function handlePluginUnloaded(payload: { pluginId: string }): void { + unwatchPlugin(payload.pluginId); + } + + lifecycle.on("plugin.loaded", handlePluginLoaded); + lifecycle.on("plugin.enabled", handlePluginEnabled); + lifecycle.on("plugin.disabled", handlePluginDisabled); + lifecycle.on("plugin.unloaded", handlePluginUnloaded); + + return { + watch: watchPlugin, + unwatch: unwatchPlugin, + close, + }; +} diff --git a/server/src/services/plugin-event-bus.ts b/server/src/services/plugin-event-bus.ts new file mode 100644 index 00000000..78184b47 --- /dev/null +++ b/server/src/services/plugin-event-bus.ts @@ -0,0 +1,515 @@ +/** + * PluginEventBus — typed in-process event bus for the Paperclip plugin system. + * + * Responsibilities: + * - Deliver core domain events to subscribing plugin workers (server-side). + * - Apply `EventFilter` server-side so filtered-out events never reach the handler. + * - Namespace plugin-emitted events as `plugin..`. + * - Guard the core namespace: plugins may not emit events with the `plugin.` prefix. + * - Isolate subscriptions per plugin — a plugin cannot enumerate or interfere with + * another plugin's subscriptions. + * - Support wildcard subscriptions via prefix matching (e.g. `plugin.acme.linear.*`). + * + * The bus operates in-process. In the full out-of-process architecture the host + * calls `bus.emit()` after receiving events from the DB/queue layer, and the bus + * forwards to handlers that proxy the call to the relevant worker process via IPC. + * That IPC layer is separate; this module only handles routing and filtering. + * + * @see PLUGIN_SPEC.md §16 — Event System + * @see PLUGIN_SPEC.md §16.1 — Event Filtering + * @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events + */ + +import type { PluginEventType } from "@paperclipai/shared"; +import type { PluginEvent, EventFilter } from "@paperclipai/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** + * A registered subscription record stored per plugin. + */ +interface Subscription { + /** The event name or prefix pattern this subscription matches. */ + eventPattern: string; + /** Optional server-side filter applied before delivery. */ + filter: EventFilter | null; + /** Async handler to invoke when a matching event passes the filter. */ + handler: (event: PluginEvent) => Promise; +} + +// --------------------------------------------------------------------------- +// Pattern matching helpers +// --------------------------------------------------------------------------- + +/** + * Returns true if the event type matches the subscription pattern. + * + * Matching rules: + * - Exact match: `"issue.created"` matches `"issue.created"`. + * - Wildcard suffix: `"plugin.acme.*"` matches any event type that starts with + * `"plugin.acme."`. The wildcard `*` is only supported as a trailing token. + * + * No full glob syntax is supported — only trailing `*` after a `.` separator. + */ +function matchesPattern(eventType: string, pattern: string): boolean { + if (pattern === eventType) return true; + + // Trailing wildcard: "plugin.foo.*" → prefix is "plugin.foo." + if (pattern.endsWith(".*")) { + const prefix = pattern.slice(0, -1); // remove the trailing "*", keep the "." + return eventType.startsWith(prefix); + } + + return false; +} + +/** + * Returns true if the event passes all fields of the filter. + * A `null` or empty filter object passes all events. + * + * **Resolution strategy per field:** + * + * - `projectId` — checked against `event.entityId` when `entityType === "project"`, + * otherwise against `payload.projectId`. This covers both direct project events + * (e.g. `project.created`) and secondary events that embed a project reference in + * their payload (e.g. `issue.created` with `payload.projectId`). + * + * - `companyId` — always resolved from `payload.companyId`. Core domain events that + * belong to a company embed the company ID in their payload. + * + * - `agentId` — checked against `event.entityId` when `entityType === "agent"`, + * otherwise against `payload.agentId`. Covers both direct agent lifecycle events + * (e.g. `agent.created`) and run-level events with `payload.agentId` (e.g. + * `agent.run.started`). + * + * Multiple filter fields are ANDed — all specified fields must match. + */ +function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean { + if (!filter) return true; + + const payload = event.payload as Record | null; + + if (filter.projectId !== undefined) { + const projectId = event.entityType === "project" + ? event.entityId + : (typeof payload?.projectId === "string" ? payload.projectId : undefined); + if (projectId !== filter.projectId) return false; + } + + if (filter.companyId !== undefined) { + if (event.companyId !== filter.companyId) return false; + } + + if (filter.agentId !== undefined) { + const agentId = event.entityType === "agent" + ? event.entityId + : (typeof payload?.agentId === "string" ? payload.agentId : undefined); + if (agentId !== filter.agentId) return false; + } + + return true; +} + +// --------------------------------------------------------------------------- +// Company availability checker +// --------------------------------------------------------------------------- + +/** + * Callback that checks whether a plugin is enabled for a given company. + * + * The event bus calls this during `emit()` to enforce company-scoped delivery: + * events are only delivered to a plugin if the plugin is enabled for the + * company that owns the event. + * + * Implementations should be fast — the bus caches results internally with a + * short TTL so the checker is not invoked on every single event. + * + * @param pluginKey The plugin registry key — the string passed to `forPlugin()` + * (e.g. `"acme.linear"`). This is the same key used throughout the bus + * internally and should not be confused with a numeric or UUID plugin ID. + * @param companyId UUID of the company to check availability for. + * + * Return `true` if the plugin is enabled (or if no settings row exists, i.e. + * default-enabled), `false` if the company has explicitly disabled the plugin. + */ +export type CompanyAvailabilityChecker = ( + pluginKey: string, + companyId: string, +) => Promise; + +/** + * Options for {@link createPluginEventBus}. + */ +export interface PluginEventBusOptions { + /** + * Optional checker that gates event delivery per company. + * + * When provided, the bus will skip delivery to a plugin if the checker + * returns `false` for the `(pluginKey, event.companyId)` pair, where + * `pluginKey` is the registry key supplied to `forPlugin()`. Results are + * cached with a short TTL (30 s) to avoid excessive lookups. + * + * When omitted, no company-scoping is applied (useful in tests). + */ + isPluginEnabledForCompany?: CompanyAvailabilityChecker; +} + +// Default cache TTL in milliseconds (30 seconds). +const AVAILABILITY_CACHE_TTL_MS = 30_000; + +// Maximum number of entries in the availability cache before it is cleared. +// Prevents unbounded memory growth in long-running processes with many unique +// (pluginKey, companyId) pairs. A full clear is intentionally simple — the +// cache is advisory (performance only) and a miss merely triggers one extra +// async lookup. +const MAX_AVAILABILITY_CACHE_SIZE = 10_000; + +// --------------------------------------------------------------------------- +// Event bus factory +// --------------------------------------------------------------------------- + +/** + * Creates and returns a new `PluginEventBus` instance. + * + * A single bus instance should be shared across the server process. Each + * plugin interacts with the bus through a scoped handle obtained via + * {@link PluginEventBus.forPlugin}. + * + * @example + * ```ts + * const bus = createPluginEventBus(); + * + * // Give the Linear plugin a scoped handle + * const linearBus = bus.forPlugin("acme.linear"); + * + * // Subscribe from the plugin's perspective + * linearBus.subscribe("issue.created", async (event) => { + * // handle event + * }); + * + * // Emit a core domain event (called by the host, not the plugin) + * await bus.emit({ + * eventId: "evt-1", + * eventType: "issue.created", + * occurredAt: new Date().toISOString(), + * entityId: "iss-1", + * entityType: "issue", + * payload: { title: "Fix login bug", projectId: "proj-1" }, + * }); + * ``` + */ +export function createPluginEventBus(options?: PluginEventBusOptions): PluginEventBus { + const checker = options?.isPluginEnabledForCompany ?? null; + + // Subscription registry: pluginKey → list of subscriptions + const registry = new Map(); + + // Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt } + const availabilityCache = new Map(); + + function cacheKey(pluginKey: string, companyId: string): string { + return `${pluginKey}\0${companyId}`; + } + + /** + * Check whether a plugin is enabled for a company, using the cached result + * when available and falling back to the injected checker. + */ + async function isEnabledForCompany(pluginKey: string, companyId: string): Promise { + if (!checker) return true; + + const key = cacheKey(pluginKey, companyId); + const cached = availabilityCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.enabled; + } + + const enabled = await checker(pluginKey, companyId); + if (availabilityCache.size >= MAX_AVAILABILITY_CACHE_SIZE) { + availabilityCache.clear(); + } + availabilityCache.set(key, { enabled, expiresAt: Date.now() + AVAILABILITY_CACHE_TTL_MS }); + return enabled; + } + + /** + * Retrieve or create the subscription list for a plugin. + */ + function subsFor(pluginId: string): Subscription[] { + let subs = registry.get(pluginId); + if (!subs) { + subs = []; + registry.set(pluginId, subs); + } + return subs; + } + + /** + * Emit an event envelope to all matching subscribers across all plugins. + * + * Subscribers are called concurrently (Promise.all). Each handler's errors + * are caught individually and collected in the returned `errors` array so a + * single misbehaving plugin cannot interrupt delivery to other plugins. + */ + async function emit(event: PluginEvent): Promise { + const errors: Array<{ pluginId: string; error: unknown }> = []; + const promises: Promise[] = []; + + // Pre-compute company availability for all registered plugins when the + // event carries a companyId and a checker is configured. This batches + // the (potentially async) lookups so we don't interleave them with + // handler dispatch. + let disabledPlugins: Set | null = null; + if (checker && event.companyId) { + const pluginKeys = Array.from(registry.keys()); + const checks = await Promise.all( + pluginKeys.map(async (pluginKey) => ({ + pluginKey, + enabled: await isEnabledForCompany(pluginKey, event.companyId!), + })), + ); + disabledPlugins = new Set(checks.filter((c) => !c.enabled).map((c) => c.pluginKey)); + } + + for (const [pluginId, subs] of registry) { + // Skip delivery to plugins that are disabled for this company. + if (disabledPlugins?.has(pluginId)) continue; + + for (const sub of subs) { + if (!matchesPattern(event.eventType, sub.eventPattern)) continue; + if (!passesFilter(event, sub.filter)) continue; + + // Use Promise.resolve().then() so that synchronous throws from handlers + // are also caught inside the promise chain. Calling + // Promise.resolve(syncThrowingFn()) does NOT catch sync throws — the + // throw escapes before Promise.resolve() can wrap it. Using .then() + // ensures the call is deferred into the microtask queue where all + // exceptions become rejections. Each .catch() swallows the rejection + // and records it — the promise always resolves, so Promise.all never rejects. + promises.push( + Promise.resolve().then(() => sub.handler(event)).catch((error: unknown) => { + errors.push({ pluginId, error }); + }), + ); + } + } + + await Promise.all(promises); + return { errors }; + } + + /** + * Remove all subscriptions for a plugin (e.g. on worker shutdown or uninstall). + */ + function clearPlugin(pluginId: string): void { + registry.delete(pluginId); + } + + /** + * Return a scoped handle for a specific plugin. The handle exposes only the + * plugin's own subscription list and enforces the plugin namespace on `emit`. + */ + function forPlugin(pluginId: string): ScopedPluginEventBus { + return { + /** + * Subscribe to a core domain event or a plugin-namespaced event. + * + * For wildcard subscriptions use a trailing `.*` pattern, e.g. + * `"plugin.acme.linear.*"`. + * + * Requires the `events.subscribe` capability (capability enforcement is + * done by the host layer before calling this method). + */ + subscribe( + eventPattern: PluginEventType | `plugin.${string}`, + fnOrFilter: EventFilter | ((event: PluginEvent) => Promise), + maybeFn?: (event: PluginEvent) => Promise, + ): void { + let filter: EventFilter | null = null; + let handler: (event: PluginEvent) => Promise; + + if (typeof fnOrFilter === "function") { + handler = fnOrFilter; + } else { + filter = fnOrFilter; + if (!maybeFn) throw new Error("Handler function is required when a filter is provided"); + handler = maybeFn; + } + + subsFor(pluginId).push({ eventPattern, filter, handler }); + }, + + /** + * Emit a plugin-namespaced event. The event type is automatically + * prefixed with `plugin..` so: + * - `emit("sync-done", payload)` becomes `"plugin.acme.linear.sync-done"`. + * + * Requires the `events.emit` capability (enforced by the host layer). + * + * @throws {Error} if `name` already contains the `plugin.` prefix + * (prevents cross-namespace spoofing). + */ + async emit(name: string, companyId: string, payload: unknown): Promise { + if (!name || name.trim() === "") { + throw new Error(`Plugin "${pluginId}" must provide a non-empty event name.`); + } + + if (!companyId || companyId.trim() === "") { + throw new Error(`Plugin "${pluginId}" must provide a companyId when emitting events.`); + } + + if (name.startsWith("plugin.")) { + throw new Error( + `Plugin "${pluginId}" must not include the "plugin." prefix when emitting events. ` + + `Emit the bare event name (e.g. "sync-done") and the bus will namespace it automatically.`, + ); + } + + const eventType = `plugin.${pluginId}.${name}` as const; + const event: PluginEvent = { + eventId: crypto.randomUUID(), + eventType, + companyId, + occurredAt: new Date().toISOString(), + actorType: "plugin", + actorId: pluginId, + payload, + }; + + return emit(event); + }, + + /** Remove all subscriptions registered by this plugin. */ + clear(): void { + clearPlugin(pluginId); + }, + }; + } + + return { + emit, + forPlugin, + clearPlugin, + /** Expose subscription count for a plugin (useful for tests and diagnostics). */ + subscriptionCount(pluginId?: string): number { + if (pluginId !== undefined) { + return registry.get(pluginId)?.length ?? 0; + } + let total = 0; + for (const subs of registry.values()) total += subs.length; + return total; + }, + }; +} + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Result returned from `emit()`. Handler errors are collected and returned + * rather than thrown so a single misbehaving plugin cannot block delivery to + * other plugins. + */ +export interface PluginEventBusEmitResult { + /** Errors thrown by individual handlers, keyed by the plugin that failed. */ + errors: Array<{ pluginId: string; error: unknown }>; +} + +/** + * The full event bus — held by the host process. + * + * Call `forPlugin(id)` to obtain a `ScopedPluginEventBus` for each plugin worker. + */ +export interface PluginEventBus { + /** + * Emit a typed domain event to all matching subscribers. + * + * Called by the host when a domain event occurs (e.g. from the DB layer or + * message queue). All registered subscriptions across all plugins are checked. + */ + emit(event: PluginEvent): Promise; + + /** + * Get a scoped handle for a specific plugin worker. + * + * The scoped handle isolates the plugin's subscriptions and enforces the + * plugin namespace on outbound events. + */ + forPlugin(pluginId: string): ScopedPluginEventBus; + + /** + * Remove all subscriptions for a plugin (called on worker shutdown/uninstall). + */ + clearPlugin(pluginId: string): void; + + /** + * Return the total number of active subscriptions, or the count for a + * specific plugin if `pluginId` is provided. + */ + subscriptionCount(pluginId?: string): number; +} + +/** + * A plugin-scoped view of the event bus. Handed to the plugin worker (or its + * host-side proxy) during initialisation. + * + * Plugins use this to: + * 1. Subscribe to domain events (with optional server-side filter). + * 2. Emit plugin-namespaced events for other plugins to consume. + * + * Note: `subscribe` overloads mirror the `PluginEventsClient.on()` interface + * from the SDK. `emit` intentionally returns `PluginEventBusEmitResult` rather + * than `void` so the host layer can inspect handler errors; the SDK-facing + * `PluginEventsClient.emit()` wraps this and returns `void`. + */ +export interface ScopedPluginEventBus { + /** + * Subscribe to a core domain event or a plugin-namespaced event. + * + * **Pattern syntax:** + * - Exact match: `"issue.created"` — receives only that event type. + * - Wildcard suffix: `"plugin.acme.linear.*"` — receives all events emitted by + * the `acme.linear` plugin. The `*` is supported only as a trailing token after + * a `.` separator; no other glob syntax is supported. + * - Top-level plugin wildcard: `"plugin.*"` — receives all plugin-emitted events + * regardless of which plugin emitted them. + * + * Wildcards apply only to the `plugin.*` namespace. Core domain events must be + * subscribed to by exact name (e.g. `"issue.created"`, not `"issue.*"`). + * + * An optional `EventFilter` can be passed as the second argument to perform + * server-side pre-filtering; filtered-out events are never delivered to the handler. + */ + subscribe( + eventPattern: PluginEventType | `plugin.${string}`, + fn: (event: PluginEvent) => Promise, + ): void; + subscribe( + eventPattern: PluginEventType | `plugin.${string}`, + filter: EventFilter, + fn: (event: PluginEvent) => Promise, + ): void; + + /** + * Emit a plugin-namespaced event. The bus automatically prepends + * `plugin..` to the `name`, so passing `"sync-done"` from plugin + * `"acme.linear"` produces the event type `"plugin.acme.linear.sync-done"`. + * + * @param name Bare event name (e.g. `"sync-done"`). Must be non-empty and + * must not include the `plugin.` prefix — the bus adds that automatically. + * @param companyId UUID of the company this event belongs to. + * @param payload Arbitrary JSON-serializable data to attach to the event. + * + * @throws {Error} if `name` is empty or whitespace-only. + * @throws {Error} if `name` starts with `"plugin."` (namespace spoofing guard). + */ + emit(name: string, companyId: string, payload: unknown): Promise; + + /** + * Remove all subscriptions registered by this plugin. + */ + clear(): void; +} diff --git a/server/src/services/plugin-host-service-cleanup.ts b/server/src/services/plugin-host-service-cleanup.ts new file mode 100644 index 00000000..cd197f7f --- /dev/null +++ b/server/src/services/plugin-host-service-cleanup.ts @@ -0,0 +1,59 @@ +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; + +type LifecycleLike = Pick; + +export interface PluginWorkerRuntimeEvent { + type: "plugin.worker.crashed" | "plugin.worker.restarted"; + pluginId: string; +} + +export interface PluginHostServiceCleanupController { + handleWorkerEvent(event: PluginWorkerRuntimeEvent): void; + disposeAll(): void; + teardown(): void; +} + +export function createPluginHostServiceCleanup( + lifecycle: LifecycleLike, + disposers: Map void>, +): PluginHostServiceCleanupController { + const runDispose = (pluginId: string, remove = false) => { + const dispose = disposers.get(pluginId); + if (!dispose) return; + dispose(); + if (remove) { + disposers.delete(pluginId); + } + }; + + const handleWorkerStopped = ({ pluginId }: { pluginId: string }) => { + runDispose(pluginId); + }; + + const handlePluginUnloaded = ({ pluginId }: { pluginId: string }) => { + runDispose(pluginId, true); + }; + + lifecycle.on("plugin.worker_stopped", handleWorkerStopped); + lifecycle.on("plugin.unloaded", handlePluginUnloaded); + + return { + handleWorkerEvent(event) { + if (event.type === "plugin.worker.crashed") { + runDispose(event.pluginId); + } + }, + + disposeAll() { + for (const dispose of disposers.values()) { + dispose(); + } + disposers.clear(); + }, + + teardown() { + lifecycle.off("plugin.worker_stopped", handleWorkerStopped); + lifecycle.off("plugin.unloaded", handlePluginUnloaded); + }, + }; +} diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts new file mode 100644 index 00000000..d7910e97 --- /dev/null +++ b/server/src/services/plugin-host-services.ts @@ -0,0 +1,1077 @@ +import type { Db } from "@paperclipai/db"; +import { pluginLogs, agentTaskSessions as agentTaskSessionsTable } from "@paperclipai/db"; +import { eq, and, like, desc } from "drizzle-orm"; +import type { + HostServices, + Company, + Agent, + Project, + Issue, + Goal, + PluginWorkspace, + IssueComment, +} from "@paperclipai/plugin-sdk"; +import { companyService } from "./companies.js"; +import { agentService } from "./agents.js"; +import { projectService } from "./projects.js"; +import { issueService } from "./issues.js"; +import { goalService } from "./goals.js"; +import { heartbeatService } from "./heartbeat.js"; +import { subscribeCompanyLiveEvents } from "./live-events.js"; +import { randomUUID } from "node:crypto"; +import { activityService } from "./activity.js"; +import { costService } from "./costs.js"; +import { assetService } from "./assets.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import { pluginStateStore } from "./plugin-state-store.js"; +import { createPluginSecretsHandler } from "./plugin-secrets-handler.js"; +import { logActivity } from "./activity-log.js"; +import type { PluginEventBus } from "./plugin-event-bus.js"; +import { lookup as dnsLookup } from "node:dns/promises"; +import type { IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// SSRF protection for plugin HTTP fetch +// --------------------------------------------------------------------------- + +/** Maximum time (ms) a plugin fetch request may take before being aborted. */ +const PLUGIN_FETCH_TIMEOUT_MS = 30_000; + +/** Maximum time (ms) to wait for a DNS lookup before aborting. */ +const DNS_LOOKUP_TIMEOUT_MS = 5_000; + +/** Only these protocols are allowed for plugin HTTP requests. */ +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]); + +/** + * Check if an IP address is in a private/reserved range (RFC 1918, loopback, + * link-local, etc.) that plugins should never be able to reach. + * + * Handles IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) which Node's + * dns.lookup may return depending on OS configuration. + */ +function isPrivateIP(ip: string): boolean { + const lower = ip.toLowerCase(); + + // Unwrap IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) and re-check as IPv4 + const v4MappedMatch = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (v4MappedMatch && v4MappedMatch[1]) return isPrivateIP(v4MappedMatch[1]); + + // IPv4 patterns + if (ip.startsWith("10.")) return true; + if (ip.startsWith("172.")) { + const second = parseInt(ip.split(".")[1]!, 10); + if (second >= 16 && second <= 31) return true; + } + if (ip.startsWith("192.168.")) return true; + if (ip.startsWith("127.")) return true; // loopback + if (ip.startsWith("169.254.")) return true; // link-local + if (ip === "0.0.0.0") return true; + + // IPv6 patterns + if (lower === "::1") return true; // loopback + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA + if (lower.startsWith("fe80")) return true; // link-local + if (lower === "::") return true; + + return false; +} + +/** + * Validate a URL for plugin fetch: protocol whitelist + private IP blocking. + * + * SSRF Prevention Strategy: + * 1. Parse and validate the URL syntax + * 2. Enforce protocol whitelist (http/https only) + * 3. Resolve the hostname to IP(s) via DNS + * 4. Validate that ALL resolved IPs are non-private + * 5. Pin the first safe IP into the URL so fetch() does not re-resolve DNS + * + * This prevents DNS rebinding attacks where an attacker controls DNS to + * resolve to a safe IP during validation, then to a private IP when fetch() runs. + * + * @returns Request-routing metadata used to connect directly to the resolved IP + * while preserving the original hostname for HTTP Host and TLS SNI. + */ +interface ValidatedFetchTarget { + parsedUrl: URL; + resolvedAddress: string; + hostHeader: string; + tlsServername?: string; + useTls: boolean; +} + +async function validateAndResolveFetchUrl(urlString: string): Promise { + let parsed: URL; + try { + parsed = new URL(urlString); + } catch { + throw new Error(`Invalid URL: ${urlString}`); + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `Disallowed protocol "${parsed.protocol}" — only http: and https: are permitted`, + ); + } + + // Resolve the hostname to an IP and check for private ranges. + // We pin the resolved IP into the URL to eliminate the TOCTOU window + // between DNS resolution here and the second resolution fetch() would do. + const originalHostname = parsed.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + const hostHeader = parsed.host; // includes port if non-default + + // Race the DNS lookup against a timeout to prevent indefinite hangs + // when DNS is misconfigured or unresponsive. + const dnsPromise = dnsLookup(originalHostname, { all: true }); + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`DNS lookup timed out after ${DNS_LOOKUP_TIMEOUT_MS}ms for ${originalHostname}`)), + DNS_LOOKUP_TIMEOUT_MS, + ); + }); + + try { + const results = await Promise.race([dnsPromise, timeoutPromise]); + if (results.length === 0) { + throw new Error(`DNS resolution returned no results for ${originalHostname}`); + } + + // Filter to only non-private IPs instead of rejecting the entire request + // when some IPs are private. This handles multi-homed hosts that resolve + // to both private and public addresses. + const safeResults = results.filter((entry) => !isPrivateIP(entry.address)); + if (safeResults.length === 0) { + throw new Error( + `All resolved IPs for ${originalHostname} are in private/reserved ranges`, + ); + } + + const resolved = safeResults[0]!; + return { + parsedUrl: parsed, + resolvedAddress: resolved.address, + hostHeader, + tlsServername: parsed.protocol === "https:" && isIP(originalHostname) === 0 + ? originalHostname + : undefined, + useTls: parsed.protocol === "https:", + }; + } catch (err) { + // Re-throw our own errors; wrap DNS failures + if (err instanceof Error && ( + err.message.startsWith("All resolved IPs") || + err.message.startsWith("DNS resolution returned") || + err.message.startsWith("DNS lookup timed out") + )) throw err; + throw new Error(`DNS resolution failed for ${originalHostname}: ${(err as Error).message}`); + } +} + +function buildPinnedRequestOptions( + target: ValidatedFetchTarget, + init?: RequestInit, +): { options: HttpRequestOptions & { servername?: string }; body: string | undefined } { + const headers = new Headers(init?.headers); + const method = init?.method ?? "GET"; + const body = init?.body === undefined || init?.body === null + ? undefined + : typeof init.body === "string" + ? init.body + : String(init.body); + + headers.set("Host", target.hostHeader); + if (body !== undefined && !headers.has("content-length") && !headers.has("transfer-encoding")) { + headers.set("content-length", String(Buffer.byteLength(body))); + } + + const pathname = `${target.parsedUrl.pathname}${target.parsedUrl.search}`; + const auth = target.parsedUrl.username || target.parsedUrl.password + ? `${decodeURIComponent(target.parsedUrl.username)}:${decodeURIComponent(target.parsedUrl.password)}` + : undefined; + + return { + options: { + protocol: target.parsedUrl.protocol, + host: target.resolvedAddress, + port: target.parsedUrl.port + ? Number(target.parsedUrl.port) + : target.useTls + ? 443 + : 80, + path: pathname, + method, + headers: Object.fromEntries(headers.entries()), + auth, + servername: target.tlsServername, + }, + body, + }; +} + +async function executePinnedHttpRequest( + target: ValidatedFetchTarget, + init: RequestInit | undefined, + signal: AbortSignal, +): Promise<{ status: number; statusText: string; headers: Record; body: string }> { + const { options, body } = buildPinnedRequestOptions(target, init); + + const response = await new Promise((resolve, reject) => { + const requestFn = target.useTls ? httpsRequest : httpRequest; + const req = requestFn({ ...options, signal }, resolve); + + req.on("error", reject); + + if (body !== undefined) { + req.write(body); + } + req.end(); + }); + + const MAX_RESPONSE_BODY_BYTES = 200 * 1024 * 1024; // 200 MB + const chunks: Buffer[] = []; + let totalBytes = 0; + await new Promise((resolve, reject) => { + response.on("data", (chunk: Buffer | string) => { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buf.length; + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + chunks.length = 0; + response.destroy(new Error(`Response body exceeded ${MAX_RESPONSE_BODY_BYTES} bytes`)); + return; + } + chunks.push(buf); + }); + response.on("end", resolve); + response.on("error", reject); + }); + + const headers: Record = {}; + for (const [key, value] of Object.entries(response.headers)) { + if (Array.isArray(value)) { + headers[key] = value.join(", "); + } else if (value !== undefined) { + headers[key] = value; + } + } + + return { + status: response.statusCode ?? 500, + statusText: response.statusMessage ?? "", + headers, + body: Buffer.concat(chunks).toString("utf8"), + }; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PATH_LIKE_PATTERN = /[\\/]/; +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +function looksLikePath(value: string): boolean { + const normalized = value.trim(); + return ( + PATH_LIKE_PATTERN.test(normalized) + || WINDOWS_DRIVE_PATH_PATTERN.test(normalized) + ) && !UUID_PATTERN.test(normalized); +} + +function sanitizeWorkspaceText(value: string): string { + const trimmed = value.trim(); + if (!trimmed || UUID_PATTERN.test(trimmed)) return ""; + return trimmed; +} + +function sanitizeWorkspacePath(cwd: string | null): string { + if (!cwd) return ""; + return looksLikePath(cwd) ? cwd.trim() : ""; +} + +function sanitizeWorkspaceName(name: string, fallbackPath: string): string { + const safeName = sanitizeWorkspaceText(name); + if (safeName && !looksLikePath(safeName)) { + return safeName; + } + const normalized = fallbackPath.trim().replace(/[\\/]+$/, ""); + const segments = normalized.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? "Workspace"; +} + +// --------------------------------------------------------------------------- +// Buffered plugin log writes +// --------------------------------------------------------------------------- + +/** How many buffered log entries trigger an immediate flush. */ +const LOG_BUFFER_FLUSH_SIZE = 100; + +/** How often (ms) the buffer is flushed regardless of size. */ +const LOG_BUFFER_FLUSH_INTERVAL_MS = 5_000; + +/** Max length for a single plugin log message (bytes/chars). */ +const MAX_LOG_MESSAGE_LENGTH = 10_000; + +/** Max serialised JSON size for plugin log meta objects. */ +const MAX_LOG_META_JSON_LENGTH = 50_000; + +/** Max length for a metric name. */ +const MAX_METRIC_NAME_LENGTH = 500; + +/** Pino reserved field names that plugins must not overwrite. */ +const PINO_RESERVED_KEYS = new Set([ + "level", + "time", + "pid", + "hostname", + "msg", + "v", +]); + +/** Truncate a string to `max` characters, appending a marker if truncated. */ +function truncStr(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max) + "...[truncated]"; +} + +/** Sanitise a plugin-supplied meta object: enforce size limit and strip reserved keys. */ +function sanitiseMeta(meta: Record | null | undefined): Record | null { + if (meta == null) return null; + // Strip pino reserved keys + const cleaned: Record = {}; + for (const [k, v] of Object.entries(meta)) { + if (!PINO_RESERVED_KEYS.has(k)) { + cleaned[k] = v; + } + } + // Enforce total serialised size + let json: string; + try { + json = JSON.stringify(cleaned); + } catch { + return { _sanitised: true, _error: "meta was not JSON-serialisable" }; + } + if (json.length > MAX_LOG_META_JSON_LENGTH) { + return { _sanitised: true, _error: `meta exceeded ${MAX_LOG_META_JSON_LENGTH} chars` }; + } + return cleaned; +} + +interface BufferedLogEntry { + db: Db; + pluginId: string; + level: string; + message: string; + meta: Record | null; +} + +const _logBuffer: BufferedLogEntry[] = []; + +/** + * Flush all buffered log entries to the database in a single batch insert per + * unique db instance. Errors are swallowed with a console.error fallback so + * flushing never crashes the process. + */ +export async function flushPluginLogBuffer(): Promise { + if (_logBuffer.length === 0) return; + + // Drain the buffer atomically so concurrent flushes don't double-insert. + const entries = _logBuffer.splice(0, _logBuffer.length); + + // Group entries by db identity so multi-db scenarios are handled correctly. + const byDb = new Map(); + for (const entry of entries) { + const group = byDb.get(entry.db); + if (group) { + group.push(entry); + } else { + byDb.set(entry.db, [entry]); + } + } + + for (const [dbInstance, group] of byDb) { + const values = group.map((e) => ({ + pluginId: e.pluginId, + level: e.level, + message: e.message, + meta: e.meta, + })); + try { + await dbInstance.insert(pluginLogs).values(values); + } catch (err) { + try { + logger.warn({ err, count: values.length }, "Failed to batch-persist plugin logs to DB"); + } catch { + console.error("[plugin-host-services] Batch log flush failed:", err); + } + } + } +} + +/** Interval handle for the periodic log flush. */ +const _logFlushInterval = setInterval(() => { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Periodic log flush error:", err); + }); +}, LOG_BUFFER_FLUSH_INTERVAL_MS); + +// Allow the interval to be unref'd so it doesn't keep the process alive in tests. +if (_logFlushInterval.unref) _logFlushInterval.unref(); + +/** + * buildHostServices — creates a concrete implementation of the `HostServices` + * interface for a specific plugin. + * + * This implementation delegates to the core Paperclip domain services, + * providing the bridge between the plugin worker's SDK and the host platform. + * + * @param db - Database connection instance. + * @param pluginId - The UUID of the plugin installation record. + * @param pluginKey - The unique identifier from the plugin manifest (e.g., "acme.linear"). + * @param eventBus - The system-wide event bus for publishing plugin events. + * @returns An object implementing the HostServices interface for the plugin SDK. + */ +/** Maximum time (ms) to keep a session event subscription alive before forcing cleanup. */ +const SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes + +export function buildHostServices( + db: Db, + pluginId: string, + pluginKey: string, + eventBus: PluginEventBus, + notifyWorker?: (method: string, params: unknown) => void, +): HostServices & { dispose(): void } { + const registry = pluginRegistryService(db); + const stateStore = pluginStateStore(db); + const secretsHandler = createPluginSecretsHandler({ db, pluginId }); + const companies = companyService(db); + const agents = agentService(db); + const heartbeat = heartbeatService(db); + const projects = projectService(db); + const issues = issueService(db); + const goals = goalService(db); + const activity = activityService(db); + const costs = costService(db); + const assets = assetService(db); + const scopedBus = eventBus.forPlugin(pluginKey); + + // Track active session event subscriptions for cleanup + const activeSubscriptions = new Set<{ unsubscribe: () => void; timer: ReturnType }>(); + let disposed = false; + + const ensureCompanyId = (companyId?: string) => { + if (!companyId) throw new Error("companyId is required for this operation"); + return companyId; + }; + + /** + * Verify that this plugin is enabled for the given company. + * Throws if the plugin is disabled or unavailable, preventing + * worker-driven access to companies that have not opted in. + */ + const ensurePluginAvailableForCompany = async (companyId: string) => { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + if (!availability || !availability.available) { + throw new Error( + `Plugin "${pluginKey}" is not enabled for company "${companyId}"`, + ); + } + }; + + const inCompany = ( + record: T | null | undefined, + companyId: string, + ): record is T => Boolean(record && record.companyId === companyId); + + const requireInCompany = ( + entityName: string, + record: T | null | undefined, + companyId: string, + ): T => { + if (!inCompany(record, companyId)) { + throw new Error(`${entityName} not found`); + } + return record; + }; + + return { + config: { + async get() { + const configRow = await registry.getConfig(pluginId); + return configRow?.configJson ?? {}; + }, + }, + + state: { + async get(params) { + return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, { + scopeId: params.scopeId, + namespace: params.namespace, + }); + }, + async set(params) { + await stateStore.set(pluginId, { + scopeKind: params.scopeKind as any, + scopeId: params.scopeId, + namespace: params.namespace, + stateKey: params.stateKey, + value: params.value, + }); + }, + async delete(params) { + await stateStore.delete(pluginId, params.scopeKind as any, params.stateKey, { + scopeId: params.scopeId, + namespace: params.namespace, + }); + }, + }, + + entities: { + async upsert(params) { + return registry.upsertEntity(pluginId, params as any) as any; + }, + async list(params) { + return registry.listEntities(pluginId, params as any) as any; + }, + }, + + events: { + async emit(params) { + if (params.companyId) { + await ensurePluginAvailableForCompany(params.companyId); + } + await scopedBus.emit(params.name, params.companyId, params.payload); + }, + }, + + http: { + async fetch(params) { + // SSRF protection: validate protocol whitelist + block private IPs. + // Resolve once, then connect directly to that IP to prevent DNS rebinding. + const target = await validateAndResolveFetchUrl(params.url); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PLUGIN_FETCH_TIMEOUT_MS); + + try { + const init = params.init as RequestInit | undefined; + return await executePinnedHttpRequest(target, init, controller.signal); + } finally { + clearTimeout(timeout); + } + }, + }, + + secrets: { + async resolve(params) { + return secretsHandler.resolve(params); + }, + }, + + assets: { + async upload(params) { + void params; + throw new Error("Plugin asset uploads are not supported in this build."); + }, + async getUrl(params) { + void params; + throw new Error("Plugin asset URLs are not supported in this build."); + }, + }, + + activity: { + async log(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + await logActivity(db, { + companyId, + actorType: "system", + actorId: pluginId, + action: params.message, + entityType: params.entityType ?? "plugin", + entityId: params.entityId ?? pluginId, + details: params.metadata, + }); + }, + }, + + metrics: { + async write(params) { + const safeName = truncStr(String(params.name ?? ""), MAX_METRIC_NAME_LENGTH); + logger.debug({ pluginId, name: safeName, value: params.value, tags: params.tags }, "Plugin metric write"); + + // Persist metrics to plugin_logs via the batch buffer (same path as + // logger.log) so they benefit from batched writes and are flushed + // reliably on shutdown. Using level "metric" makes them queryable + // alongside regular logs via the same API (§26). + _logBuffer.push({ + db, + pluginId, + level: "metric", + message: safeName, + meta: sanitiseMeta({ value: params.value, tags: params.tags ?? null }), + }); + if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Triggered metric flush failed:", err); + }); + } + }, + }, + + logger: { + async log(params) { + const { level, meta } = params; + const safeMessage = truncStr(String(params.message ?? ""), MAX_LOG_MESSAGE_LENGTH); + const safeMeta = sanitiseMeta(meta); + const pluginLogger = logger.child({ service: "plugin-worker", pluginId }); + const logFields = { + ...safeMeta, + pluginLogLevel: level, + pluginTimestamp: new Date().toISOString(), + }; + + if (level === "error") pluginLogger.error(logFields, `[plugin] ${safeMessage}`); + else if (level === "warn") pluginLogger.warn(logFields, `[plugin] ${safeMessage}`); + else if (level === "debug") pluginLogger.debug(logFields, `[plugin] ${safeMessage}`); + else pluginLogger.info(logFields, `[plugin] ${safeMessage}`); + + // Persist to plugin_logs table via the module-level batch buffer (§26.1). + // Fire-and-forget — logging should never block the worker. + _logBuffer.push({ + db, + pluginId, + level: level ?? "info", + message: safeMessage, + meta: safeMeta, + }); + if (_logBuffer.length >= LOG_BUFFER_FLUSH_SIZE) { + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] Triggered log flush failed:", err); + }); + } + }, + }, + + companies: { + async list(_params) { + const allCompanies = (await companies.list()) as Company[]; + if (allCompanies.length === 0) return []; + + // Batch query: fetch all company settings for this plugin in one query + // instead of N+1 individual getCompanyAvailability() calls. + const companyIds = allCompanies.map((c) => c.id); + const disabledCompanyIds = await registry.getDisabledCompanyIds(companyIds, pluginId); + return allCompanies.filter((c) => !disabledCompanyIds.has(c.id)); + }, + async get(params) { + await ensurePluginAvailableForCompany(params.companyId); + return (await companies.getById(params.companyId)) as Company; + }, + }, + + projects: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await projects.list(companyId)) as Project[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + return (inCompany(project, companyId) ? project : null) as Project | null; + }, + async listWorkspaces(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + if (!inCompany(project, companyId)) return []; + const rows = await projects.listWorkspaces(params.projectId); + return rows.map((row) => { + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }); + }, + async getPrimaryWorkspace(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const project = await projects.getById(params.projectId); + if (!inCompany(project, companyId)) return null; + const row = project.primaryWorkspace; + if (!row) return null; + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }, + + async getWorkspaceForIssue(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = await issues.getById(params.issueId); + if (!inCompany(issue, companyId)) return null; + const projectId = (issue as Record).projectId as string | null; + if (!projectId) return null; + const project = await projects.getById(projectId); + if (!inCompany(project, companyId)) return null; + const row = project.primaryWorkspace; + if (!row) return null; + const path = sanitizeWorkspacePath(row.cwd); + const name = sanitizeWorkspaceName(row.name, path); + return { + id: row.id, + projectId: row.projectId, + name, + path, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + }, + }, + + issues: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await issues.list(companyId, params as any)) as Issue[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const issue = await issues.getById(params.issueId); + return (inCompany(issue, companyId) ? issue : null) as Issue | null; + }, + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await issues.create(companyId, params as any)) as Issue; + }, + async update(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + return (await issues.update(params.issueId, params.patch as any)) as Issue; + }, + async listComments(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + if (!inCompany(await issues.getById(params.issueId), companyId)) return []; + return (await issues.listComments(params.issueId)) as IssueComment[]; + }, + async createComment(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Issue", await issues.getById(params.issueId), companyId); + return (await issues.addComment( + params.issueId, + params.body, + {}, + )) as IssueComment; + }, + }, + + agents: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await agents.list(companyId); + return rows.filter((agent) => !params.status || agent.status === params.status) as Agent[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + return (inCompany(agent, companyId) ? agent : null) as Agent | null; + }, + async pause(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + return (await agents.pause(params.agentId)) as Agent; + }, + async resume(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + return (await agents.resume(params.agentId)) as Agent; + }, + async invoke(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + const run = await heartbeat.wakeup(params.agentId, { + source: "automation", + triggerDetail: "system", + reason: params.reason ?? null, + payload: { prompt: params.prompt }, + requestedByActorType: "system", + requestedByActorId: pluginId, + }); + if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); + return { runId: run.id }; + }, + }, + + goals: { + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await goals.list(companyId); + return rows.filter((goal) => + (!params.level || goal.level === params.level) && + (!params.status || goal.status === params.status), + ) as Goal[]; + }, + async get(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const goal = await goals.getById(params.goalId); + return (inCompany(goal, companyId) ? goal : null) as Goal | null; + }, + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return (await goals.create(companyId, { + title: params.title, + description: params.description, + level: params.level as any, + status: params.status as any, + parentId: params.parentId, + ownerAgentId: params.ownerAgentId, + })) as Goal; + }, + async update(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + requireInCompany("Goal", await goals.getById(params.goalId), companyId); + return (await goals.update(params.goalId, params.patch as any)) as Goal; + }, + }, + + agentSessions: { + async create(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const agent = await agents.getById(params.agentId); + requireInCompany("Agent", agent, companyId); + const taskKey = params.taskKey ?? `plugin:${pluginKey}:session:${randomUUID()}`; + + const row = await db + .insert(agentTaskSessionsTable) + .values({ + companyId, + agentId: params.agentId, + adapterType: agent!.adapterType, + taskKey, + sessionParamsJson: null, + sessionDisplayId: null, + lastRunId: null, + lastError: null, + }) + .returning() + .then((rows) => rows[0]); + + return { + sessionId: row!.id, + agentId: params.agentId, + companyId, + status: "active" as const, + createdAt: row!.createdAt.toISOString(), + }; + }, + + async list(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const rows = await db + .select() + .from(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.agentId, params.agentId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .orderBy(desc(agentTaskSessionsTable.createdAt)); + + return rows.map((row) => ({ + sessionId: row.id, + agentId: row.agentId, + companyId: row.companyId, + status: "active" as const, + createdAt: row.createdAt.toISOString(), + })); + }, + + async sendMessage(params) { + if (disposed) { + throw new Error("Host services have been disposed"); + } + + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + + // Verify session exists and belongs to this plugin + const session = await db + .select() + .from(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.id, params.sessionId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .then((rows) => rows[0] ?? null); + if (!session) throw new Error(`Session not found: ${params.sessionId}`); + + const run = await heartbeat.wakeup(session.agentId, { + source: "automation", + triggerDetail: "system", + reason: params.reason ?? null, + payload: { prompt: params.prompt }, + contextSnapshot: { + taskKey: session.taskKey, + wakeSource: "automation", + wakeTriggerDetail: "system", + }, + requestedByActorType: "system", + requestedByActorId: pluginId, + }); + if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); + + // Subscribe to live events and forward to the plugin worker as notifications. + // Track the subscription so it can be cleaned up on dispose() if the run + // never reaches a terminal status (hang, crash, network partition). + if (notifyWorker) { + const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); + + const cleanup = () => { + unsubscribe(); + clearTimeout(timeoutTimer); + activeSubscriptions.delete(entry); + }; + + const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => { + const payload = event.payload as Record | undefined; + if (!payload || payload.runId !== run.id) return; + + if (event.type === "heartbeat.run.log" || event.type === "heartbeat.run.event") { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: (payload.seq as number) ?? 0, + eventType: "chunk", + stream: (payload.stream as string) ?? null, + message: (payload.chunk as string) ?? (payload.message as string) ?? null, + payload: payload, + }); + } else if (event.type === "heartbeat.run.status") { + const status = payload.status as string; + if (TERMINAL_STATUSES.has(status)) { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: 0, + eventType: status === "succeeded" ? "done" : "error", + stream: "system", + message: status === "succeeded" ? "Run completed" : `Run ${status}`, + payload: payload, + }); + cleanup(); + } else { + notifyWorker("agents.sessions.event", { + sessionId: params.sessionId, + runId: run.id, + seq: 0, + eventType: "status", + stream: "system", + message: `Run status: ${status}`, + payload: payload, + }); + } + } + }); + + // Safety-net timeout: if the run never reaches a terminal status, + // force-cleanup the subscription to prevent unbounded leaks. + const timeoutTimer = setTimeout(() => { + logger.warn( + { pluginId, pluginKey, runId: run.id }, + "session event subscription timed out — forcing cleanup", + ); + cleanup(); + }, SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS); + + const entry = { unsubscribe, timer: timeoutTimer }; + activeSubscriptions.add(entry); + } + + return { runId: run.id }; + }, + + async close(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const deleted = await db + .delete(agentTaskSessionsTable) + .where( + and( + eq(agentTaskSessionsTable.id, params.sessionId), + eq(agentTaskSessionsTable.companyId, companyId), + like(agentTaskSessionsTable.taskKey, `plugin:${pluginKey}:session:%`), + ), + ) + .returning() + .then((rows) => rows.length); + if (deleted === 0) throw new Error(`Session not found: ${params.sessionId}`); + }, + }, + + /** + * Clean up all active session event subscriptions and flush any buffered + * log entries. Must be called when the plugin worker is stopped, crashed, + * or unloaded to prevent leaked listeners and lost log entries. + */ + dispose() { + disposed = true; + + // Snapshot to avoid iterator invalidation from concurrent sendMessage() calls + const snapshot = Array.from(activeSubscriptions); + activeSubscriptions.clear(); + + for (const entry of snapshot) { + clearTimeout(entry.timer); + entry.unsubscribe(); + } + + // Flush any buffered log entries synchronously-as-possible on dispose. + flushPluginLogBuffer().catch((err) => { + console.error("[plugin-host-services] dispose() log flush failed:", err); + }); + }, + }; +} diff --git a/server/src/services/plugin-job-coordinator.ts b/server/src/services/plugin-job-coordinator.ts new file mode 100644 index 00000000..bb0df083 --- /dev/null +++ b/server/src/services/plugin-job-coordinator.ts @@ -0,0 +1,260 @@ +/** + * PluginJobCoordinator — bridges the plugin lifecycle manager with the + * job scheduler and job store. + * + * This service listens to lifecycle events and performs the corresponding + * scheduler and job store operations: + * + * - **plugin.loaded** → sync job declarations from manifest, then register + * the plugin with the scheduler (computes `nextRunAt` for active jobs). + * + * - **plugin.disabled / plugin.unloaded** → unregister the plugin from the + * scheduler (cancels in-flight runs, clears tracking state). + * + * ## Why a separate coordinator? + * + * The lifecycle manager, scheduler, and job store are independent services + * with clean single-responsibility boundaries. The coordinator provides + * the "glue" between them without adding coupling. This pattern is used + * throughout Paperclip (e.g. heartbeat service coordinates timers + runs). + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + * @see ./plugin-job-scheduler.ts — Scheduler service + * @see ./plugin-job-store.ts — Persistence layer + * @see ./plugin-lifecycle.ts — Plugin state machine + */ + +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; +import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; +import type { PluginJobStore } from "./plugin-job-store.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import type { Db } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for creating a PluginJobCoordinator. + */ +export interface PluginJobCoordinatorOptions { + /** Drizzle database instance. */ + db: Db; + /** The plugin lifecycle manager to listen to. */ + lifecycle: PluginLifecycleManager; + /** The job scheduler to register/unregister plugins with. */ + scheduler: PluginJobScheduler; + /** The job store for syncing declarations. */ + jobStore: PluginJobStore; +} + +/** + * The public interface of the job coordinator. + */ +export interface PluginJobCoordinator { + /** + * Start listening to lifecycle events. + * + * This wires up the `plugin.loaded`, `plugin.disabled`, and + * `plugin.unloaded` event handlers. + */ + start(): void; + + /** + * Stop listening to lifecycle events. + * + * Removes all event subscriptions added by `start()`. + */ + stop(): void; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Create a PluginJobCoordinator. + * + * @example + * ```ts + * const coordinator = createPluginJobCoordinator({ + * db, + * lifecycle, + * scheduler, + * jobStore, + * }); + * + * // Start listening to lifecycle events + * coordinator.start(); + * + * // On server shutdown + * coordinator.stop(); + * ``` + */ +export function createPluginJobCoordinator( + options: PluginJobCoordinatorOptions, +): PluginJobCoordinator { + const { db, lifecycle, scheduler, jobStore } = options; + const log = logger.child({ service: "plugin-job-coordinator" }); + const registry = pluginRegistryService(db); + + // ----------------------------------------------------------------------- + // Event handlers + // ----------------------------------------------------------------------- + + /** + * When a plugin is loaded (transitions to `ready`): + * 1. Look up the manifest from the registry + * 2. Sync job declarations from the manifest into the DB + * 3. Register the plugin with the scheduler (computes nextRunAt) + */ + async function onPluginLoaded(payload: { pluginId: string; pluginKey: string }): Promise { + const { pluginId, pluginKey } = payload; + log.info({ pluginId, pluginKey }, "plugin loaded — syncing jobs and registering with scheduler"); + + try { + // Get the manifest from the registry + const plugin = await registry.getById(pluginId); + if (!plugin?.manifestJson) { + log.warn({ pluginId, pluginKey }, "plugin loaded but no manifest found — skipping job sync"); + return; + } + + // Sync job declarations from the manifest + const manifest = plugin.manifestJson; + const jobDeclarations = manifest.jobs ?? []; + + if (jobDeclarations.length > 0) { + log.info( + { pluginId, pluginKey, jobCount: jobDeclarations.length }, + "syncing job declarations from manifest", + ); + await jobStore.syncJobDeclarations(pluginId, jobDeclarations); + } + + // Register with the scheduler (computes nextRunAt for active jobs) + await scheduler.registerPlugin(pluginId); + } catch (err) { + log.error( + { + pluginId, + pluginKey, + err: err instanceof Error ? err.message : String(err), + }, + "failed to sync jobs or register plugin with scheduler", + ); + } + } + + /** + * When a plugin is disabled (transitions to `error` with "disabled by + * operator" or genuine error): unregister from the scheduler. + */ + async function onPluginDisabled(payload: { + pluginId: string; + pluginKey: string; + reason?: string; + }): Promise { + const { pluginId, pluginKey, reason } = payload; + log.info( + { pluginId, pluginKey, reason }, + "plugin disabled — unregistering from scheduler", + ); + + try { + await scheduler.unregisterPlugin(pluginId); + } catch (err) { + log.error( + { + pluginId, + pluginKey, + err: err instanceof Error ? err.message : String(err), + }, + "failed to unregister plugin from scheduler", + ); + } + } + + /** + * When a plugin is unloaded (uninstalled): unregister from the scheduler. + */ + async function onPluginUnloaded(payload: { + pluginId: string; + pluginKey: string; + removeData: boolean; + }): Promise { + const { pluginId, pluginKey, removeData } = payload; + log.info( + { pluginId, pluginKey, removeData }, + "plugin unloaded — unregistering from scheduler", + ); + + try { + await scheduler.unregisterPlugin(pluginId); + + // If data is being purged, also delete all job definitions and runs + if (removeData) { + log.info({ pluginId, pluginKey }, "purging job data for uninstalled plugin"); + await jobStore.deleteAllJobs(pluginId); + } + } catch (err) { + log.error( + { + pluginId, + pluginKey, + err: err instanceof Error ? err.message : String(err), + }, + "failed to unregister plugin from scheduler during unload", + ); + } + } + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + let attached = false; + + // We need stable references for on/off since the lifecycle manager + // uses them for matching. We wrap the async handlers in sync wrappers + // that fire-and-forget (swallowing unhandled rejections via the try/catch + // inside each handler). + const boundOnLoaded = (payload: { pluginId: string; pluginKey: string }) => { + void onPluginLoaded(payload); + }; + const boundOnDisabled = (payload: { pluginId: string; pluginKey: string; reason?: string }) => { + void onPluginDisabled(payload); + }; + const boundOnUnloaded = (payload: { pluginId: string; pluginKey: string; removeData: boolean }) => { + void onPluginUnloaded(payload); + }; + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + start(): void { + if (attached) return; + attached = true; + + lifecycle.on("plugin.loaded", boundOnLoaded); + lifecycle.on("plugin.disabled", boundOnDisabled); + lifecycle.on("plugin.unloaded", boundOnUnloaded); + + log.info("plugin job coordinator started — listening to lifecycle events"); + }, + + stop(): void { + if (!attached) return; + attached = false; + + lifecycle.off("plugin.loaded", boundOnLoaded); + lifecycle.off("plugin.disabled", boundOnDisabled); + lifecycle.off("plugin.unloaded", boundOnUnloaded); + + log.info("plugin job coordinator stopped"); + }, + }; +} diff --git a/server/src/services/plugin-job-scheduler.ts b/server/src/services/plugin-job-scheduler.ts new file mode 100644 index 00000000..09a6b878 --- /dev/null +++ b/server/src/services/plugin-job-scheduler.ts @@ -0,0 +1,752 @@ +/** + * PluginJobScheduler — tick-based scheduler for plugin scheduled jobs. + * + * The scheduler is the central coordinator for all plugin cron jobs. It + * periodically ticks (default every 30 seconds), queries the `plugin_jobs` + * table for jobs whose `nextRunAt` has passed, dispatches `runJob` RPC calls + * to the appropriate worker processes, records each execution in the + * `plugin_job_runs` table, and advances the scheduling pointer. + * + * ## Responsibilities + * + * 1. **Tick loop** — A `setInterval`-based loop fires every `tickIntervalMs` + * (default 30s). Each tick scans for due jobs and dispatches them. + * + * 2. **Cron parsing & next-run calculation** — Uses the lightweight built-in + * cron parser ({@link parseCron}, {@link nextCronTick}) to compute the + * `nextRunAt` timestamp after each run or when a new job is registered. + * + * 3. **Overlap prevention** — Before dispatching a job, the scheduler checks + * for an existing `running` run for the same job. If one exists, the job + * is skipped for that tick. + * + * 4. **Job run recording** — Every execution creates a `plugin_job_runs` row: + * `queued` → `running` → `succeeded` | `failed`. Duration and error are + * captured. + * + * 5. **Lifecycle integration** — The scheduler exposes `registerPlugin()` and + * `unregisterPlugin()` so the host lifecycle manager can wire up job + * scheduling when plugins start/stop. On registration, the scheduler + * computes `nextRunAt` for all active jobs that don't already have one. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + * @see ./plugin-job-store.ts — Persistence layer + * @see ./cron.ts — Cron parsing utilities + */ + +import { and, eq, lte, or } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { pluginJobs, pluginJobRuns } from "@paperclipai/db"; +import type { PluginJobStore } from "./plugin-job-store.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { parseCron, nextCronTick, validateCron } from "./cron.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default interval between scheduler ticks (30 seconds). */ +const DEFAULT_TICK_INTERVAL_MS = 30_000; + +/** Default timeout for a runJob RPC call (5 minutes). */ +const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1_000; + +/** Maximum number of concurrent job executions across all plugins. */ +const DEFAULT_MAX_CONCURRENT_JOBS = 10; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Options for creating a PluginJobScheduler. + */ +export interface PluginJobSchedulerOptions { + /** Drizzle database instance. */ + db: Db; + /** Persistence layer for jobs and runs. */ + jobStore: PluginJobStore; + /** Worker process manager for RPC calls. */ + workerManager: PluginWorkerManager; + /** Interval between scheduler ticks in ms (default: 30s). */ + tickIntervalMs?: number; + /** Timeout for individual job RPC calls in ms (default: 5min). */ + jobTimeoutMs?: number; + /** Maximum number of concurrent job executions (default: 10). */ + maxConcurrentJobs?: number; +} + +/** + * Result of a manual job trigger. + */ +export interface TriggerJobResult { + /** The created run ID. */ + runId: string; + /** The job ID that was triggered. */ + jobId: string; +} + +/** + * Diagnostic information about the scheduler. + */ +export interface SchedulerDiagnostics { + /** Whether the tick loop is running. */ + running: boolean; + /** Number of jobs currently executing. */ + activeJobCount: number; + /** Set of job IDs currently in-flight. */ + activeJobIds: string[]; + /** Total number of ticks executed since start. */ + tickCount: number; + /** Timestamp of the last tick (ISO 8601). */ + lastTickAt: string | null; +} + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- + +/** + * The public interface of the job scheduler. + */ +export interface PluginJobScheduler { + /** + * Start the scheduler tick loop. + * + * Safe to call multiple times — subsequent calls are no-ops. + */ + start(): void; + + /** + * Stop the scheduler tick loop. + * + * In-flight job runs are NOT cancelled — they are allowed to finish + * naturally. The tick loop simply stops firing. + */ + stop(): void; + + /** + * Register a plugin with the scheduler. + * + * Computes `nextRunAt` for all active jobs that are missing it. This is + * typically called after a plugin's worker process starts and + * `syncJobDeclarations()` has been called. + * + * @param pluginId - UUID of the plugin + */ + registerPlugin(pluginId: string): Promise; + + /** + * Unregister a plugin from the scheduler. + * + * Cancels any in-flight runs for the plugin and removes tracking state. + * + * @param pluginId - UUID of the plugin + */ + unregisterPlugin(pluginId: string): Promise; + + /** + * Manually trigger a specific job (outside of the cron schedule). + * + * Creates a run with `trigger: "manual"` and dispatches immediately, + * respecting the overlap prevention check. + * + * @param jobId - UUID of the job to trigger + * @param trigger - What triggered this run (default: "manual") + * @returns The created run info + * @throws {Error} if the job is not found, not active, or already running + */ + triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise; + + /** + * Run a single scheduler tick immediately (for testing). + * + * @internal + */ + tick(): Promise; + + /** + * Get diagnostic information about the scheduler state. + */ + diagnostics(): SchedulerDiagnostics; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Create a new PluginJobScheduler. + * + * @example + * ```ts + * const scheduler = createPluginJobScheduler({ + * db, + * jobStore, + * workerManager, + * }); + * + * // Start the tick loop + * scheduler.start(); + * + * // When a plugin comes online, register it + * await scheduler.registerPlugin(pluginId); + * + * // Manually trigger a job + * const { runId } = await scheduler.triggerJob(jobId); + * + * // On server shutdown + * scheduler.stop(); + * ``` + */ +export function createPluginJobScheduler( + options: PluginJobSchedulerOptions, +): PluginJobScheduler { + const { + db, + jobStore, + workerManager, + tickIntervalMs = DEFAULT_TICK_INTERVAL_MS, + jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS, + maxConcurrentJobs = DEFAULT_MAX_CONCURRENT_JOBS, + } = options; + + const log = logger.child({ service: "plugin-job-scheduler" }); + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + + /** Timer handle for the tick loop. */ + let tickTimer: ReturnType | null = null; + + /** Whether the scheduler is running. */ + let running = false; + + /** Set of job IDs currently being executed (for overlap prevention). */ + const activeJobs = new Set(); + + /** Total number of ticks since start. */ + let tickCount = 0; + + /** Timestamp of the last tick. */ + let lastTickAt: Date | null = null; + + /** Guard against concurrent tick execution. */ + let tickInProgress = false; + + // ----------------------------------------------------------------------- + // Core: tick + // ----------------------------------------------------------------------- + + /** + * A single scheduler tick. Queries for due jobs and dispatches them. + */ + async function tick(): Promise { + // Prevent overlapping ticks (in case a tick takes longer than the interval) + if (tickInProgress) { + log.debug("skipping tick — previous tick still in progress"); + return; + } + + tickInProgress = true; + tickCount++; + lastTickAt = new Date(); + + try { + const now = new Date(); + + // Query for jobs whose nextRunAt has passed and are active. + // We include jobs with null nextRunAt since they may have just been + // registered and need their first run calculated. + const dueJobs = await db + .select() + .from(pluginJobs) + .where( + and( + eq(pluginJobs.status, "active"), + lte(pluginJobs.nextRunAt, now), + ), + ); + + if (dueJobs.length === 0) { + return; + } + + log.debug({ count: dueJobs.length }, "found due jobs"); + + // Dispatch each due job (respecting concurrency limits) + const dispatches: Promise[] = []; + + for (const job of dueJobs) { + // Concurrency limit + if (activeJobs.size >= maxConcurrentJobs) { + log.warn( + { maxConcurrentJobs, activeJobCount: activeJobs.size }, + "max concurrent jobs reached, deferring remaining jobs", + ); + break; + } + + // Overlap prevention: skip if this job is already running + if (activeJobs.has(job.id)) { + log.debug( + { jobId: job.id, jobKey: job.jobKey, pluginId: job.pluginId }, + "skipping job — already running (overlap prevention)", + ); + continue; + } + + // Check if the worker is available + if (!workerManager.isRunning(job.pluginId)) { + log.debug( + { jobId: job.id, pluginId: job.pluginId }, + "skipping job — worker not running", + ); + continue; + } + + // Validate cron expression before dispatching + if (!job.schedule) { + log.warn( + { jobId: job.id, jobKey: job.jobKey }, + "skipping job — no schedule defined", + ); + continue; + } + + dispatches.push(dispatchJob(job)); + } + + if (dispatches.length > 0) { + await Promise.allSettled(dispatches); + } + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + "scheduler tick error", + ); + } finally { + tickInProgress = false; + } + } + + // ----------------------------------------------------------------------- + // Core: dispatch a single job + // ----------------------------------------------------------------------- + + /** + * Dispatch a single job run — create the run record, call the worker, + * record the result, and advance the schedule pointer. + */ + async function dispatchJob( + job: typeof pluginJobs.$inferSelect, + ): Promise { + const { id: jobId, pluginId, jobKey, schedule } = job; + const jobLog = log.child({ jobId, pluginId, jobKey }); + + // Mark as active (overlap prevention) + activeJobs.add(jobId); + + let runId: string | undefined; + const startedAt = Date.now(); + + try { + // 1. Create run record + const run = await jobStore.createRun({ + jobId, + pluginId, + trigger: "schedule", + }); + runId = run.id; + + jobLog.info({ runId }, "dispatching scheduled job"); + + // 2. Mark run as running + await jobStore.markRunning(runId); + + // 3. Call worker via RPC + await workerManager.call( + pluginId, + "runJob", + { + job: { + jobKey, + runId, + trigger: "schedule" as const, + scheduledAt: (job.nextRunAt ?? new Date()).toISOString(), + }, + }, + jobTimeoutMs, + ); + + // 4. Mark run as succeeded + const durationMs = Date.now() - startedAt; + await jobStore.completeRun(runId, { + status: "succeeded", + durationMs, + }); + + jobLog.info({ runId, durationMs }, "job completed successfully"); + } catch (err) { + const durationMs = Date.now() - startedAt; + const errorMessage = err instanceof Error ? err.message : String(err); + + jobLog.error( + { runId, durationMs, err: errorMessage }, + "job execution failed", + ); + + // Record the failure + if (runId) { + try { + await jobStore.completeRun(runId, { + status: "failed", + error: errorMessage, + durationMs, + }); + } catch (completeErr) { + jobLog.error( + { + runId, + err: completeErr instanceof Error ? completeErr.message : String(completeErr), + }, + "failed to record job failure", + ); + } + } + } finally { + // Remove from active set + activeJobs.delete(jobId); + + // 5. Always advance the schedule pointer (even on failure) + try { + await advanceSchedulePointer(job); + } catch (err) { + jobLog.error( + { err: err instanceof Error ? err.message : String(err) }, + "failed to advance schedule pointer", + ); + } + } + } + + // ----------------------------------------------------------------------- + // Core: manual trigger + // ----------------------------------------------------------------------- + + async function triggerJob( + jobId: string, + trigger: "manual" | "retry" = "manual", + ): Promise { + const job = await jobStore.getJobById(jobId); + if (!job) { + throw new Error(`Job not found: ${jobId}`); + } + + if (job.status !== "active") { + throw new Error( + `Job "${job.jobKey}" is not active (status: ${job.status})`, + ); + } + + // Overlap prevention + if (activeJobs.has(jobId)) { + throw new Error( + `Job "${job.jobKey}" is already running — cannot trigger while in progress`, + ); + } + + // Also check DB for running runs (defensive — covers multi-instance) + const existingRuns = await db + .select() + .from(pluginJobRuns) + .where( + and( + eq(pluginJobRuns.jobId, jobId), + eq(pluginJobRuns.status, "running"), + ), + ); + + if (existingRuns.length > 0) { + throw new Error( + `Job "${job.jobKey}" already has a running execution — cannot trigger while in progress`, + ); + } + + // Check worker availability + if (!workerManager.isRunning(job.pluginId)) { + throw new Error( + `Worker for plugin "${job.pluginId}" is not running — cannot trigger job`, + ); + } + + // Create the run and dispatch (non-blocking) + const run = await jobStore.createRun({ + jobId, + pluginId: job.pluginId, + trigger, + }); + + // Dispatch in background — don't block the caller + void dispatchManualRun(job, run.id, trigger); + + return { runId: run.id, jobId }; + } + + /** + * Dispatch a manually triggered job run. + */ + async function dispatchManualRun( + job: typeof pluginJobs.$inferSelect, + runId: string, + trigger: "manual" | "retry", + ): Promise { + const { id: jobId, pluginId, jobKey } = job; + const jobLog = log.child({ jobId, pluginId, jobKey, runId, trigger }); + + activeJobs.add(jobId); + const startedAt = Date.now(); + + try { + await jobStore.markRunning(runId); + + await workerManager.call( + pluginId, + "runJob", + { + job: { + jobKey, + runId, + trigger, + scheduledAt: new Date().toISOString(), + }, + }, + jobTimeoutMs, + ); + + const durationMs = Date.now() - startedAt; + await jobStore.completeRun(runId, { + status: "succeeded", + durationMs, + }); + + jobLog.info({ durationMs }, "manual job completed successfully"); + } catch (err) { + const durationMs = Date.now() - startedAt; + const errorMessage = err instanceof Error ? err.message : String(err); + jobLog.error({ durationMs, err: errorMessage }, "manual job failed"); + + try { + await jobStore.completeRun(runId, { + status: "failed", + error: errorMessage, + durationMs, + }); + } catch (completeErr) { + jobLog.error( + { + err: completeErr instanceof Error ? completeErr.message : String(completeErr), + }, + "failed to record manual job failure", + ); + } + } finally { + activeJobs.delete(jobId); + } + } + + // ----------------------------------------------------------------------- + // Schedule pointer management + // ----------------------------------------------------------------------- + + /** + * Advance the `lastRunAt` and `nextRunAt` timestamps on a job after a run. + */ + async function advanceSchedulePointer( + job: typeof pluginJobs.$inferSelect, + ): Promise { + const now = new Date(); + let nextRunAt: Date | null = null; + + if (job.schedule) { + const validationError = validateCron(job.schedule); + if (validationError) { + log.warn( + { jobId: job.id, schedule: job.schedule, error: validationError }, + "invalid cron schedule — cannot compute next run", + ); + } else { + const cron = parseCron(job.schedule); + nextRunAt = nextCronTick(cron, now); + } + } + + await jobStore.updateRunTimestamps(job.id, now, nextRunAt); + } + + /** + * Ensure all active jobs for a plugin have a `nextRunAt` value. + * Called when a plugin is registered with the scheduler. + */ + async function ensureNextRunTimestamps(pluginId: string): Promise { + const jobs = await jobStore.listJobs(pluginId, "active"); + + for (const job of jobs) { + // Skip jobs that already have a valid nextRunAt in the future + if (job.nextRunAt && job.nextRunAt.getTime() > Date.now()) { + continue; + } + + // Skip jobs without a schedule + if (!job.schedule) { + continue; + } + + const validationError = validateCron(job.schedule); + if (validationError) { + log.warn( + { jobId: job.id, jobKey: job.jobKey, schedule: job.schedule, error: validationError }, + "skipping job with invalid cron schedule", + ); + continue; + } + + const cron = parseCron(job.schedule); + const nextRunAt = nextCronTick(cron, new Date()); + + if (nextRunAt) { + await jobStore.updateRunTimestamps( + job.id, + job.lastRunAt ?? new Date(0), + nextRunAt, + ); + log.debug( + { jobId: job.id, jobKey: job.jobKey, nextRunAt: nextRunAt.toISOString() }, + "computed nextRunAt for job", + ); + } + } + } + + // ----------------------------------------------------------------------- + // Plugin registration + // ----------------------------------------------------------------------- + + async function registerPlugin(pluginId: string): Promise { + log.info({ pluginId }, "registering plugin with job scheduler"); + await ensureNextRunTimestamps(pluginId); + } + + async function unregisterPlugin(pluginId: string): Promise { + log.info({ pluginId }, "unregistering plugin from job scheduler"); + + // Cancel any in-flight run records for this plugin that are still + // queued or running. Active jobs in-memory will finish naturally. + try { + const runningRuns = await db + .select() + .from(pluginJobRuns) + .where( + and( + eq(pluginJobRuns.pluginId, pluginId), + or( + eq(pluginJobRuns.status, "running"), + eq(pluginJobRuns.status, "queued"), + ), + ), + ); + + for (const run of runningRuns) { + await jobStore.completeRun(run.id, { + status: "cancelled", + error: "Plugin unregistered", + durationMs: run.startedAt + ? Date.now() - run.startedAt.getTime() + : null, + }); + } + } catch (err) { + log.error( + { + pluginId, + err: err instanceof Error ? err.message : String(err), + }, + "error cancelling in-flight runs during unregister", + ); + } + + // Remove any active tracking for jobs owned by this plugin + const jobs = await jobStore.listJobs(pluginId); + for (const job of jobs) { + activeJobs.delete(job.id); + } + } + + // ----------------------------------------------------------------------- + // Lifecycle: start / stop + // ----------------------------------------------------------------------- + + function start(): void { + if (running) { + log.debug("scheduler already running"); + return; + } + + running = true; + tickTimer = setInterval(() => { + void tick(); + }, tickIntervalMs); + + log.info( + { tickIntervalMs, maxConcurrentJobs }, + "plugin job scheduler started", + ); + } + + function stop(): void { + // Always clear the timer defensively, even if `running` is already false, + // to prevent leaked interval timers. + if (tickTimer !== null) { + clearInterval(tickTimer); + tickTimer = null; + } + + if (!running) return; + running = false; + + log.info( + { activeJobCount: activeJobs.size }, + "plugin job scheduler stopped", + ); + } + + // ----------------------------------------------------------------------- + // Diagnostics + // ----------------------------------------------------------------------- + + function diagnostics(): SchedulerDiagnostics { + return { + running, + activeJobCount: activeJobs.size, + activeJobIds: [...activeJobs], + tickCount, + lastTickAt: lastTickAt?.toISOString() ?? null, + }; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + start, + stop, + registerPlugin, + unregisterPlugin, + triggerJob, + tick, + diagnostics, + }; +} diff --git a/server/src/services/plugin-job-store.ts b/server/src/services/plugin-job-store.ts new file mode 100644 index 00000000..f5fdbce1 --- /dev/null +++ b/server/src/services/plugin-job-store.ts @@ -0,0 +1,465 @@ +/** + * Plugin Job Store — persistence layer for scheduled plugin jobs and their + * execution history. + * + * This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is + * the server-side backing store for the `ctx.jobs` SDK surface exposed to + * plugin workers. + * + * ## Responsibilities + * + * 1. **Sync job declarations** — When a plugin is installed or started, the + * host calls `syncJobDeclarations()` to upsert the manifest's declared jobs + * into the `plugin_jobs` table. Jobs removed from the manifest are marked + * `paused` (not deleted) to preserve history. + * + * 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin. + * + * 3. **Run lifecycle** — Create job run records, update their status, and + * record results (duration, errors, logs). + * + * 4. **Next-run calculation** — After a run completes the host should call + * `updateNextRunAt()` with the next cron tick so the scheduler knows when + * to fire next. + * + * The capability check (`jobs.schedule`) is enforced upstream by the host + * client factory and manifest validator — this store trusts that the caller + * has already been authorised. + * + * @see PLUGIN_SPEC.md §17 — Scheduled Jobs + * @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables + */ + +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db"; +import type { + PluginJobDeclaration, + PluginJobRunStatus, + PluginJobRunTrigger, + PluginJobRecord, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; + +/** + * The statuses used for job *definitions* in the `plugin_jobs` table. + * Aliased from `PluginJobRecord` to keep the store API aligned with + * the domain type (`"active" | "paused" | "failed"`). + */ +type JobDefinitionStatus = PluginJobRecord["status"]; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Input for creating a job run record. + */ +export interface CreateJobRunInput { + /** FK to the plugin_jobs row. */ + jobId: string; + /** FK to the plugins row. */ + pluginId: string; + /** What triggered this run. */ + trigger: PluginJobRunTrigger; +} + +/** + * Input for completing (or failing) a job run. + */ +export interface CompleteJobRunInput { + /** Final run status. */ + status: PluginJobRunStatus; + /** Error message if the run failed. */ + error?: string | null; + /** Run duration in milliseconds. */ + durationMs?: number | null; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * Create a PluginJobStore backed by the given Drizzle database instance. + * + * @example + * ```ts + * const jobStore = pluginJobStore(db); + * + * // On plugin install/start — sync declared jobs into the DB + * await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []); + * + * // Before dispatching a runJob RPC — create a run record + * const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" }); + * + * // After the RPC completes — record the result + * await jobStore.completeRun(run.id, { + * status: "succeeded", + * durationMs: Date.now() - startedAt, + * }); + * ``` + */ +export function pluginJobStore(db: Db) { + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function assertPluginExists(pluginId: string): Promise { + const rows = await db + .select({ id: plugins.id }) + .from(plugins) + .where(eq(plugins.id, pluginId)); + if (rows.length === 0) { + throw notFound(`Plugin not found: ${pluginId}`); + } + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + // ===================================================================== + // Job declarations (plugin_jobs) + // ===================================================================== + + /** + * Sync declared jobs from a plugin manifest into the `plugin_jobs` table. + * + * This is called at plugin install and on each worker startup so the DB + * always reflects the manifest's declared jobs: + * + * - **New jobs** are inserted with status `active`. + * - **Existing jobs** have their `schedule` updated if it changed. + * - **Removed jobs** (present in DB but absent from the manifest) are + * set to `paused` so their history is preserved. + * + * The unique constraint `(pluginId, jobKey)` is used for conflict + * resolution. + * + * @param pluginId - UUID of the owning plugin + * @param declarations - Job declarations from the plugin manifest + */ + async syncJobDeclarations( + pluginId: string, + declarations: PluginJobDeclaration[], + ): Promise { + await assertPluginExists(pluginId); + + // Fetch existing jobs for this plugin + const existingJobs = await db + .select() + .from(pluginJobs) + .where(eq(pluginJobs.pluginId, pluginId)); + + const existingByKey = new Map( + existingJobs.map((j) => [j.jobKey, j]), + ); + + const declaredKeys = new Set(); + + // Upsert each declared job + for (const decl of declarations) { + declaredKeys.add(decl.jobKey); + + const existing = existingByKey.get(decl.jobKey); + const schedule = decl.schedule ?? ""; + + if (existing) { + // Update schedule if it changed; re-activate if it was paused + const updates: Record = { + updatedAt: new Date(), + }; + if (existing.schedule !== schedule) { + updates.schedule = schedule; + } + if (existing.status === "paused") { + updates.status = "active"; + } + + await db + .update(pluginJobs) + .set(updates) + .where(eq(pluginJobs.id, existing.id)); + } else { + // Insert new job + await db.insert(pluginJobs).values({ + pluginId, + jobKey: decl.jobKey, + schedule, + status: "active", + }); + } + } + + // Pause jobs that are no longer declared in the manifest + for (const existing of existingJobs) { + if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") { + await db + .update(pluginJobs) + .set({ status: "paused", updatedAt: new Date() }) + .where(eq(pluginJobs.id, existing.id)); + } + } + }, + + /** + * List all jobs for a plugin, optionally filtered by status. + * + * @param pluginId - UUID of the owning plugin + * @param status - Optional status filter + */ + async listJobs( + pluginId: string, + status?: JobDefinitionStatus, + ): Promise<(typeof pluginJobs.$inferSelect)[]> { + const conditions = [eq(pluginJobs.pluginId, pluginId)]; + if (status) { + conditions.push(eq(pluginJobs.status, status)); + } + return db + .select() + .from(pluginJobs) + .where(and(...conditions)); + }, + + /** + * Get a single job by its composite key `(pluginId, jobKey)`. + * + * @param pluginId - UUID of the owning plugin + * @param jobKey - Stable job identifier from the manifest + * @returns The job row, or `null` if not found + */ + async getJobByKey( + pluginId: string, + jobKey: string, + ): Promise<(typeof pluginJobs.$inferSelect) | null> { + const rows = await db + .select() + .from(pluginJobs) + .where( + and( + eq(pluginJobs.pluginId, pluginId), + eq(pluginJobs.jobKey, jobKey), + ), + ); + return rows[0] ?? null; + }, + + /** + * Get a single job by its primary key (UUID). + * + * @param jobId - UUID of the job row + * @returns The job row, or `null` if not found + */ + async getJobById( + jobId: string, + ): Promise<(typeof pluginJobs.$inferSelect) | null> { + const rows = await db + .select() + .from(pluginJobs) + .where(eq(pluginJobs.id, jobId)); + return rows[0] ?? null; + }, + + /** + * Fetch a single job by ID, scoped to a specific plugin. + * + * Returns `null` if the job does not exist or does not belong to the + * given plugin — callers should treat both cases as "not found". + */ + async getJobByIdForPlugin( + pluginId: string, + jobId: string, + ): Promise<(typeof pluginJobs.$inferSelect) | null> { + const rows = await db + .select() + .from(pluginJobs) + .where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId))); + return rows[0] ?? null; + }, + + /** + * Update a job's status. + * + * @param jobId - UUID of the job row + * @param status - New status + */ + async updateJobStatus( + jobId: string, + status: JobDefinitionStatus, + ): Promise { + await db + .update(pluginJobs) + .set({ status, updatedAt: new Date() }) + .where(eq(pluginJobs.id, jobId)); + }, + + /** + * Update the `lastRunAt` and `nextRunAt` timestamps on a job. + * + * Called by the scheduler after a run completes to advance the + * scheduling pointer. + * + * @param jobId - UUID of the job row + * @param lastRunAt - When the last run started + * @param nextRunAt - When the next run should fire + */ + async updateRunTimestamps( + jobId: string, + lastRunAt: Date, + nextRunAt: Date | null, + ): Promise { + await db + .update(pluginJobs) + .set({ + lastRunAt, + nextRunAt, + updatedAt: new Date(), + }) + .where(eq(pluginJobs.id, jobId)); + }, + + /** + * Delete all jobs (and cascaded runs) owned by a plugin. + * + * Called during plugin uninstall when `removeData = true`. + * + * @param pluginId - UUID of the owning plugin + */ + async deleteAllJobs(pluginId: string): Promise { + await db + .delete(pluginJobs) + .where(eq(pluginJobs.pluginId, pluginId)); + }, + + // ===================================================================== + // Job runs (plugin_job_runs) + // ===================================================================== + + /** + * Create a new job run record with status `queued`. + * + * The caller should create the run record *before* dispatching the + * `runJob` RPC to the worker, then update it to `running` once the + * worker begins execution. + * + * @param input - Job run input (jobId, pluginId, trigger) + * @returns The newly created run row + */ + async createRun( + input: CreateJobRunInput, + ): Promise { + const rows = await db + .insert(pluginJobRuns) + .values({ + jobId: input.jobId, + pluginId: input.pluginId, + trigger: input.trigger, + status: "queued", + }) + .returning(); + + return rows[0]!; + }, + + /** + * Mark a run as `running` and set its `startedAt` timestamp. + * + * @param runId - UUID of the run row + */ + async markRunning(runId: string): Promise { + await db + .update(pluginJobRuns) + .set({ + status: "running" as PluginJobRunStatus, + startedAt: new Date(), + }) + .where(eq(pluginJobRuns.id, runId)); + }, + + /** + * Complete a run — set its final status, error, duration, and + * `finishedAt` timestamp. + * + * @param runId - UUID of the run row + * @param input - Completion details + */ + async completeRun( + runId: string, + input: CompleteJobRunInput, + ): Promise { + await db + .update(pluginJobRuns) + .set({ + status: input.status, + error: input.error ?? null, + durationMs: input.durationMs ?? null, + finishedAt: new Date(), + }) + .where(eq(pluginJobRuns.id, runId)); + }, + + /** + * Get a run by its primary key. + * + * @param runId - UUID of the run row + * @returns The run row, or `null` if not found + */ + async getRunById( + runId: string, + ): Promise<(typeof pluginJobRuns.$inferSelect) | null> { + const rows = await db + .select() + .from(pluginJobRuns) + .where(eq(pluginJobRuns.id, runId)); + return rows[0] ?? null; + }, + + /** + * List runs for a specific job, ordered by creation time descending. + * + * @param jobId - UUID of the job + * @param limit - Maximum number of rows to return (default: 50) + */ + async listRunsByJob( + jobId: string, + limit = 50, + ): Promise<(typeof pluginJobRuns.$inferSelect)[]> { + return db + .select() + .from(pluginJobRuns) + .where(eq(pluginJobRuns.jobId, jobId)) + .orderBy(desc(pluginJobRuns.createdAt)) + .limit(limit); + }, + + /** + * List runs for a plugin, optionally filtered by status. + * + * @param pluginId - UUID of the owning plugin + * @param status - Optional status filter + * @param limit - Maximum number of rows to return (default: 50) + */ + async listRunsByPlugin( + pluginId: string, + status?: PluginJobRunStatus, + limit = 50, + ): Promise<(typeof pluginJobRuns.$inferSelect)[]> { + const conditions = [eq(pluginJobRuns.pluginId, pluginId)]; + if (status) { + conditions.push(eq(pluginJobRuns.status, status)); + } + return db + .select() + .from(pluginJobRuns) + .where(and(...conditions)) + .orderBy(desc(pluginJobRuns.createdAt)) + .limit(limit); + }, + }; +} + +/** Type alias for the return value of `pluginJobStore()`. */ +export type PluginJobStore = ReturnType; diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts new file mode 100644 index 00000000..d3e93677 --- /dev/null +++ b/server/src/services/plugin-lifecycle.ts @@ -0,0 +1,807 @@ +/** + * PluginLifecycleManager — state-machine controller for plugin status + * transitions and worker process coordination. + * + * Each plugin moves through a well-defined state machine: + * + * ``` + * installed ──→ ready ──→ disabled + * │ │ │ + * │ ├──→ error│ + * │ ↓ │ + * │ upgrade_pending │ + * │ │ │ + * ↓ ↓ ↓ + * uninstalled + * ``` + * + * The lifecycle manager: + * + * 1. **Validates transitions** — Only transitions defined in + * `VALID_TRANSITIONS` are allowed; invalid transitions throw. + * + * 2. **Coordinates workers** — When a plugin moves to `ready`, its + * worker process is started. When it moves out of `ready`, the + * worker is stopped gracefully. + * + * 3. **Emits events** — `plugin.loaded`, `plugin.enabled`, + * `plugin.disabled`, `plugin.unloaded`, `plugin.status_changed` + * events are emitted so that other services (job coordinator, + * tool dispatcher, event bus) can react accordingly. + * + * 4. **Persists state** — Status changes are written to the database + * through the plugin registry service. + * + * @see PLUGIN_SPEC.md §12 — Process Model + * @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy + */ +import { EventEmitter } from "node:events"; +import type { Db } from "@paperclipai/db"; +import type { + PluginStatus, + PluginRecord, + PaperclipPluginManifestV1, +} from "@paperclipai/shared"; +import { pluginRegistryService } from "./plugin-registry.js"; +import { pluginLoader, type PluginLoader } from "./plugin-loader.js"; +import type { PluginWorkerManager, WorkerStartOptions } from "./plugin-worker-manager.js"; +import { badRequest, notFound } from "../errors.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Lifecycle state machine +// --------------------------------------------------------------------------- + +/** + * Valid state transitions for the plugin lifecycle. + * + * installed → ready (initial load succeeds) + * installed → error (initial load fails) + * installed → uninstalled (abort installation) + * + * ready → disabled (operator disables plugin) + * ready → error (runtime failure) + * ready → upgrade_pending (upgrade with new capabilities) + * ready → uninstalled (uninstall) + * + * disabled → ready (operator re-enables plugin) + * disabled → uninstalled (uninstall while disabled) + * + * error → ready (retry / recovery) + * error → uninstalled (give up and uninstall) + * + * upgrade_pending → ready (operator approves new capabilities) + * upgrade_pending → error (upgrade worker fails) + * upgrade_pending → uninstalled (reject upgrade and uninstall) + * + * uninstalled → installed (reinstall) + */ +const VALID_TRANSITIONS: Record = { + installed: ["ready", "error", "uninstalled"], + ready: ["ready", "disabled", "error", "upgrade_pending", "uninstalled"], + disabled: ["ready", "uninstalled"], + error: ["ready", "uninstalled"], + upgrade_pending: ["ready", "error", "uninstalled"], + uninstalled: ["installed"], // reinstall +}; + +/** + * Check whether a transition from `from` → `to` is valid. + */ +function isValidTransition(from: PluginStatus, to: PluginStatus): boolean { + return VALID_TRANSITIONS[from]?.includes(to) ?? false; +} + +// --------------------------------------------------------------------------- +// Lifecycle events +// --------------------------------------------------------------------------- + +/** + * Events emitted by the PluginLifecycleManager. + * Consumers can subscribe to these for routing-table updates, UI refresh + * notifications, and observability. + */ +export interface PluginLifecycleEvents { + /** Emitted after a plugin is loaded (installed → ready). */ + "plugin.loaded": { pluginId: string; pluginKey: string }; + /** Emitted after a plugin transitions to ready (enabled). */ + "plugin.enabled": { pluginId: string; pluginKey: string }; + /** Emitted after a plugin is disabled (ready → disabled). */ + "plugin.disabled": { pluginId: string; pluginKey: string; reason?: string }; + /** Emitted after a plugin is unloaded (any → uninstalled). */ + "plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean }; + /** Emitted on any status change. */ + "plugin.status_changed": { + pluginId: string; + pluginKey: string; + previousStatus: PluginStatus; + newStatus: PluginStatus; + }; + /** Emitted when a plugin enters an error state. */ + "plugin.error": { pluginId: string; pluginKey: string; error: string }; + /** Emitted when a plugin enters upgrade_pending. */ + "plugin.upgrade_pending": { pluginId: string; pluginKey: string }; + /** Emitted when a plugin worker process has been started. */ + "plugin.worker_started": { pluginId: string; pluginKey: string }; + /** Emitted when a plugin worker process has been stopped. */ + "plugin.worker_stopped": { pluginId: string; pluginKey: string }; +} + +type LifecycleEventName = keyof PluginLifecycleEvents; +type LifecycleEventPayload = PluginLifecycleEvents[K]; + +// --------------------------------------------------------------------------- +// PluginLifecycleManager +// --------------------------------------------------------------------------- + +export interface PluginLifecycleManager { + /** + * Load a newly installed plugin – transitions `installed` → `ready`. + * + * This is called after the registry has persisted the initial install record. + * The caller should have already spawned the worker and performed health + * checks before calling this. If the worker fails, call `markError` instead. + */ + load(pluginId: string): Promise; + + /** + * Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state. + * Transitions → `ready`. + */ + enable(pluginId: string): Promise; + + /** + * Disable a running plugin. + * Transitions `ready` → `disabled`. + */ + disable(pluginId: string, reason?: string): Promise; + + /** + * Unload (uninstall) a plugin from any active state. + * Transitions → `uninstalled`. + * + * When `removeData` is true, the plugin row and cascaded config are + * hard-deleted. Otherwise a soft-delete sets status to `uninstalled`. + */ + unload(pluginId: string, removeData?: boolean): Promise; + + /** + * Mark a plugin as errored (e.g. worker crash, health-check failure). + * Transitions → `error`. + */ + markError(pluginId: string, error: string): Promise; + + /** + * Mark a plugin as requiring upgrade approval. + * Transitions `ready` → `upgrade_pending`. + */ + markUpgradePending(pluginId: string): Promise; + + /** + * Upgrade a plugin to a newer version. + * This is a placeholder that handles the lifecycle state transition. + * The actual package installation is handled by plugin-loader. + * + * If the upgrade adds new capabilities, transitions to `upgrade_pending`. + * Otherwise, transitions to `ready` directly. + */ + upgrade(pluginId: string, version?: string): Promise; + + /** + * Start the worker process for a plugin that is already in `ready` state. + * + * This is used by the server startup orchestration to start workers for + * plugins that were persisted as `ready`. It requires a `PluginWorkerManager` + * to have been provided at construction time. + * + * @param pluginId - The UUID of the plugin to start + * @param options - Worker start options (entrypoint path, config, etc.) + * @throws if no worker manager is configured or the plugin is not ready + */ + startWorker(pluginId: string, options: WorkerStartOptions): Promise; + + /** + * Stop the worker process for a plugin without changing lifecycle state. + * + * This is used during server shutdown to gracefully stop all workers. + * It does not transition the plugin state — plugins remain in their + * current status so they can be restarted on next server boot. + * + * @param pluginId - The UUID of the plugin to stop + */ + stopWorker(pluginId: string): Promise; + + /** + * Restart the worker process for a running plugin. + * + * Stops and re-starts the worker process. The plugin remains in `ready` + * state throughout. This is typically called after a config change. + * + * @param pluginId - The UUID of the plugin to restart + * @throws if no worker manager is configured or the plugin is not ready + */ + restartWorker(pluginId: string): Promise; + + /** + * Get the current lifecycle state for a plugin. + */ + getStatus(pluginId: string): Promise; + + /** + * Check whether a transition is allowed from the plugin's current state. + */ + canTransition(pluginId: string, to: PluginStatus): Promise; + + /** + * Subscribe to lifecycle events. + */ + on( + event: K, + listener: (payload: LifecycleEventPayload) => void, + ): void; + + /** + * Unsubscribe from lifecycle events. + */ + off( + event: K, + listener: (payload: LifecycleEventPayload) => void, + ): void; + + /** + * Subscribe to a lifecycle event once. + */ + once( + event: K, + listener: (payload: LifecycleEventPayload) => void, + ): void; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Options for constructing a PluginLifecycleManager. + */ +export interface PluginLifecycleManagerOptions { + /** Plugin loader instance. Falls back to the default if omitted. */ + loader?: PluginLoader; + + /** + * Worker process manager. When provided, lifecycle transitions that bring + * a plugin online (load, enable, upgrade-to-ready) will start the worker + * process, and transitions that take a plugin offline (disable, unload, + * markError) will stop it. + * + * When omitted the lifecycle manager operates in state-only mode — the + * caller is responsible for managing worker processes externally. + */ + workerManager?: PluginWorkerManager; +} + +/** + * Create a PluginLifecycleManager. + * + * This service orchestrates plugin state transitions on top of the + * `pluginRegistryService` (which handles raw DB persistence). It enforces + * the lifecycle state machine, emits events for downstream consumers + * (routing tables, UI, observability), and manages worker processes via + * the `PluginWorkerManager` when one is provided. + * + * Usage: + * ```ts + * const lifecycle = pluginLifecycleManager(db, { + * workerManager: createPluginWorkerManager(), + * }); + * lifecycle.on("plugin.enabled", ({ pluginId }) => { ... }); + * await lifecycle.load(pluginId); + * ``` + * + * @see PLUGIN_SPEC.md §21.3 — `plugins.status` column + * @see PLUGIN_SPEC.md §12 — Process Model + */ +export function pluginLifecycleManager( + db: Db, + options?: PluginLoader | PluginLifecycleManagerOptions, +): PluginLifecycleManager { + // Support the legacy signature: pluginLifecycleManager(db, loader) + // as well as the new options object form. + let loaderArg: PluginLoader | undefined; + let workerManager: PluginWorkerManager | undefined; + + if (options && typeof options === "object" && "discoverAll" in options) { + // Legacy: second arg is a PluginLoader directly + loaderArg = options as PluginLoader; + } else if (options && typeof options === "object") { + const opts = options as PluginLifecycleManagerOptions; + loaderArg = opts.loader; + workerManager = opts.workerManager; + } + + const registry = pluginRegistryService(db); + const pluginLoaderInstance = loaderArg ?? pluginLoader(db); + const emitter = new EventEmitter(); + emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound + + const log = logger.child({ service: "plugin-lifecycle" }); + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function requirePlugin(pluginId: string): Promise { + const plugin = await registry.getById(pluginId); + if (!plugin) throw notFound(`Plugin not found: ${pluginId}`); + return plugin as PluginRecord; + } + + function assertTransition(plugin: PluginRecord, to: PluginStatus): void { + if (!isValidTransition(plugin.status, to)) { + throw badRequest( + `Invalid lifecycle transition: ${plugin.status} → ${to} for plugin ${plugin.pluginKey}`, + ); + } + } + + async function transition( + pluginId: string, + to: PluginStatus, + lastError: string | null = null, + existingPlugin?: PluginRecord, + ): Promise { + const plugin = existingPlugin ?? await requirePlugin(pluginId); + assertTransition(plugin, to); + + const previousStatus = plugin.status; + + const updated = await registry.updateStatus(pluginId, { + status: to, + lastError, + }); + + if (!updated) throw notFound(`Plugin not found after status update: ${pluginId}`); + const result = updated as PluginRecord; + + log.info( + { pluginId, pluginKey: result.pluginKey, from: previousStatus, to }, + `plugin lifecycle: ${previousStatus} → ${to}`, + ); + + // Emit the generic status_changed event + emitter.emit("plugin.status_changed", { + pluginId, + pluginKey: result.pluginKey, + previousStatus, + newStatus: to, + }); + + return result; + } + + function emitDomain( + event: LifecycleEventName, + payload: PluginLifecycleEvents[LifecycleEventName], + ): void { + emitter.emit(event, payload); + } + + // ----------------------------------------------------------------------- + // Worker management helpers + // ----------------------------------------------------------------------- + + /** + * Stop the worker for a plugin if one is running. + * This is a best-effort operation — if no worker manager is configured + * or no worker is running, it silently succeeds. + */ + async function stopWorkerIfRunning( + pluginId: string, + pluginKey: string, + ): Promise { + if (!workerManager) return; + if (!workerManager.isRunning(pluginId) && !workerManager.getWorker(pluginId)) return; + + try { + await workerManager.stopWorker(pluginId); + log.info({ pluginId, pluginKey }, "plugin lifecycle: worker stopped"); + emitDomain("plugin.worker_stopped", { pluginId, pluginKey }); + } catch (err) { + log.warn( + { pluginId, pluginKey, err: err instanceof Error ? err.message : String(err) }, + "plugin lifecycle: failed to stop worker (best-effort)", + ); + } + } + + async function activateReadyPlugin(pluginId: string): Promise { + const supportsRuntimeActivation = + typeof pluginLoaderInstance.hasRuntimeServices === "function" + && typeof pluginLoaderInstance.loadSingle === "function"; + if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) { + return; + } + + const loadResult = await pluginLoaderInstance.loadSingle(pluginId); + if (!loadResult.success) { + throw new Error( + loadResult.error + ?? `Failed to activate plugin ${loadResult.plugin.pluginKey}`, + ); + } + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + // -- load ------------------------------------------------------------- + /** + * load — Transitions a plugin to 'ready' status and starts its worker. + * + * This method is called after a plugin has been successfully installed and + * validated. It marks the plugin as ready in the database and immediately + * triggers the plugin loader to start the worker process. + * + * @param pluginId - The UUID of the plugin to load. + * @returns The updated plugin record. + */ + async load(pluginId: string): Promise { + const result = await transition(pluginId, "ready"); + await activateReadyPlugin(pluginId); + + emitDomain("plugin.loaded", { + pluginId, + pluginKey: result.pluginKey, + }); + emitDomain("plugin.enabled", { + pluginId, + pluginKey: result.pluginKey, + }); + return result; + }, + + // -- enable ----------------------------------------------------------- + /** + * enable — Re-enables a plugin that was previously in an error or upgrade state. + * + * Similar to load(), this method transitions the plugin to 'ready' and starts + * its worker, but it specifically targets plugins that are currently disabled. + * + * @param pluginId - The UUID of the plugin to enable. + * @returns The updated plugin record. + */ + async enable(pluginId: string): Promise { + const plugin = await requirePlugin(pluginId); + + // Only allow enabling from disabled, error, or upgrade_pending states + if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") { + throw badRequest( + `Cannot enable plugin in status '${plugin.status}'. ` + + `Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`, + ); + } + + const result = await transition(pluginId, "ready", null, plugin); + await activateReadyPlugin(pluginId); + emitDomain("plugin.enabled", { + pluginId, + pluginKey: result.pluginKey, + }); + return result; + }, + + // -- disable ---------------------------------------------------------- + async disable(pluginId: string, reason?: string): Promise { + const plugin = await requirePlugin(pluginId); + + // Only allow disabling from ready state + if (plugin.status !== "ready") { + throw badRequest( + `Cannot disable plugin in status '${plugin.status}'. ` + + `Plugin must be in 'ready' status to be disabled.`, + ); + } + + // Stop the worker before transitioning state + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + const result = await transition(pluginId, "disabled", reason ?? null, plugin); + emitDomain("plugin.disabled", { + pluginId, + pluginKey: result.pluginKey, + reason, + }); + return result; + }, + + // -- unload ----------------------------------------------------------- + async unload( + pluginId: string, + removeData = false, + ): Promise { + const plugin = await requirePlugin(pluginId); + + // If already uninstalled and removeData, hard-delete + if (plugin.status === "uninstalled") { + if (removeData) { + const deleted = await registry.uninstall(pluginId, true); + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: hard-deleted already-uninstalled plugin", + ); + emitDomain("plugin.unloaded", { + pluginId, + pluginKey: plugin.pluginKey, + removeData: true, + }); + return deleted as PluginRecord | null; + } + throw badRequest( + `Plugin ${plugin.pluginKey} is already uninstalled. ` + + `Use removeData=true to permanently delete it.`, + ); + } + + // Stop the worker before uninstalling + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + // Perform the uninstall via registry (handles soft/hard delete) + const result = await registry.uninstall(pluginId, removeData); + + log.info( + { pluginId, pluginKey: plugin.pluginKey, removeData }, + `plugin lifecycle: ${plugin.status} → uninstalled${removeData ? " (hard delete)" : ""}`, + ); + + emitter.emit("plugin.status_changed", { + pluginId, + pluginKey: plugin.pluginKey, + previousStatus: plugin.status, + newStatus: "uninstalled" as PluginStatus, + }); + + emitDomain("plugin.unloaded", { + pluginId, + pluginKey: plugin.pluginKey, + removeData, + }); + + return result as PluginRecord | null; + }, + + // -- markError -------------------------------------------------------- + async markError(pluginId: string, error: string): Promise { + // Stop the worker — the plugin is in an error state and should not + // continue running. The worker manager's auto-restart is disabled + // because we are intentionally taking the plugin offline. + const plugin = await requirePlugin(pluginId); + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + const result = await transition(pluginId, "error", error, plugin); + emitDomain("plugin.error", { + pluginId, + pluginKey: result.pluginKey, + error, + }); + return result; + }, + + // -- markUpgradePending ----------------------------------------------- + async markUpgradePending(pluginId: string): Promise { + // Stop the worker while waiting for operator approval of new capabilities + const plugin = await requirePlugin(pluginId); + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + const result = await transition(pluginId, "upgrade_pending", null, plugin); + emitDomain("plugin.upgrade_pending", { + pluginId, + pluginKey: result.pluginKey, + }); + return result; + }, + + // -- upgrade ---------------------------------------------------------- + /** + * Upgrade a plugin to a newer version by performing a package update and + * managing the lifecycle state transition. + * + * Following PLUGIN_SPEC.md §25.3, the upgrade process: + * 1. Stops the current worker process (if running). + * 2. Fetches and validates the new plugin package via the `PluginLoader`. + * 3. Compares the capabilities declared in the new manifest against the old one. + * 4. If new capabilities are added, transitions the plugin to `upgrade_pending` + * to await operator approval (worker stays stopped). + * 5. If no new capabilities are added, transitions the plugin back to `ready` + * with the updated version and manifest metadata. + * + * @param pluginId - The UUID of the plugin to upgrade. + * @param version - Optional target version specifier. + * @returns The updated `PluginRecord`. + * @throws {BadRequest} If the plugin is not in a ready or upgrade_pending state. + */ + async upgrade(pluginId: string, version?: string): Promise { + const plugin = await requirePlugin(pluginId); + + // Can only upgrade plugins that are ready or already in upgrade_pending + if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") { + throw badRequest( + `Cannot upgrade plugin in status '${plugin.status}'. ` + + `Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`, + ); + } + + log.info( + { pluginId, pluginKey: plugin.pluginKey, targetVersion: version }, + "plugin lifecycle: upgrade requested", + ); + + // Stop the current worker before upgrading on disk + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + + // 1. Download and validate new package via loader + const { oldManifest, newManifest, discovered } = + await pluginLoaderInstance.upgradePlugin(pluginId, { version }); + + log.info( + { + pluginId, + pluginKey: plugin.pluginKey, + oldVersion: oldManifest.version, + newVersion: newManifest.version, + }, + "plugin lifecycle: package upgraded on disk", + ); + + // 2. Compare capabilities + const addedCaps = newManifest.capabilities.filter( + (cap) => !oldManifest.capabilities.includes(cap), + ); + + // 3. Transition state + if (addedCaps.length > 0) { + // New capabilities require operator approval — worker stays stopped + log.info( + { pluginId, pluginKey: plugin.pluginKey, addedCaps }, + "plugin lifecycle: new capabilities detected, transitioning to upgrade_pending", + ); + // Skip the inner stopWorkerIfRunning since we already stopped above + const result = await transition(pluginId, "upgrade_pending", null, plugin); + emitDomain("plugin.upgrade_pending", { + pluginId, + pluginKey: result.pluginKey, + }); + return result; + } else { + const result = await transition(pluginId, "ready", null, { + ...plugin, + version: discovered.version, + manifestJson: newManifest, + } as PluginRecord); + await activateReadyPlugin(pluginId); + + emitDomain("plugin.loaded", { + pluginId, + pluginKey: result.pluginKey, + }); + emitDomain("plugin.enabled", { + pluginId, + pluginKey: result.pluginKey, + }); + + return result; + } + }, + + // -- startWorker ------------------------------------------------------ + async startWorker( + pluginId: string, + options: WorkerStartOptions, + ): Promise { + if (!workerManager) { + throw badRequest( + "Cannot start worker: no PluginWorkerManager is configured. " + + "Provide a workerManager option when constructing the lifecycle manager.", + ); + } + + const plugin = await requirePlugin(pluginId); + if (plugin.status !== "ready") { + throw badRequest( + `Cannot start worker for plugin in status '${plugin.status}'. ` + + `Plugin must be in 'ready' status.`, + ); + } + + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: starting worker", + ); + + await workerManager.startWorker(pluginId, options); + emitDomain("plugin.worker_started", { + pluginId, + pluginKey: plugin.pluginKey, + }); + + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: worker started", + ); + }, + + // -- stopWorker ------------------------------------------------------- + async stopWorker(pluginId: string): Promise { + if (!workerManager) return; // No worker manager — nothing to stop + + const plugin = await requirePlugin(pluginId); + await stopWorkerIfRunning(pluginId, plugin.pluginKey); + }, + + // -- restartWorker ---------------------------------------------------- + async restartWorker(pluginId: string): Promise { + if (!workerManager) { + throw badRequest( + "Cannot restart worker: no PluginWorkerManager is configured.", + ); + } + + const plugin = await requirePlugin(pluginId); + if (plugin.status !== "ready") { + throw badRequest( + `Cannot restart worker for plugin in status '${plugin.status}'. ` + + `Plugin must be in 'ready' status.`, + ); + } + + const handle = workerManager.getWorker(pluginId); + if (!handle) { + throw badRequest( + `Cannot restart worker for plugin "${plugin.pluginKey}": no worker is running.`, + ); + } + + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: restarting worker", + ); + + await handle.restart(); + + emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey }); + emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey }); + + log.info( + { pluginId, pluginKey: plugin.pluginKey }, + "plugin lifecycle: worker restarted", + ); + }, + + // -- getStatus -------------------------------------------------------- + async getStatus(pluginId: string): Promise { + const plugin = await registry.getById(pluginId); + return plugin?.status ?? null; + }, + + // -- canTransition ---------------------------------------------------- + async canTransition(pluginId: string, to: PluginStatus): Promise { + const plugin = await registry.getById(pluginId); + if (!plugin) return false; + return isValidTransition(plugin.status, to); + }, + + // -- Event subscriptions ---------------------------------------------- + on(event, listener) { + emitter.on(event, listener); + }, + + off(event, listener) { + emitter.off(event, listener); + }, + + once(event, listener) { + emitter.once(event, listener); + }, + }; +} diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts new file mode 100644 index 00000000..7041bc23 --- /dev/null +++ b/server/src/services/plugin-loader.ts @@ -0,0 +1,1852 @@ +/** + * PluginLoader — discovery, installation, and runtime activation of plugins. + * + * This service is the entry point for the plugin system's I/O boundary: + * + * 1. **Discovery** — Scans the local plugin directory + * (`~/.paperclip/plugins/`) and `node_modules` for packages matching + * the `paperclip-plugin-*` naming convention. Aggregates results with + * path-based deduplication. + * + * 2. **Installation** — `installPlugin()` downloads from npm (or reads a + * local path), validates the manifest, checks capability consistency, + * and persists the install record. + * + * 3. **Runtime activation** — `activatePlugin()` wires up a loaded plugin + * with all runtime services: resolves its entrypoint, builds + * capability-gated host handlers, spawns a worker process, syncs job + * declarations, registers event subscriptions, and discovers tools. + * + * 4. **Shutdown** — `shutdownAll()` gracefully stops all active workers + * and unregisters runtime hooks. + * + * @see PLUGIN_SPEC.md §8 — Plugin Discovery + * @see PLUGIN_SPEC.md §10 — Package Contract + * @see PLUGIN_SPEC.md §12 — Process Model + */ +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { Db } from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginLauncherDeclaration, + PluginRecord, + PluginUiSlotDeclaration, +} from "@paperclipai/shared"; +import { logger } from "../middleware/logger.js"; +import { pluginManifestValidator } from "./plugin-manifest-validator.js"; +import { pluginCapabilityValidator } from "./plugin-capability-validator.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import type { PluginWorkerManager, WorkerStartOptions, WorkerToHostHandlers } from "./plugin-worker-manager.js"; +import type { PluginEventBus } from "./plugin-event-bus.js"; +import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; +import type { PluginJobStore } from "./plugin-job-store.js"; +import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Naming convention for npm-published Paperclip plugins. + * Packages matching this pattern are considered Paperclip plugins. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +export const NPM_PLUGIN_PACKAGE_PREFIX = "paperclip-plugin-"; + +/** + * Default local plugin directory. The loader scans this directory for + * locally-installed plugin packages. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + */ +export const DEFAULT_LOCAL_PLUGIN_DIR = path.join( + os.homedir(), + ".paperclip", + "plugins", +); + +const DEV_TSX_LOADER_PATH = path.resolve(__dirname, "../../../cli/node_modules/tsx/dist/loader.mjs"); + +// --------------------------------------------------------------------------- +// Discovery result types +// --------------------------------------------------------------------------- + +/** + * A plugin package found during discovery from any source. + */ +export interface DiscoveredPlugin { + /** Absolute path to the root of the npm package directory. */ + packagePath: string; + /** The npm package name as declared in package.json. */ + packageName: string; + /** Semver version from package.json. */ + version: string; + /** Source that found this package. */ + source: PluginSource; + /** The parsed and validated manifest if available, null if discovery-only. */ + manifest: PaperclipPluginManifestV1 | null; +} + +/** + * Sources from which plugins can be discovered. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + */ +export type PluginSource = + | "local-filesystem" // ~/.paperclip/plugins/ local directory + | "npm" // npm packages matching paperclip-plugin-* convention + | "registry"; // future: remote plugin registry URL + +type ParsedSemver = { + major: number; + minor: number; + patch: number; + prerelease: string[]; +}; + +/** + * Result of a discovery scan. + */ +export interface PluginDiscoveryResult { + /** Plugins successfully discovered and validated. */ + discovered: DiscoveredPlugin[]; + /** Packages found but with validation errors. */ + errors: Array<{ packagePath: string; packageName: string; error: string }>; + /** Source(s) that were scanned. */ + sources: PluginSource[]; +} + +// --------------------------------------------------------------------------- +// Loader options +// --------------------------------------------------------------------------- + +/** + * Options for the plugin loader service. + */ +export interface PluginLoaderOptions { + /** + * Path to the local plugin directory to scan. + * Defaults to ~/.paperclip/plugins/ + */ + localPluginDir?: string; + + /** + * Whether to scan the local filesystem directory for plugins. + * Defaults to true. + */ + enableLocalFilesystem?: boolean; + + /** + * Whether to discover installed npm packages matching the paperclip-plugin-* + * naming convention. + * Defaults to true. + */ + enableNpmDiscovery?: boolean; + + /** + * Future: URL of the remote plugin registry to query. + * When set, the loader will also fetch available plugins from this endpoint. + * Registry support is not yet implemented; this field is reserved. + */ + registryUrl?: string; +} + +// --------------------------------------------------------------------------- +// Install options +// --------------------------------------------------------------------------- + +/** + * Options for installing a single plugin package. + */ +export interface PluginInstallOptions { + /** + * npm package name to install (e.g. "paperclip-plugin-linear" or "@acme/plugin-linear"). + * Either packageName or localPath must be set. + */ + packageName?: string; + + /** + * Absolute or relative path to a local plugin directory for development installs. + * When set, the plugin is loaded from this path without npm install. + * Either packageName or localPath must be set. + */ + localPath?: string; + + /** + * Version specifier passed to npm install (e.g. "^1.2.0", "latest"). + * Ignored when localPath is set. + */ + version?: string; + + /** + * Plugin install directory where packages are managed. + * Defaults to the localPluginDir configured on the service. + */ + installDir?: string; +} + +// --------------------------------------------------------------------------- +// Runtime options — services needed for initializing loaded plugins +// --------------------------------------------------------------------------- + +/** + * Runtime services passed to the loader for plugin initialization. + * + * When these are provided, the loader can fully activate plugins (spawn + * workers, register event subscriptions, sync jobs, register tools). + * When omitted, the loader operates in discovery/install-only mode. + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + * @see PLUGIN_SPEC.md §12 — Process Model + */ +export interface PluginRuntimeServices { + /** Worker process manager for spawning and managing plugin workers. */ + workerManager: PluginWorkerManager; + /** Event bus for registering plugin event subscriptions. */ + eventBus: PluginEventBus; + /** Job scheduler for registering plugin cron jobs. */ + jobScheduler: PluginJobScheduler; + /** Job store for syncing manifest job declarations to the DB. */ + jobStore: PluginJobStore; + /** Tool dispatcher for registering plugin-contributed agent tools. */ + toolDispatcher: PluginToolDispatcher; + /** Lifecycle manager for state transitions and worker lifecycle events. */ + lifecycleManager: PluginLifecycleManager; + /** + * Factory that creates worker-to-host RPC handlers for a given plugin. + * + * The returned handlers service worker→host calls (e.g. state.get, + * events.emit, config.get). Each plugin gets its own set of handlers + * scoped to its capabilities and plugin ID. + */ + buildHostHandlers: (pluginId: string, manifest: PaperclipPluginManifestV1) => WorkerToHostHandlers; + /** + * Host instance information passed to the worker during initialization. + * Includes the instance ID and host version. + */ + instanceInfo: { + instanceId: string; + hostVersion: string; + }; +} + +// --------------------------------------------------------------------------- +// Load results +// --------------------------------------------------------------------------- + +/** + * Result of activating (loading) a single plugin at runtime. + * + * Contains the plugin record, activation status, and any error that + * occurred during the process. + */ +export interface PluginLoadResult { + /** The plugin record from the database. */ + plugin: PluginRecord; + /** Whether the plugin was successfully activated. */ + success: boolean; + /** Error message if activation failed. */ + error?: string; + /** Which subsystems were registered during activation. */ + registered: { + /** True if the worker process was started. */ + worker: boolean; + /** Number of event subscriptions registered (from manifest event declarations). */ + eventSubscriptions: number; + /** Number of job declarations synced to the database. */ + jobs: number; + /** Number of webhook endpoints declared in manifest. */ + webhooks: number; + /** Number of agent tools registered. */ + tools: number; + }; +} + +/** + * Result of activating all ready plugins at server startup. + */ +export interface PluginLoadAllResult { + /** Total number of plugins that were attempted. */ + total: number; + /** Number of plugins successfully activated. */ + succeeded: number; + /** Number of plugins that failed to activate. */ + failed: number; + /** Per-plugin results. */ + results: PluginLoadResult[]; +} + +/** + * Normalized UI contribution metadata extracted from a plugin manifest. + * + * The host serves all plugin UI bundles from the manifest's `entrypoints.ui` + * directory and currently expects the bundle entry module to be `index.js`. + */ +export interface PluginUiContributionMetadata { + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +} + +// --------------------------------------------------------------------------- +// Service interface +// --------------------------------------------------------------------------- + +export interface PluginLoader { + /** + * Discover all available plugins from configured sources. + * + * This performs a non-destructive scan of all enabled sources and returns + * the discovered plugins with their parsed manifests. No installs or DB + * writes happen during discovery. + * + * @param npmSearchDirs - Optional override for node_modules directories to search. + * Passed through to discoverFromNpm. When omitted the defaults are used. + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + discoverAll(npmSearchDirs?: string[]): Promise; + + /** + * Scan the local filesystem plugin directory for installed plugin packages. + * + * Reads the plugin directory, attempts to load each subdirectory as an npm + * package, and validates the plugin manifest. + * + * @param dir - Directory to scan (defaults to configured localPluginDir). + */ + discoverFromLocalFilesystem(dir?: string): Promise; + + /** + * Discover Paperclip plugins installed as npm packages in the current + * Node.js environment matching the "paperclip-plugin-*" naming convention. + * + * Looks for packages in node_modules that match the naming convention. + * + * @param searchDirs - node_modules directories to search (defaults to process cwd resolution). + */ + discoverFromNpm(searchDirs?: string[]): Promise; + + /** + * Load and parse the plugin manifest from a package directory. + * + * Reads the package.json, finds the manifest entrypoint declared under + * the "paperclipPlugin.manifest" key, loads the manifest module, and + * validates it against the plugin manifest schema. + * + * Returns null if the package is not a Paperclip plugin. + * Throws if the package is a Paperclip plugin but the manifest is invalid. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ + loadManifest(packagePath: string): Promise; + + /** + * Install a plugin package and register it in the database. + * + * Follows the install process described in PLUGIN_SPEC.md §8.3: + * 1. Resolve npm package / local path. + * 2. Install into the plugin directory (npm install). + * 3. Read and validate plugin manifest. + * 4. Reject incompatible plugin API versions. + * 5. Validate manifest capabilities. + * 6. Persist install record in Postgres. + * 7. Return the discovered plugin for the caller to use. + * + * Worker spawning and lifecycle management are handled by the caller + * (pluginLifecycleManager and the server startup orchestration). + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + installPlugin(options: PluginInstallOptions): Promise; + + /** + * Upgrade an already-installed plugin to a newer version. + * + * Similar to installPlugin, but: + * 1. Requires the plugin to already exist in the database. + * 2. Uses the existing packageName if not provided in options. + * 3. Updates the existing plugin record instead of creating a new one. + * 4. Returns the old and new manifests for capability comparison. + * + * @see PLUGIN_SPEC.md §25.3 — Upgrade Lifecycle + */ + upgradePlugin(pluginId: string, options: Omit): Promise<{ + oldManifest: PaperclipPluginManifestV1; + newManifest: PaperclipPluginManifestV1; + discovered: DiscoveredPlugin; + }>; + + /** + * Check whether a plugin API version is supported by this host. + */ + isSupportedApiVersion(apiVersion: number): boolean; + + /** + * Get the local plugin directory this loader is configured to use. + */ + getLocalPluginDir(): string; + + // ----------------------------------------------------------------------- + // Runtime initialization (requires PluginRuntimeServices) + // ----------------------------------------------------------------------- + + /** + * Load and activate all plugins that are in `ready` status. + * + * This is the main server-startup orchestration method. For each plugin + * that is persisted as `ready`, it: + * 1. Resolves the worker entrypoint from the manifest. + * 2. Spawns the worker process via the worker manager. + * 3. Syncs job declarations from the manifest to the `plugin_jobs` table. + * 4. Registers the plugin with the job scheduler. + * 5. Registers event subscriptions declared in the manifest (scoped via the event bus). + * 6. Registers agent tools from the manifest via the tool dispatcher. + * + * Plugins that fail to activate are marked as `error` in the database. + * Activation failures are non-fatal — other plugins continue loading. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * Throws if runtime services are not available. + * + * @returns Aggregated results for all attempted plugin loads. + * + * @see PLUGIN_SPEC.md §8.4 — Server-Start Plugin Loading + * @see PLUGIN_SPEC.md §12 — Process Model + */ + loadAll(): Promise; + + /** + * Activate a single plugin that is in `installed` or `ready` status. + * + * Used after a fresh install (POST /api/plugins/install) or after + * enabling a previously disabled plugin. Performs the same subsystem + * registration as `loadAll()` but for a single plugin. + * + * If the plugin is in `installed` status, transitions it to `ready` + * via the lifecycle manager before spawning the worker. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * + * @param pluginId - UUID of the plugin to activate + * @returns The activation result for this plugin + * + * @see PLUGIN_SPEC.md §8.3 — Install Process + */ + loadSingle(pluginId: string): Promise; + + /** + * Deactivate a single plugin — stop its worker and unregister all + * subsystem registrations (events, jobs, tools). + * + * Used during plugin disable, uninstall, and before upgrade. Does NOT + * change the plugin's status in the database — that is the caller's + * responsibility (via the lifecycle manager). + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + * + * @param pluginId - UUID of the plugin to deactivate + * @param pluginKey - The plugin key (manifest ID) for scoped cleanup + * + * @see PLUGIN_SPEC.md §8.5 — Uninstall Process + */ + unloadSingle(pluginId: string, pluginKey: string): Promise; + + /** + * Stop all managed plugin workers. Called during server shutdown. + * + * Stops the job scheduler and then stops all workers via the worker + * manager. Does NOT change plugin statuses in the database — plugins + * remain in `ready` so they are restarted on next boot. + * + * **Requires** `PluginRuntimeServices` to have been provided at construction. + */ + shutdownAll(): Promise; + + /** + * Whether runtime services are available for plugin activation. + */ + hasRuntimeServices(): boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Check whether a package name matches the Paperclip plugin naming convention. + * Accepts both the "paperclip-plugin-" prefix and scoped "@scope/plugin-" packages. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +export function isPluginPackageName(name: string): boolean { + if (name.startsWith(NPM_PLUGIN_PACKAGE_PREFIX)) return true; + // Also accept scoped packages like @acme/plugin-linear or @paperclipai/plugin-* + if (name.includes("/")) { + const localPart = name.split("/")[1] ?? ""; + return localPart.startsWith("plugin-"); + } + return false; +} + +/** + * Read and parse a package.json from a directory path. + * Returns null if no package.json exists. + */ +async function readPackageJson( + dir: string, +): Promise | null> { + const pkgPath = path.join(dir, "package.json"); + if (!existsSync(pkgPath)) return null; + + try { + const raw = await readFile(pkgPath, "utf-8"); + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +/** + * Resolve the manifest entrypoint from a package.json and package root. + * + * The spec defines a "paperclipPlugin" key in package.json with a "manifest" + * subkey pointing to the manifest module. This helper resolves the path. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +function resolveManifestPath( + packageRoot: string, + pkgJson: Record, +): string | null { + const paperclipPlugin = pkgJson["paperclipPlugin"]; + if ( + paperclipPlugin !== null && + typeof paperclipPlugin === "object" && + !Array.isArray(paperclipPlugin) + ) { + const manifestRelPath = (paperclipPlugin as Record)[ + "manifest" + ]; + if (typeof manifestRelPath === "string") { + // NOTE: the resolved path is returned as-is even if the file does not yet + // exist on disk (e.g. the package has not been built). Callers MUST guard + // with existsSync() before passing the path to loadManifestFromPath(). + return path.resolve(packageRoot, manifestRelPath); + } + } + + // Fallback: look for dist/manifest.js as a convention + const conventionalPath = path.join(packageRoot, "dist", "manifest.js"); + if (existsSync(conventionalPath)) { + return conventionalPath; + } + + // Fallback: look for manifest.js at package root + const rootManifestPath = path.join(packageRoot, "manifest.js"); + if (existsSync(rootManifestPath)) { + return rootManifestPath; + } + + return null; +} + +function parseSemver(version: string): ParsedSemver | null { + const match = version.match( + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) return null; + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ? match[4].split(".") : [], + }; +} + +function compareIdentifiers(left: string, right: string): number { + const leftIsNumeric = /^\d+$/.test(left); + const rightIsNumeric = /^\d+$/.test(right); + + if (leftIsNumeric && rightIsNumeric) { + return Number(left) - Number(right); + } + + if (leftIsNumeric) return -1; + if (rightIsNumeric) return 1; + return left.localeCompare(right); +} + +function compareSemver(left: string, right: string): number { + const leftParsed = parseSemver(left); + const rightParsed = parseSemver(right); + + if (!leftParsed || !rightParsed) { + throw new Error(`Invalid semver comparison: '${left}' vs '${right}'`); + } + + const coreOrder = ( + ["major", "minor", "patch"] as const + ).map((key) => leftParsed[key] - rightParsed[key]).find((delta) => delta !== 0); + if (coreOrder) { + return coreOrder; + } + + if (leftParsed.prerelease.length === 0 && rightParsed.prerelease.length === 0) { + return 0; + } + if (leftParsed.prerelease.length === 0) return 1; + if (rightParsed.prerelease.length === 0) return -1; + + const maxLength = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length); + for (let index = 0; index < maxLength; index += 1) { + const leftId = leftParsed.prerelease[index]; + const rightId = rightParsed.prerelease[index]; + if (leftId === undefined) return -1; + if (rightId === undefined) return 1; + + const diff = compareIdentifiers(leftId, rightId); + if (diff !== 0) return diff; + } + + return 0; +} + +function getMinimumHostVersion(manifest: PaperclipPluginManifestV1): string | undefined { + return manifest.minimumHostVersion ?? manifest.minimumPaperclipVersion; +} + +/** + * Extract UI contribution metadata from a manifest for route serialization. + * + * Returns `null` when the plugin does not declare any UI slots or launchers. + * Launcher declarations are aggregated from both the legacy top-level + * `launchers` field and the preferred `ui.launchers` field. + */ +export function getPluginUiContributionMetadata( + manifest: PaperclipPluginManifestV1, +): PluginUiContributionMetadata | null { + const slots = manifest.ui?.slots ?? []; + const launchers = [ + ...(manifest.launchers ?? []), + ...(manifest.ui?.launchers ?? []), + ]; + + if (slots.length === 0 && launchers.length === 0) { + return null; + } + + return { + uiEntryFile: "index.js", + slots, + launchers, + }; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a PluginLoader service. + * + * The loader is responsible for plugin discovery, installation, and runtime + * activation. It reads plugin packages from the local filesystem and npm, + * validates their manifests, registers them in the database, and — when + * runtime services are provided — initialises worker processes, event + * subscriptions, job schedules, webhook endpoints, and agent tools. + * + * Usage (discovery & install only): + * ```ts + * const loader = pluginLoader(db, { enableLocalFilesystem: true }); + * + * // Discover all available plugins + * const result = await loader.discoverAll(); + * for (const plugin of result.discovered) { + * console.log(plugin.packageName, plugin.manifest?.id); + * } + * + * // Install a specific plugin + * const discovered = await loader.installPlugin({ + * packageName: "paperclip-plugin-linear", + * version: "^1.0.0", + * }); + * ``` + * + * Usage (full runtime activation at server startup): + * ```ts + * const loader = pluginLoader(db, loaderOpts, { + * workerManager, + * eventBus, + * jobScheduler, + * jobStore, + * toolDispatcher, + * lifecycleManager, + * buildHostHandlers: (pluginId, manifest) => ({ ... }), + * instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" }, + * }); + * + * // Load all ready plugins at startup + * const loadResult = await loader.loadAll(); + * console.log(`Loaded ${loadResult.succeeded}/${loadResult.total} plugins`); + * + * // Load a single plugin after install + * const singleResult = await loader.loadSingle(pluginId); + * + * // Shutdown all plugin workers on server exit + * await loader.shutdownAll(); + * ``` + * + * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout + * @see PLUGIN_SPEC.md §8.3 — Install Process + * @see PLUGIN_SPEC.md §12 — Process Model + */ +export function pluginLoader( + db: Db, + options: PluginLoaderOptions = {}, + runtimeServices?: PluginRuntimeServices, +): PluginLoader { + const { + localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR, + enableLocalFilesystem = true, + enableNpmDiscovery = true, + } = options; + + const registry = pluginRegistryService(db); + const manifestValidator = pluginManifestValidator(); + const capabilityValidator = pluginCapabilityValidator(); + const log = logger.child({ service: "plugin-loader" }); + const hostVersion = runtimeServices?.instanceInfo.hostVersion; + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /** + * Fetch a plugin from npm or local path, then parse and validate its manifest. + * + * This internal helper encapsulates the core plugin retrieval and validation + * logic used by both install and upgrade operations. It handles: + * 1. Resolving the package from npm or local filesystem. + * 2. Installing the package via npm if necessary. + * 3. Reading and parsing the plugin manifest. + * 4. Validating API version compatibility. + * 5. Validating manifest capabilities. + * + * @param installOptions - Options specifying the package to fetch. + * @returns A `DiscoveredPlugin` object containing the validated manifest. + */ + async function fetchAndValidate( + installOptions: PluginInstallOptions, + ): Promise { + const { packageName, localPath, version, installDir } = installOptions; + + if (!packageName && !localPath) { + throw new Error("Either packageName or localPath must be provided"); + } + + const targetInstallDir = installDir ?? localPluginDir; + + // Step 1 & 2: Resolve and install package + let resolvedPackagePath: string; + let resolvedPackageName: string; + + if (localPath) { + // Local path install — validate the directory exists + const absLocalPath = path.resolve(localPath); + if (!existsSync(absLocalPath)) { + throw new Error(`Local plugin path does not exist: ${absLocalPath}`); + } + resolvedPackagePath = absLocalPath; + const pkgJson = await readPackageJson(absLocalPath); + resolvedPackageName = + typeof pkgJson?.["name"] === "string" + ? pkgJson["name"] + : path.basename(absLocalPath); + + log.info( + { localPath: absLocalPath, packageName: resolvedPackageName }, + "plugin-loader: fetching plugin from local path", + ); + } else { + // npm install + const spec = version ? `${packageName}@${version}` : packageName!; + + log.info( + { spec, installDir: targetInstallDir }, + "plugin-loader: fetching plugin from npm", + ); + + try { + // Use execFile (not exec) to avoid shell injection from package name/version. + // --ignore-scripts prevents preinstall/install/postinstall hooks from + // executing arbitrary code on the host before manifest validation. + await execFileAsync( + "npm", + ["install", spec, "--prefix", targetInstallDir, "--save", "--ignore-scripts"], + { timeout: 120_000 }, // 2 minute timeout for npm install + ); + } catch (err) { + throw new Error(`npm install failed for ${spec}: ${String(err)}`); + } + + // Resolve the package path after installation + const nodeModulesPath = path.join(targetInstallDir, "node_modules"); + resolvedPackageName = packageName!; + + // Handle scoped packages + if (resolvedPackageName.startsWith("@")) { + const [scope, name] = resolvedPackageName.split("/"); + resolvedPackagePath = path.join(nodeModulesPath, scope!, name!); + } else { + resolvedPackagePath = path.join(nodeModulesPath, resolvedPackageName); + } + + if (!existsSync(resolvedPackagePath)) { + throw new Error( + `Package directory not found after installation: ${resolvedPackagePath}`, + ); + } + } + + // Step 3: Read and validate plugin manifest + // Note: this.loadManifest (used via current context) + const pkgJson = await readPackageJson(resolvedPackagePath); + if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); + + const manifestPath = resolveManifestPath(resolvedPackagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) { + throw new Error( + `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).`, + ); + } + + const manifest = await loadManifestFromPath(manifestPath); + + // Step 4: Reject incompatible plugin API versions + if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) { + throw new Error( + `Plugin ${manifest.id} declares apiVersion ${manifest.apiVersion} which is not supported by this host. ` + + `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, + ); + } + + // Step 5: Validate manifest capabilities are consistent + const capResult = capabilityValidator.validateManifestCapabilities(manifest); + if (!capResult.allowed) { + throw new Error( + `Plugin ${manifest.id} manifest has inconsistent capabilities. ` + + `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, + ); + } + + // Step 6: Reject plugins that require a newer host than the running server + const minimumHostVersion = getMinimumHostVersion(manifest); + if (minimumHostVersion && hostVersion) { + if (compareSemver(hostVersion, minimumHostVersion) < 0) { + throw new Error( + `Plugin ${manifest.id} requires host version ${minimumHostVersion} or newer, ` + + `but this server is running ${hostVersion}`, + ); + } + } + + // Use the version declared in the manifest (required field per the spec) + const resolvedVersion = manifest.version; + + return { + packagePath: resolvedPackagePath, + packageName: resolvedPackageName, + version: resolvedVersion, + source: localPath ? "local-filesystem" : "npm", + manifest, + }; + } + + /** + * Attempt to load and validate a plugin manifest from a resolved path. + * Returns the manifest on success or throws with a descriptive error. + */ + async function loadManifestFromPath( + manifestPath: string, + ): Promise { + let raw: unknown; + + try { + // Dynamic import works for both .js (ESM) and .cjs (CJS) manifests + const mod = await import(manifestPath) as Record; + // The manifest may be the default export or the module itself + raw = mod["default"] ?? mod; + } catch (err) { + throw new Error( + `Failed to load manifest module at ${manifestPath}: ${String(err)}`, + ); + } + + return manifestValidator.parseOrThrow(raw); + } + + /** + * Build a DiscoveredPlugin from a resolved package directory, or null + * if the package is not a Paperclip plugin. + */ + async function buildDiscoveredPlugin( + packagePath: string, + source: PluginSource, + ): Promise { + const pkgJson = await readPackageJson(packagePath); + if (!pkgJson) return null; + + const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; + const version = typeof pkgJson["version"] === "string" ? pkgJson["version"] : "0.0.0"; + + // Determine if this is a plugin package at all + const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; + const nameMatchesConvention = isPluginPackageName(packageName); + + if (!hasPaperclipPlugin && !nameMatchesConvention) { + return null; + } + + const manifestPath = resolveManifestPath(packagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) { + // Found a potential plugin package but no manifest entry point — treat + // as a discovery-only result with no manifest + return { + packagePath, + packageName, + version, + source, + manifest: null, + }; + } + + try { + const manifest = await loadManifestFromPath(manifestPath); + return { + packagePath, + packageName, + version, + source, + manifest, + }; + } catch (err) { + // Rethrow with context — callers catch and route to the errors array + throw new Error( + `Plugin ${packageName}: ${String(err)}`, + ); + } + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + return { + // ----------------------------------------------------------------------- + // discoverAll + // ----------------------------------------------------------------------- + + async discoverAll(npmSearchDirs?: string[]): Promise { + const allDiscovered: DiscoveredPlugin[] = []; + const allErrors: Array<{ packagePath: string; packageName: string; error: string }> = []; + const sources: PluginSource[] = []; + + if (enableLocalFilesystem) { + sources.push("local-filesystem"); + const fsResult = await this.discoverFromLocalFilesystem(); + allDiscovered.push(...fsResult.discovered); + allErrors.push(...fsResult.errors); + } + + if (enableNpmDiscovery) { + sources.push("npm"); + const npmResult = await this.discoverFromNpm(npmSearchDirs); + // Deduplicate against already-discovered packages (same package path) + const existingPaths = new Set(allDiscovered.map((d) => d.packagePath)); + for (const plugin of npmResult.discovered) { + if (!existingPaths.has(plugin.packagePath)) { + allDiscovered.push(plugin); + } + } + allErrors.push(...npmResult.errors); + } + + // Future: registry source (options.registryUrl) + if (options.registryUrl) { + sources.push("registry"); + log.warn( + { registryUrl: options.registryUrl }, + "plugin-loader: remote registry discovery is not yet implemented", + ); + } + + log.info( + { + discovered: allDiscovered.length, + errors: allErrors.length, + sources, + }, + "plugin-loader: discovery complete", + ); + + return { discovered: allDiscovered, errors: allErrors, sources }; + }, + + // ----------------------------------------------------------------------- + // discoverFromLocalFilesystem + // ----------------------------------------------------------------------- + + async discoverFromLocalFilesystem(dir?: string): Promise { + const scanDir = dir ?? localPluginDir; + const discovered: DiscoveredPlugin[] = []; + const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; + + if (!existsSync(scanDir)) { + log.debug( + { dir: scanDir }, + "plugin-loader: local plugin directory does not exist, skipping", + ); + return { discovered, errors, sources: ["local-filesystem"] }; + } + + let entries: string[]; + try { + entries = await readdir(scanDir); + } catch (err) { + log.warn({ dir: scanDir, err }, "plugin-loader: failed to read local plugin directory"); + return { discovered, errors, sources: ["local-filesystem"] }; + } + + for (const entry of entries) { + const entryPath = path.join(scanDir, entry); + + // Check if entry is a directory + let entryStat; + try { + entryStat = await stat(entryPath); + } catch { + continue; + } + if (!entryStat.isDirectory()) continue; + + // Handle scoped packages: @scope/plugin-name is a subdirectory + if (entry.startsWith("@")) { + let scopedEntries: string[]; + try { + scopedEntries = await readdir(entryPath); + } catch { + continue; + } + for (const scopedEntry of scopedEntries) { + const scopedPath = path.join(entryPath, scopedEntry); + try { + const scopedStat = await stat(scopedPath); + if (!scopedStat.isDirectory()) continue; + const plugin = await buildDiscoveredPlugin(scopedPath, "local-filesystem"); + if (plugin) discovered.push(plugin); + } catch (err) { + errors.push({ + packagePath: scopedPath, + packageName: `${entry}/${scopedEntry}`, + error: String(err), + }); + } + } + continue; + } + + try { + const plugin = await buildDiscoveredPlugin(entryPath, "local-filesystem"); + if (plugin) discovered.push(plugin); + } catch (err) { + const pkgJson = await readPackageJson(entryPath); + const packageName = + typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; + errors.push({ packagePath: entryPath, packageName, error: String(err) }); + } + } + + log.debug( + { dir: scanDir, discovered: discovered.length, errors: errors.length }, + "plugin-loader: local filesystem scan complete", + ); + + return { discovered, errors, sources: ["local-filesystem"] }; + }, + + // ----------------------------------------------------------------------- + // discoverFromNpm + // ----------------------------------------------------------------------- + + async discoverFromNpm(searchDirs?: string[]): Promise { + const discovered: DiscoveredPlugin[] = []; + const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; + + // Determine the node_modules directories to search. + // When searchDirs is undefined OR empty, fall back to the conventional + // defaults (cwd/node_modules and localPluginDir/node_modules). + // To search nowhere explicitly, pass a non-empty array of non-existent paths. + const dirsToSearch: string[] = searchDirs && searchDirs.length > 0 ? searchDirs : []; + + if (dirsToSearch.length === 0) { + // Default: search node_modules relative to the process working directory + // and also the local plugin dir's node_modules + const cwdNodeModules = path.join(process.cwd(), "node_modules"); + const localNodeModules = path.join(localPluginDir, "node_modules"); + + if (existsSync(cwdNodeModules)) dirsToSearch.push(cwdNodeModules); + if (existsSync(localNodeModules)) dirsToSearch.push(localNodeModules); + } + + for (const nodeModulesDir of dirsToSearch) { + if (!existsSync(nodeModulesDir)) continue; + + let entries: string[]; + try { + entries = await readdir(nodeModulesDir); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(nodeModulesDir, entry); + + // Handle scoped packages (@scope/*) + if (entry.startsWith("@")) { + let scopedEntries: string[]; + try { + scopedEntries = await readdir(entryPath); + } catch { + continue; + } + for (const scopedEntry of scopedEntries) { + const fullName = `${entry}/${scopedEntry}`; + if (!isPluginPackageName(fullName)) continue; + + const scopedPath = path.join(entryPath, scopedEntry); + try { + const plugin = await buildDiscoveredPlugin(scopedPath, "npm"); + if (plugin) discovered.push(plugin); + } catch (err) { + errors.push({ + packagePath: scopedPath, + packageName: fullName, + error: String(err), + }); + } + } + continue; + } + + // Non-scoped packages: check naming convention + if (!isPluginPackageName(entry)) continue; + + let entryStat; + try { + entryStat = await stat(entryPath); + } catch { + continue; + } + if (!entryStat.isDirectory()) continue; + + try { + const plugin = await buildDiscoveredPlugin(entryPath, "npm"); + if (plugin) discovered.push(plugin); + } catch (err) { + const pkgJson = await readPackageJson(entryPath); + const packageName = + typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; + errors.push({ packagePath: entryPath, packageName, error: String(err) }); + } + } + } + + log.debug( + { searchDirs: dirsToSearch, discovered: discovered.length, errors: errors.length }, + "plugin-loader: npm discovery scan complete", + ); + + return { discovered, errors, sources: ["npm"] }; + }, + + // ----------------------------------------------------------------------- + // loadManifest + // ----------------------------------------------------------------------- + + async loadManifest(packagePath: string): Promise { + const pkgJson = await readPackageJson(packagePath); + if (!pkgJson) return null; + + const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; + const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; + const nameMatchesConvention = isPluginPackageName(packageName); + + if (!hasPaperclipPlugin && !nameMatchesConvention) { + return null; + } + + const manifestPath = resolveManifestPath(packagePath, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) return null; + + return loadManifestFromPath(manifestPath); + }, + + // ----------------------------------------------------------------------- + // installPlugin + // ----------------------------------------------------------------------- + + async installPlugin(installOptions: PluginInstallOptions): Promise { + const discovered = await fetchAndValidate(installOptions); + + // Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved) + await registry.install( + { + packageName: discovered.packageName, + packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, + }, + discovered.manifest!, + ); + + log.info( + { + pluginId: discovered.manifest!.id, + packageName: discovered.packageName, + version: discovered.version, + capabilities: discovered.manifest!.capabilities, + }, + "plugin-loader: plugin installed successfully", + ); + + return discovered; + }, + + // ----------------------------------------------------------------------- + // upgradePlugin + // ----------------------------------------------------------------------- + + /** + * Upgrade an already-installed plugin to a newer version. + * + * This method: + * 1. Fetches and validates the new plugin package using `fetchAndValidate`. + * 2. Ensures the new manifest ID matches the existing plugin ID for safety. + * 3. Updates the plugin record in the registry with the new version and manifest. + * + * @param pluginId - The UUID of the plugin to upgrade. + * @param upgradeOptions - Options for the upgrade (packageName, localPath, version). + * @returns The old and new manifests, along with the discovery metadata. + * @throws {Error} If the plugin is not found or if the new manifest ID differs. + */ + async upgradePlugin( + pluginId: string, + upgradeOptions: Omit, + ): Promise<{ + oldManifest: PaperclipPluginManifestV1; + newManifest: PaperclipPluginManifestV1; + discovered: DiscoveredPlugin; + }> { + const plugin = (await registry.getById(pluginId)) as { + id: string; + packageName: string; + manifestJson: PaperclipPluginManifestV1; + } | null; + if (!plugin) throw new Error(`Plugin not found: ${pluginId}`); + + const oldManifest = plugin.manifestJson; + const { + packageName = plugin.packageName, + localPath, + version, + } = upgradeOptions; + + log.info( + { pluginId, packageName, version, localPath }, + "plugin-loader: upgrading plugin", + ); + + // 1. Fetch/Install the new version + const discovered = await fetchAndValidate({ + packageName, + localPath, + version, + installDir: localPluginDir, + }); + + const newManifest = discovered.manifest!; + + // 2. Validate it's the same plugin ID + if (newManifest.id !== oldManifest.id) { + throw new Error( + `Upgrade failed: new manifest ID '${newManifest.id}' does not match existing plugin ID '${oldManifest.id}'`, + ); + } + + // 3. Detect capability escalation — new capabilities not in the old manifest + const oldCaps = new Set(oldManifest.capabilities ?? []); + const newCaps = newManifest.capabilities ?? []; + const escalated = newCaps.filter((c) => !oldCaps.has(c)); + + if (escalated.length > 0) { + log.warn( + { pluginId, escalated, oldVersion: oldManifest.version, newVersion: newManifest.version }, + "plugin-loader: upgrade introduces new capabilities — requires admin approval", + ); + throw new Error( + `Upgrade for "${pluginId}" introduces new capabilities that require approval: ${escalated.join(", ")}. ` + + `The previous version declared [${[...oldCaps].join(", ")}]. ` + + `Please review and approve the capability escalation before upgrading.`, + ); + } + + // 4. Update the existing record + await registry.update(pluginId, { + packageName: discovered.packageName, + version: discovered.version, + manifest: newManifest, + }); + + return { + oldManifest, + newManifest, + discovered, + }; + }, + + // ----------------------------------------------------------------------- + // isSupportedApiVersion + // ----------------------------------------------------------------------- + + isSupportedApiVersion(apiVersion: number): boolean { + return manifestValidator.getSupportedVersions().includes(apiVersion); + }, + + // ----------------------------------------------------------------------- + // getLocalPluginDir + // ----------------------------------------------------------------------- + + getLocalPluginDir(): string { + return localPluginDir; + }, + + // ----------------------------------------------------------------------- + // hasRuntimeServices + // ----------------------------------------------------------------------- + + hasRuntimeServices(): boolean { + return runtimeServices !== undefined; + }, + + // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // loadAll + // ----------------------------------------------------------------------- + + /** + * loadAll — Loads and activates all plugins that are currently in 'ready' status. + * + * This method is typically called during server startup. It fetches all ready + * plugins from the registry and attempts to activate them in parallel using + * Promise.allSettled. Failures in individual plugins do not prevent others from loading. + * + * @returns A promise that resolves with summary statistics of the load operation. + */ + async loadAll(): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot loadAll: no PluginRuntimeServices provided. " + + "Pass runtime services as the third argument to pluginLoader().", + ); + } + + log.info("plugin-loader: loading all ready plugins"); + + // Fetch all plugins in ready status, ordered by installOrder + const readyPlugins = (await registry.listByStatus("ready")) as PluginRecord[]; + + if (readyPlugins.length === 0) { + log.info("plugin-loader: no ready plugins to load"); + return { total: 0, succeeded: 0, failed: 0, results: [] }; + } + + log.info( + { count: readyPlugins.length }, + "plugin-loader: found ready plugins to load", + ); + + // Load plugins in parallel + const results = await Promise.allSettled( + readyPlugins.map((plugin) => activatePlugin(plugin)) + ); + + const loadResults = results.map((r, i) => { + if (r.status === "fulfilled") return r.value; + return { + plugin: readyPlugins[i]!, + success: false, + error: String(r.reason), + registered: { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }; + }); + + const succeeded = loadResults.filter((r) => r.success).length; + const failed = loadResults.filter((r) => !r.success).length; + + log.info( + { + total: readyPlugins.length, + succeeded, + failed, + }, + "plugin-loader: loadAll complete", + ); + + return { + total: readyPlugins.length, + succeeded, + failed, + results: loadResults, + }; + }, + + // ----------------------------------------------------------------------- + // loadSingle + // ----------------------------------------------------------------------- + + /** + * loadSingle — Loads and activates a single plugin by its ID. + * + * This method retrieves the plugin from the registry, ensures it's in a valid + * state, and then calls activatePlugin to start its worker and register its + * capabilities (tools, jobs, etc.). + * + * @param pluginId - The UUID of the plugin to load. + * @returns A promise that resolves with the result of the activation. + */ + async loadSingle(pluginId: string): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot loadSingle: no PluginRuntimeServices provided. " + + "Pass runtime services as the third argument to pluginLoader().", + ); + } + + const plugin = (await registry.getById(pluginId)) as PluginRecord | null; + if (!plugin) { + throw new Error(`Plugin not found: ${pluginId}`); + } + + // If the plugin is in 'installed' status, transition it to 'ready' first. + // lifecycleManager.load() transitions the status AND activates the plugin + // via activateReadyPlugin() → loadSingle() (recursive call with 'ready' + // status) → activatePlugin(). We must NOT call activatePlugin() again here, + // as that would double-start the worker and duplicate registrations. + if (plugin.status === "installed") { + await runtimeServices.lifecycleManager.load(pluginId); + const updated = (await registry.getById(pluginId)) as PluginRecord | null; + if (!updated) throw new Error(`Plugin not found after status update: ${pluginId}`); + return { + plugin: updated, + success: true, + registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, + }; + } + + if (plugin.status !== "ready") { + throw new Error( + `Cannot load plugin in status '${plugin.status}'. ` + + `Plugin must be in 'installed' or 'ready' status.`, + ); + } + + return activatePlugin(plugin); + }, + + // ----------------------------------------------------------------------- + // unloadSingle + // ----------------------------------------------------------------------- + + async unloadSingle(pluginId: string, pluginKey: string): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot unloadSingle: no PluginRuntimeServices provided.", + ); + } + + log.info( + { pluginId, pluginKey }, + "plugin-loader: unloading single plugin", + ); + + const { + workerManager, + eventBus, + jobScheduler, + toolDispatcher, + } = runtimeServices; + + // 1. Unregister from job scheduler (cancels in-flight runs) + try { + await jobScheduler.unregisterPlugin(pluginId); + } catch (err) { + log.warn( + { pluginId, err: err instanceof Error ? err.message : String(err) }, + "plugin-loader: failed to unregister from job scheduler (best-effort)", + ); + } + + // 2. Clear event subscriptions + eventBus.clearPlugin(pluginKey); + + // 3. Unregister agent tools + toolDispatcher.unregisterPluginTools(pluginKey); + + // 4. Stop the worker process + try { + if (workerManager.isRunning(pluginId)) { + await workerManager.stopWorker(pluginId); + } + } catch (err) { + log.warn( + { pluginId, err: err instanceof Error ? err.message : String(err) }, + "plugin-loader: failed to stop worker during unload (best-effort)", + ); + } + + log.info( + { pluginId, pluginKey }, + "plugin-loader: plugin unloaded successfully", + ); + }, + + // ----------------------------------------------------------------------- + // shutdownAll + // ----------------------------------------------------------------------- + + async shutdownAll(): Promise { + if (!runtimeServices) { + throw new Error( + "Cannot shutdownAll: no PluginRuntimeServices provided.", + ); + } + + log.info("plugin-loader: shutting down all plugins"); + + const { workerManager, jobScheduler } = runtimeServices; + + // 1. Stop the job scheduler tick loop + jobScheduler.stop(); + + // 2. Stop all worker processes + await workerManager.stopAll(); + + log.info("plugin-loader: all plugins shut down"); + }, + }; + + // ------------------------------------------------------------------------- + // Internal: activatePlugin — shared logic for loadAll and loadSingle + // ------------------------------------------------------------------------- + + /** + * Activate a single plugin: spawn its worker, register event subscriptions, + * sync jobs, register tools. + * + * This is the core orchestration logic shared by `loadAll()` and `loadSingle()`. + * Failures are caught and reported in the result; the plugin is marked as + * `error` in the database when activation fails. + */ + async function activatePlugin(plugin: PluginRecord): Promise { + const manifest = plugin.manifestJson; + const pluginId = plugin.id; + const pluginKey = plugin.pluginKey; + + const registered: PluginLoadResult["registered"] = { + worker: false, + eventSubscriptions: 0, + jobs: 0, + webhooks: 0, + tools: 0, + }; + + // Guard: runtime services must exist (callers already checked) + if (!runtimeServices) { + return { + plugin, + success: false, + error: "No runtime services available", + registered, + }; + } + + const { + workerManager, + eventBus, + jobScheduler, + jobStore, + toolDispatcher, + lifecycleManager, + buildHostHandlers, + instanceInfo, + } = runtimeServices; + + try { + log.info( + { pluginId, pluginKey, version: plugin.version }, + "plugin-loader: activating plugin", + ); + + // ------------------------------------------------------------------ + // 1. Resolve worker entrypoint + // ------------------------------------------------------------------ + const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); + + // ------------------------------------------------------------------ + // 2. Build host handlers for this plugin + // ------------------------------------------------------------------ + const hostHandlers = buildHostHandlers(pluginId, manifest); + + // ------------------------------------------------------------------ + // 3. Retrieve plugin config (if any) + // ------------------------------------------------------------------ + let config: Record = {}; + try { + const configRow = await registry.getConfig(pluginId); + if (configRow && typeof configRow === "object" && "configJson" in configRow) { + config = (configRow as { configJson: Record }).configJson ?? {}; + } + } catch { + // Config may not exist yet — use empty object + log.debug({ pluginId }, "plugin-loader: no config found, using empty config"); + } + + // ------------------------------------------------------------------ + // 4. Spawn worker process + // ------------------------------------------------------------------ + const workerOptions: WorkerStartOptions = { + entrypointPath: workerEntrypoint, + manifest, + config, + instanceInfo, + apiVersion: manifest.apiVersion, + hostHandlers, + autoRestart: true, + }; + + // Repo-local plugin installs can resolve workspace TS sources at runtime + // (for example @paperclipai/shared exports). Run those workers through + // the tsx loader so first-party example plugins work in development. + if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { + workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; + } + + await workerManager.startWorker(pluginId, workerOptions); + registered.worker = true; + + log.info( + { pluginId, pluginKey }, + "plugin-loader: worker started", + ); + + // ------------------------------------------------------------------ + // 5. Sync job declarations and register with scheduler + // ------------------------------------------------------------------ + const jobDeclarations = manifest.jobs ?? []; + if (jobDeclarations.length > 0) { + await jobStore.syncJobDeclarations(pluginId, jobDeclarations); + await jobScheduler.registerPlugin(pluginId); + registered.jobs = jobDeclarations.length; + + log.info( + { pluginId, pluginKey, jobs: jobDeclarations.length }, + "plugin-loader: job declarations synced and plugin registered with scheduler", + ); + } + + // ------------------------------------------------------------------ + // 6. Register event subscriptions + // + // Note: Event subscriptions are declared at runtime by the plugin + // worker via the SDK's ctx.events.on() calls. The event bus manages + // per-plugin subscription scoping. Here we ensure the event bus has + // a scoped handle ready for this plugin — the actual subscriptions + // are registered by the host handler layer when the worker calls + // events.subscribe via RPC. + // + // The bus.forPlugin() call creates the scoped handle if needed; + // any previous subscriptions for this plugin are preserved if the + // worker is restarting. + // ------------------------------------------------------------------ + const _scopedBus = eventBus.forPlugin(pluginKey); + registered.eventSubscriptions = eventBus.subscriptionCount(pluginKey); + + log.debug( + { pluginId, pluginKey }, + "plugin-loader: event bus scoped handle ready", + ); + + // ------------------------------------------------------------------ + // 7. Register webhook endpoints (manifest-declared) + // + // Webhooks are statically declared in the manifest. The actual + // endpoint routing is handled by the plugin routes module which + // checks the manifest for declared webhooks. No explicit + // registration step is needed here — the manifest is persisted + // in the DB and the route handler reads it at request time. + // + // We track the count for the result reporting. + // ------------------------------------------------------------------ + const webhookDeclarations = manifest.webhooks ?? []; + registered.webhooks = webhookDeclarations.length; + + if (webhookDeclarations.length > 0) { + log.info( + { pluginId, pluginKey, webhooks: webhookDeclarations.length }, + "plugin-loader: webhook endpoints declared in manifest", + ); + } + + // ------------------------------------------------------------------ + // 8. Register agent tools + // ------------------------------------------------------------------ + const toolDeclarations = manifest.tools ?? []; + if (toolDeclarations.length > 0) { + toolDispatcher.registerPluginTools(pluginKey, manifest); + registered.tools = toolDeclarations.length; + + log.info( + { pluginId, pluginKey, tools: toolDeclarations.length }, + "plugin-loader: agent tools registered", + ); + } + + // ------------------------------------------------------------------ + // Done — plugin fully activated + // ------------------------------------------------------------------ + log.info( + { + pluginId, + pluginKey, + version: plugin.version, + registered, + }, + "plugin-loader: plugin activated successfully", + ); + + return { plugin, success: true, registered }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + log.error( + { pluginId, pluginKey, err: errorMessage }, + "plugin-loader: failed to activate plugin", + ); + + // Mark the plugin as errored in the database so it is not retried + // automatically on next startup without operator intervention. + try { + await lifecycleManager.markError(pluginId, `Activation failed: ${errorMessage}`); + } catch (markErr) { + log.error( + { + pluginId, + err: markErr instanceof Error ? markErr.message : String(markErr), + }, + "plugin-loader: failed to mark plugin as error after activation failure", + ); + } + + return { + plugin, + success: false, + error: errorMessage, + registered, + }; + } + } +} + +// --------------------------------------------------------------------------- +// Worker entrypoint resolution +// --------------------------------------------------------------------------- + +/** + * Resolve the absolute path to a plugin's worker entrypoint from its manifest + * and known install locations. + * + * The manifest `entrypoints.worker` field is relative to the package root. + * We check the local plugin directory (where the package was installed) and + * also the package directory if it was a local-path install. + * + * @see PLUGIN_SPEC.md §10 — Package Contract + */ +function resolveWorkerEntrypoint( + plugin: PluginRecord & { packagePath?: string | null }, + localPluginDir: string, +): string { + const manifest = plugin.manifestJson; + const workerRelPath = manifest.entrypoints.worker; + + // For local-path installs we persist the resolved package path; use it first + if (plugin.packagePath && existsSync(plugin.packagePath)) { + const entrypoint = path.resolve(plugin.packagePath, workerRelPath); + if (entrypoint.startsWith(path.resolve(plugin.packagePath)) && existsSync(entrypoint)) { + return entrypoint; + } + } + + // Try the local plugin directory (standard npm install location) + const packageName = plugin.packageName; + let packageDir: string; + + if (packageName.startsWith("@")) { + // Scoped package: @scope/plugin-name → localPluginDir/node_modules/@scope/plugin-name + const [scope, name] = packageName.split("/"); + packageDir = path.join(localPluginDir, "node_modules", scope!, name!); + } else { + packageDir = path.join(localPluginDir, "node_modules", packageName); + } + + // Also check if the package exists directly under localPluginDir + // (for direct local-path installs or symlinked packages) + const directDir = path.join(localPluginDir, packageName); + + // Try in order: node_modules path, direct path + for (const dir of [packageDir, directDir]) { + const entrypoint = path.resolve(dir, workerRelPath); + + // Security: ensure entrypoint is actually inside the directory (prevent path traversal) + if (!entrypoint.startsWith(path.resolve(dir))) { + continue; + } + + if (existsSync(entrypoint)) { + return entrypoint; + } + } + + // Fallback: try the worker path as-is (absolute or relative to cwd) + // ONLY if it's already an absolute path and we trust the manifest (which we've already validated) + if (path.isAbsolute(workerRelPath) && existsSync(workerRelPath)) { + return workerRelPath; + } + + throw new Error( + `Worker entrypoint not found for plugin "${plugin.pluginKey}". ` + + `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + + `${path.resolve(directDir, workerRelPath)}`, + ); +} diff --git a/server/src/services/plugin-log-retention.ts b/server/src/services/plugin-log-retention.ts new file mode 100644 index 00000000..832ddfd1 --- /dev/null +++ b/server/src/services/plugin-log-retention.ts @@ -0,0 +1,86 @@ +import { lt, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { pluginLogs } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; + +/** Default retention period: 7 days. */ +const DEFAULT_RETENTION_DAYS = 7; + +/** Maximum rows to delete per sweep to avoid long-running transactions. */ +const DELETE_BATCH_SIZE = 5_000; + +/** Maximum number of batches per sweep to guard against unbounded loops. */ +const MAX_ITERATIONS = 100; + +/** + * Delete plugin log rows older than `retentionDays`. + * + * Deletes in batches of `DELETE_BATCH_SIZE` to keep transaction sizes + * bounded and avoid holding locks for extended periods. + * + * @returns The total number of rows deleted. + */ +export async function prunePluginLogs( + db: Db, + retentionDays: number = DEFAULT_RETENTION_DAYS, +): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + let totalDeleted = 0; + let iterations = 0; + + // Delete in batches to avoid long-running transactions + while (iterations < MAX_ITERATIONS) { + const deleted = await db + .delete(pluginLogs) + .where(lt(pluginLogs.createdAt, cutoff)) + .returning({ id: pluginLogs.id }) + .then((rows) => rows.length); + + totalDeleted += deleted; + iterations++; + + if (deleted < DELETE_BATCH_SIZE) break; + } + + if (iterations >= MAX_ITERATIONS) { + logger.warn( + { totalDeleted, iterations, cutoffDate: cutoff }, + "Plugin log retention hit iteration limit; some logs may remain", + ); + } + + if (totalDeleted > 0) { + logger.info({ totalDeleted, retentionDays }, "Pruned expired plugin logs"); + } + + return totalDeleted; +} + +/** + * Start a periodic plugin log cleanup interval. + * + * @param db - Database connection + * @param intervalMs - How often to run (default: 1 hour) + * @param retentionDays - How many days of logs to keep (default: 7) + * @returns A cleanup function that stops the interval + */ +export function startPluginLogRetention( + db: Db, + intervalMs: number = 60 * 60 * 1_000, + retentionDays: number = DEFAULT_RETENTION_DAYS, +): () => void { + const timer = setInterval(() => { + prunePluginLogs(db, retentionDays).catch((err) => { + logger.warn({ err }, "Plugin log retention sweep failed"); + }); + }, intervalMs); + + // Run once immediately on startup + prunePluginLogs(db, retentionDays).catch((err) => { + logger.warn({ err }, "Initial plugin log retention sweep failed"); + }); + + return () => clearInterval(timer); +} diff --git a/server/src/services/plugin-manifest-validator.ts b/server/src/services/plugin-manifest-validator.ts new file mode 100644 index 00000000..9af6b61c --- /dev/null +++ b/server/src/services/plugin-manifest-validator.ts @@ -0,0 +1,163 @@ +/** + * PluginManifestValidator — schema validation for plugin manifest files. + * + * Uses the shared Zod schema (`pluginManifestV1Schema`) to validate + * manifest payloads. Provides both a safe `parse()` variant (returns + * a result union) and a throwing `parseOrThrow()` for HTTP error + * propagation at install time. + * + * @see PLUGIN_SPEC.md §10 — Plugin Manifest + * @see packages/shared/src/validators/plugin.ts — Zod schema definition + */ +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { PLUGIN_API_VERSION } from "@paperclipai/shared"; +import { badRequest } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Supported manifest API versions +// --------------------------------------------------------------------------- + +/** + * The set of plugin API versions this host can accept. + * When a new API version is introduced, add it here. Old versions should be + * retained until the host drops support for them. + */ +const SUPPORTED_VERSIONS = [PLUGIN_API_VERSION] as const; + +// --------------------------------------------------------------------------- +// Parse result types +// --------------------------------------------------------------------------- + +/** + * Successful parse result. + */ +export interface ManifestParseSuccess { + success: true; + manifest: PaperclipPluginManifestV1; +} + +/** + * Failed parse result. `errors` is a human-readable description of what went + * wrong; `details` is the raw Zod error list for programmatic inspection. + */ +export interface ManifestParseFailure { + success: false; + errors: string; + details: Array<{ path: (string | number)[]; message: string }>; +} + +/** Union of parse outcomes. */ +export type ManifestParseResult = ManifestParseSuccess | ManifestParseFailure; + +// --------------------------------------------------------------------------- +// PluginManifestValidator interface +// --------------------------------------------------------------------------- + +/** + * Service for parsing and validating plugin manifests. + * + * @see PLUGIN_SPEC.md §10 — Plugin Manifest + */ +export interface PluginManifestValidator { + /** + * Try to parse `input` as a plugin manifest. + * + * Returns a {@link ManifestParseSuccess} when the input passes all + * validation rules, or a {@link ManifestParseFailure} with human-readable + * error messages when it does not. + * + * This is the "safe" variant — it never throws. + */ + parse(input: unknown): ManifestParseResult; + + /** + * Parse `input` as a plugin manifest, throwing a 400 HttpError on failure. + * + * Use this at install time when an invalid manifest should surface as an + * HTTP error to the caller. + * + * @throws {HttpError} 400 Bad Request if the manifest is invalid. + */ + parseOrThrow(input: unknown): PaperclipPluginManifestV1; + + /** + * Return the list of plugin API versions supported by this host. + * + * Callers can use this to present the supported version range to operators + * or to decide whether a candidate plugin can be installed. + */ + getSupportedVersions(): readonly number[]; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a {@link PluginManifestValidator}. + * + * Usage: + * ```ts + * const validator = pluginManifestValidator(); + * + * // Safe parse — inspect the result + * const result = validator.parse(rawManifest); + * if (!result.success) { + * console.error(result.errors); + * return; + * } + * const manifest = result.manifest; + * + * // Throwing parse — use at install time + * const manifest = validator.parseOrThrow(rawManifest); + * + * // Check supported versions + * const versions = validator.getSupportedVersions(); // [1] + * ``` + */ +export function pluginManifestValidator(): PluginManifestValidator { + return { + parse(input: unknown): ManifestParseResult { + const result = pluginManifestV1Schema.safeParse(input); + + if (result.success) { + return { + success: true, + manifest: result.data as PaperclipPluginManifestV1, + }; + } + + const details = result.error.errors.map((issue) => ({ + path: issue.path, + message: issue.message, + })); + + const errors = details + .map(({ path, message }) => + path.length > 0 ? `${path.join(".")}: ${message}` : message, + ) + .join("; "); + + return { + success: false, + errors, + details, + }; + }, + + parseOrThrow(input: unknown): PaperclipPluginManifestV1 { + const result = this.parse(input); + + if (!result.success) { + throw badRequest(`Invalid plugin manifest: ${result.errors}`, result.details); + } + + return result.manifest; + }, + + getSupportedVersions(): readonly number[] { + return SUPPORTED_VERSIONS; + }, + }; +} diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts new file mode 100644 index 00000000..eb4495a5 --- /dev/null +++ b/server/src/services/plugin-registry.ts @@ -0,0 +1,963 @@ +import { asc, eq, ne, sql, and, inArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + plugins, + companies, + pluginConfig, + pluginCompanySettings, + pluginEntities, + pluginJobs, + pluginJobRuns, + pluginWebhookDeliveries, +} from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginStatus, + InstallPlugin, + UpdatePluginStatus, + UpsertPluginConfig, + PatchPluginConfig, + PluginCompanySettings, + CompanyPluginAvailability, + UpsertPluginCompanySettings, + UpdateCompanyPluginAvailability, + PluginEntityRecord, + PluginEntityQuery, + PluginJobRecord, + PluginJobRunRecord, + PluginWebhookDeliveryRecord, + PluginJobStatus, + PluginJobRunStatus, + PluginJobRunTrigger, + PluginWebhookDeliveryStatus, +} from "@paperclipai/shared"; +import { conflict, notFound } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Detect if a Postgres error is a unique-constraint violation on the + * `plugins_plugin_key_idx` unique index. + */ +function isPluginKeyConflict(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const err = error as { code?: string; constraint?: string; constraint_name?: string }; + const constraint = err.constraint ?? err.constraint_name; + return err.code === "23505" && constraint === "plugins_plugin_key_idx"; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * PluginRegistry – CRUD operations for the `plugins` and `plugin_config` + * tables. Follows the same factory-function pattern used by the rest of + * the Paperclip service layer. + * + * This is the lowest-level persistence layer for plugins. Higher-level + * concerns such as lifecycle state-machine enforcement and capability + * gating are handled by {@link pluginLifecycleManager} and + * {@link pluginCapabilityValidator} respectively. + * + * @see PLUGIN_SPEC.md §21.3 — Required Tables + */ +export function pluginRegistryService(db: Db) { + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function getById(id: string) { + return db + .select() + .from(plugins) + .where(eq(plugins.id, id)) + .then((rows) => rows[0] ?? null); + } + + async function getByKey(pluginKey: string) { + return db + .select() + .from(plugins) + .where(eq(plugins.pluginKey, pluginKey)) + .then((rows) => rows[0] ?? null); + } + + async function nextInstallOrder(): Promise { + const result = await db + .select({ maxOrder: sql`coalesce(max(${plugins.installOrder}), 0)` }) + .from(plugins); + return (result[0]?.maxOrder ?? 0) + 1; + } + + /** + * Load the persisted company override row for a plugin, if one exists. + * + * Missing rows are meaningful: the company inherits the default-enabled + * behavior and the caller should treat the plugin as available. + */ + async function getCompanySettingsRow(companyId: string, pluginId: string) { + return db + .select() + .from(pluginCompanySettings) + .where(and( + eq(pluginCompanySettings.companyId, companyId), + eq(pluginCompanySettings.pluginId, pluginId), + )) + .then((rows) => rows[0] ?? null); + } + + /** + * Normalize registry records into the API response returned by company + * plugin availability routes. + * + * The key business rule is captured here: plugins are enabled for a company + * unless an explicit `plugin_company_settings.enabled = false` override says + * otherwise. + */ + function toCompanyAvailability( + companyId: string, + plugin: Awaited>, + settings: PluginCompanySettings | null, + ): CompanyPluginAvailability { + if (!plugin) { + throw notFound("Plugin not found"); + } + + return { + companyId, + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + pluginDisplayName: plugin.manifestJson.displayName, + pluginStatus: plugin.status, + available: settings?.enabled ?? true, + settingsJson: settings?.settingsJson ?? {}, + lastError: settings?.lastError ?? null, + createdAt: settings?.createdAt ?? null, + updatedAt: settings?.updatedAt ?? null, + }; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + // ----- Read ----------------------------------------------------------- + + /** List all registered plugins ordered by install order. */ + list: () => + db + .select() + .from(plugins) + .orderBy(asc(plugins.installOrder)), + + /** + * List installed plugins (excludes soft-deleted/uninstalled). + * Use for Plugin Manager and default API list so uninstalled plugins do not appear. + */ + listInstalled: () => + db + .select() + .from(plugins) + .where(ne(plugins.status, "uninstalled")) + .orderBy(asc(plugins.installOrder)), + + /** List plugins filtered by status. */ + listByStatus: (status: PluginStatus) => + db + .select() + .from(plugins) + .where(eq(plugins.status, status)) + .orderBy(asc(plugins.installOrder)), + + /** Get a single plugin by primary key. */ + getById, + + /** Get a single plugin by its unique `pluginKey`. */ + getByKey, + + // ----- Install / Register -------------------------------------------- + + /** + * Register (install) a new plugin. + * + * The caller is expected to have already resolved and validated the + * manifest from the package. This method persists the plugin row and + * assigns the next install order. + */ + install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => { + const existing = await getByKey(manifest.id); + if (existing) { + if (existing.status !== "uninstalled") { + throw conflict(`Plugin already installed: ${manifest.id}`); + } + + // Reinstall after soft-delete: reactivate the existing row so plugin-scoped + // data and references remain stable across uninstall/reinstall cycles. + return db + .update(plugins) + .set({ + packageName: input.packageName, + packagePath: input.packagePath ?? null, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + status: "installed" as PluginStatus, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(plugins.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + } + + const installOrder = await nextInstallOrder(); + + try { + const rows = await db + .insert(plugins) + .values({ + pluginKey: manifest.id, + packageName: input.packageName, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + status: "installed" as PluginStatus, + installOrder, + packagePath: input.packagePath ?? null, + }) + .returning(); + return rows[0]; + } catch (error) { + if (isPluginKeyConflict(error)) { + throw conflict(`Plugin already installed: ${manifest.id}`); + } + throw error; + } + }, + + // ----- Update --------------------------------------------------------- + + /** + * Update a plugin's manifest and version (e.g. on upgrade). + * The plugin must already exist. + */ + update: async ( + id: string, + data: { + packageName?: string; + version?: string; + manifest?: PaperclipPluginManifestV1; + }, + ) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + const setClause: Partial & { updatedAt: Date } = { + updatedAt: new Date(), + }; + if (data.packageName !== undefined) setClause.packageName = data.packageName; + if (data.version !== undefined) setClause.version = data.version; + if (data.manifest !== undefined) { + setClause.manifestJson = data.manifest; + setClause.apiVersion = data.manifest.apiVersion; + setClause.categories = data.manifest.categories; + } + + return db + .update(plugins) + .set(setClause) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Status --------------------------------------------------------- + + /** Update a plugin's lifecycle status and optional error message. */ + updateStatus: async (id: string, input: UpdatePluginStatus) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + return db + .update(plugins) + .set({ + status: input.status, + lastError: input.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Uninstall / Remove -------------------------------------------- + + /** + * Uninstall a plugin. + * + * When `removeData` is true the plugin row (and cascaded config) is + * hard-deleted. Otherwise the status is set to `"uninstalled"` for + * a soft-delete that preserves the record. + */ + uninstall: async (id: string, removeData = false) => { + const plugin = await getById(id); + if (!plugin) throw notFound("Plugin not found"); + + if (removeData) { + // Hard delete – plugin_config cascades via FK onDelete + return db + .delete(plugins) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + } + + // Soft delete – mark as uninstalled + return db + .update(plugins) + .set({ + status: "uninstalled" as PluginStatus, + updatedAt: new Date(), + }) + .where(eq(plugins.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Config --------------------------------------------------------- + + /** Retrieve a plugin's instance configuration. */ + getConfig: (pluginId: string) => + db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null), + + /** + * Create or fully replace a plugin's instance configuration. + * If a config row already exists for the plugin it is replaced; + * otherwise a new row is inserted. + */ + upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginConfig) + .set({ + configJson: input.configJson, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginConfig) + .values({ + pluginId, + configJson: input.configJson, + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Partially update a plugin's instance configuration via shallow merge. + * If no config row exists yet one is created with the supplied values. + */ + patchConfig: async (pluginId: string, input: PatchPluginConfig) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null); + + if (existing) { + const merged = { ...existing.configJson, ...input.configJson }; + return db + .update(pluginConfig) + .set({ + configJson: merged, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginConfig) + .values({ + pluginId, + configJson: input.configJson, + }) + .returning() + .then((rows) => rows[0]); + }, + + // ----- Company-scoped settings ---------------------------------------- + + /** Retrieve a plugin's company-scoped settings row, if any. */ + getCompanySettings: (companyId: string, pluginId: string) => + getCompanySettingsRow(companyId, pluginId), + + /** Create or replace the company-scoped settings row for a plugin. */ + upsertCompanySettings: async ( + companyId: string, + pluginId: string, + input: UpsertPluginCompanySettings, + ) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + if (existing) { + return db + .update(pluginCompanySettings) + .set({ + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0]); + }, + + /** Delete the company-scoped settings row for a plugin if it exists. */ + deleteCompanySettings: async (companyId: string, pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + if (!existing) return null; + + return db + .delete(pluginCompanySettings) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + /** List normalized company-plugin availability records across installed plugins. */ + listCompanyAvailability: async ( + companyId: string, + filter?: { available?: boolean }, + ) => { + const installed = await db + .select() + .from(plugins) + .where(ne(plugins.status, "uninstalled")) + .orderBy(asc(plugins.installOrder)); + + const settingsRows = await db + .select() + .from(pluginCompanySettings) + .where(eq(pluginCompanySettings.companyId, companyId)); + + const settingsByPluginId = new Map(settingsRows.map((row) => [row.pluginId, row])); + const availability = installed.map((plugin) => { + const row = settingsByPluginId.get(plugin.id) ?? null; + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + }); + + if (filter?.available === undefined) return availability; + return availability.filter((item) => item.available === filter.available); + }, + + /** + * Batch-check which companies have this plugin explicitly disabled. + * Returns a Set of companyIds where `enabled = false`. Companies with + * no settings row default to enabled, so they are NOT in the result set. + */ + getDisabledCompanyIds: async (companyIds: string[], pluginId: string): Promise> => { + if (companyIds.length === 0) return new Set(); + const rows = await db + .select({ + companyId: pluginCompanySettings.companyId, + enabled: pluginCompanySettings.enabled, + }) + .from(pluginCompanySettings) + .where(and( + inArray(pluginCompanySettings.companyId, companyIds), + eq(pluginCompanySettings.pluginId, pluginId), + )); + const disabled = new Set(); + for (const row of rows) { + if (!row.enabled) disabled.add(row.companyId); + } + return disabled; + }, + + /** Get the normalized availability record for a single company/plugin pair. */ + getCompanyAvailability: async (companyId: string, pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") return null; + + const settings = await getCompanySettingsRow(companyId, pluginId); + return toCompanyAvailability(companyId, plugin, settings); + }, + + /** Update normalized company availability, persisting or deleting settings as needed. */ + updateCompanyAvailability: async ( + companyId: string, + pluginId: string, + input: UpdateCompanyPluginAvailability, + ) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); + + const existing = await getCompanySettingsRow(companyId, pluginId); + + if (!input.available) { + const row = await (existing + ? db + .update(pluginCompanySettings) + .set({ + enabled: false, + settingsJson: input.settingsJson ?? existing.settingsJson, + lastError: input.lastError ?? existing.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]) + : db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: false, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0])); + + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + } + + const row = await (existing + ? db + .update(pluginCompanySettings) + .set({ + enabled: true, + settingsJson: input.settingsJson ?? existing.settingsJson, + lastError: input.lastError ?? existing.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]) + : db + .insert(pluginCompanySettings) + .values({ + companyId, + pluginId, + enabled: true, + settingsJson: input.settingsJson ?? {}, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0])); + + return { + ...toCompanyAvailability(companyId, plugin, row), + }; + }, + + /** + * Ensure all companies have an explicit enabled row for this plugin. + * + * Company availability defaults to enabled when no row exists, but this + * helper persists explicit `enabled=true` rows so newly-installed plugins + * appear as enabled immediately and consistently in company-scoped views. + */ + seedEnabledForAllCompanies: async (pluginId: string) => { + const plugin = await getById(pluginId); + if (!plugin || plugin.status === "uninstalled") throw notFound("Plugin not found"); + + const companyRows = await db + .select({ id: companies.id }) + .from(companies); + + if (companyRows.length === 0) return 0; + + const now = new Date(); + await db + .insert(pluginCompanySettings) + .values( + companyRows.map((company) => ({ + companyId: company.id, + pluginId, + enabled: true, + settingsJson: {}, + lastError: null, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [pluginCompanySettings.companyId, pluginCompanySettings.pluginId], + }); + + return companyRows.length; + }, + + /** + * Record an error against a plugin's config (e.g. validation failure + * against the plugin's instanceConfigSchema). + */ + setConfigError: async (pluginId: string, lastError: string | null) => { + const rows = await db + .update(pluginConfig) + .set({ lastError, updatedAt: new Date() }) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning(); + + if (rows.length === 0) throw notFound("Plugin config not found"); + return rows[0]; + }, + + /** Delete a plugin's config row. */ + deleteConfig: async (pluginId: string) => { + const rows = await db + .delete(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .returning(); + + return rows[0] ?? null; + }, + + // ----- Entities ------------------------------------------------------- + + /** + * List persistent entity mappings owned by a specific plugin, with filtering and pagination. + * + * @param pluginId - The UUID of the plugin. + * @param query - Optional filters (type, externalId) and pagination (limit, offset). + * @returns A list of matching `PluginEntityRecord` objects. + */ + listEntities: (pluginId: string, query?: PluginEntityQuery) => { + const conditions = [eq(pluginEntities.pluginId, pluginId)]; + if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType)); + if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId)); + + return db + .select() + .from(pluginEntities) + .where(and(...conditions)) + .orderBy(asc(pluginEntities.createdAt)) + .limit(query?.limit ?? 100) + .offset(query?.offset ?? 0); + }, + + /** + * Look up a plugin-owned entity mapping by its external identifier. + * + * @param pluginId - The UUID of the plugin. + * @param entityType - The type of entity (e.g., 'project', 'issue'). + * @param externalId - The identifier in the external system. + * @returns The matching `PluginEntityRecord` or null. + */ + getEntityByExternalId: ( + pluginId: string, + entityType: string, + externalId: string, + ) => + db + .select() + .from(pluginEntities) + .where( + and( + eq(pluginEntities.pluginId, pluginId), + eq(pluginEntities.entityType, entityType), + eq(pluginEntities.externalId, externalId), + ), + ) + .then((rows) => rows[0] ?? null), + + /** + * Create or update a persistent mapping between a Paperclip object and an + * external entity. + * + * @param pluginId - The UUID of the plugin. + * @param input - The entity data to persist. + * @returns The newly created or updated `PluginEntityRecord`. + */ + upsertEntity: async ( + pluginId: string, + input: Omit, + ) => { + // Drizzle doesn't support pg-specific onConflictDoUpdate easily in the insert() call + // with complex where clauses, so we do it manually. + const existing = await db + .select() + .from(pluginEntities) + .where( + and( + eq(pluginEntities.pluginId, pluginId), + eq(pluginEntities.entityType, input.entityType), + eq(pluginEntities.externalId, input.externalId ?? ""), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginEntities) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(pluginEntities.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginEntities) + .values({ + ...input, + pluginId, + } as any) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Delete a specific plugin-owned entity mapping by its internal UUID. + * + * @param id - The UUID of the entity record. + * @returns The deleted record, or null if not found. + */ + deleteEntity: async (id: string) => { + const rows = await db + .delete(pluginEntities) + .where(eq(pluginEntities.id, id)) + .returning(); + return rows[0] ?? null; + }, + + // ----- Jobs ----------------------------------------------------------- + + /** + * List all scheduled jobs registered for a specific plugin. + * + * @param pluginId - The UUID of the plugin. + * @returns A list of `PluginJobRecord` objects. + */ + listJobs: (pluginId: string) => + db + .select() + .from(pluginJobs) + .where(eq(pluginJobs.pluginId, pluginId)) + .orderBy(asc(pluginJobs.jobKey)), + + /** + * Look up a plugin job by its unique job key. + * + * @param pluginId - The UUID of the plugin. + * @param jobKey - The key defined in the plugin manifest. + * @returns The matching `PluginJobRecord` or null. + */ + getJobByKey: (pluginId: string, jobKey: string) => + db + .select() + .from(pluginJobs) + .where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey))) + .then((rows) => rows[0] ?? null), + + /** + * Register or update a scheduled job for a plugin. + * + * @param pluginId - The UUID of the plugin. + * @param jobKey - The unique key for the job. + * @param input - The schedule (cron) and optional status. + * @returns The updated or created `PluginJobRecord`. + */ + upsertJob: async ( + pluginId: string, + jobKey: string, + input: { schedule: string; status?: PluginJobStatus }, + ) => { + const existing = await db + .select() + .from(pluginJobs) + .where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey))) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginJobs) + .set({ + schedule: input.schedule, + status: input.status ?? existing.status, + updatedAt: new Date(), + }) + .where(eq(pluginJobs.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + + return db + .insert(pluginJobs) + .values({ + pluginId, + jobKey, + schedule: input.schedule, + status: input.status ?? "active", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Record the start of a specific job execution. + * + * @param pluginId - The UUID of the plugin. + * @param jobId - The UUID of the parent job record. + * @param trigger - What triggered this run (e.g., 'schedule', 'manual'). + * @returns The newly created `PluginJobRunRecord` in 'pending' status. + */ + createJobRun: async ( + pluginId: string, + jobId: string, + trigger: PluginJobRunTrigger, + ) => { + return db + .insert(pluginJobRuns) + .values({ + pluginId, + jobId, + trigger, + status: "pending", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Update the status, duration, and logs of a job execution record. + * + * @param runId - The UUID of the job run. + * @param input - The update fields (status, error, duration, etc.). + * @returns The updated `PluginJobRunRecord`. + */ + updateJobRun: async ( + runId: string, + input: { + status: PluginJobRunStatus; + durationMs?: number; + error?: string; + logs?: string[]; + startedAt?: Date; + finishedAt?: Date; + }, + ) => { + return db + .update(pluginJobRuns) + .set(input) + .where(eq(pluginJobRuns.id, runId)) + .returning() + .then((rows) => rows[0] ?? null); + }, + + // ----- Webhooks ------------------------------------------------------- + + /** + * Create a record for an incoming webhook delivery. + * + * @param pluginId - The UUID of the receiving plugin. + * @param webhookKey - The endpoint key defined in the manifest. + * @param input - The payload, headers, and optional external ID. + * @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status. + */ + createWebhookDelivery: async ( + pluginId: string, + webhookKey: string, + input: { + externalId?: string; + payload: Record; + headers?: Record; + }, + ) => { + return db + .insert(pluginWebhookDeliveries) + .values({ + pluginId, + webhookKey, + externalId: input.externalId, + payload: input.payload, + headers: input.headers ?? {}, + status: "pending", + }) + .returning() + .then((rows) => rows[0]); + }, + + /** + * Update the status and processing metrics of a webhook delivery. + * + * @param deliveryId - The UUID of the delivery record. + * @param input - The update fields (status, error, duration, etc.). + * @returns The updated `PluginWebhookDeliveryRecord`. + */ + updateWebhookDelivery: async ( + deliveryId: string, + input: { + status: PluginWebhookDeliveryStatus; + durationMs?: number; + error?: string; + startedAt?: Date; + finishedAt?: Date; + }, + ) => { + return db + .update(pluginWebhookDeliveries) + .set(input) + .where(eq(pluginWebhookDeliveries.id, deliveryId)) + .returning() + .then((rows) => rows[0] ?? null); + }, + }; +} diff --git a/server/src/services/plugin-runtime-sandbox.ts b/server/src/services/plugin-runtime-sandbox.ts new file mode 100644 index 00000000..0ffe4a3f --- /dev/null +++ b/server/src/services/plugin-runtime-sandbox.ts @@ -0,0 +1,221 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import type { PluginCapabilityValidator } from "./plugin-capability-validator.js"; + +export class PluginSandboxError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginSandboxError"; + } +} + +/** + * Sandbox runtime options used when loading a plugin worker module. + * + * `allowedModuleSpecifiers` controls which bare module specifiers are permitted. + * `allowedModules` provides concrete host-provided bindings for those specifiers. + */ +export interface PluginSandboxOptions { + entrypointPath: string; + allowedModuleSpecifiers?: ReadonlySet; + allowedModules?: Readonly>>; + allowedGlobals?: Record; + timeoutMs?: number; +} + +/** + * Operation-level runtime gate for plugin host API calls. + * Every host operation must be checked against manifest capabilities before execution. + */ +export interface CapabilityScopedInvoker { + invoke(operation: string, fn: () => Promise | T): Promise; +} + +interface LoadedModule { + namespace: Record; +} + +const DEFAULT_TIMEOUT_MS = 2_000; +const MODULE_PATH_SUFFIXES = ["", ".js", ".mjs", ".cjs", "/index.js", "/index.mjs", "/index.cjs"]; +const DEFAULT_GLOBALS: Record = { + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + URL, + URLSearchParams, + TextEncoder, + TextDecoder, + AbortController, + AbortSignal, +}; + +export function createCapabilityScopedInvoker( + manifest: PaperclipPluginManifestV1, + validator: PluginCapabilityValidator, +): CapabilityScopedInvoker { + return { + async invoke(operation: string, fn: () => Promise | T): Promise { + validator.assertOperation(manifest, operation); + return await fn(); + }, + }; +} + +/** + * Load a CommonJS plugin module in a VM context with explicit module import allow-listing. + * + * Security properties: + * - no implicit access to host globals like `process` + * - no unrestricted built-in module imports + * - relative imports are resolved only inside the plugin root directory + */ +export async function loadPluginModuleInSandbox( + options: PluginSandboxOptions, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const allowedSpecifiers = options.allowedModuleSpecifiers ?? new Set(); + const entrypointPath = path.resolve(options.entrypointPath); + const pluginRoot = path.dirname(entrypointPath); + + const context = vm.createContext({ + ...DEFAULT_GLOBALS, + ...options.allowedGlobals, + }); + + const moduleCache = new Map>(); + const allowedModules = options.allowedModules ?? {}; + + const realPluginRoot = realpathSync(pluginRoot); + + const loadModuleSync = (modulePath: string): Record => { + const resolvedPath = resolveModulePathSync(path.resolve(modulePath)); + const realPath = realpathSync(resolvedPath); + + if (!isWithinRoot(realPath, realPluginRoot)) { + throw new PluginSandboxError( + `Import '${modulePath}' escapes plugin root and is not allowed`, + ); + } + + const cached = moduleCache.get(realPath); + if (cached) return cached; + + const code = readModuleSourceSync(realPath); + + if (looksLikeEsm(code)) { + throw new PluginSandboxError( + "Sandbox loader only supports CommonJS modules. Build plugin worker entrypoints as CJS for sandboxed loading.", + ); + } + + const module = { exports: {} as Record }; + // Cache the module before execution to preserve CommonJS cycle semantics. + moduleCache.set(realPath, module.exports); + + const requireInSandbox = (specifier: string): Record => { + if (!specifier.startsWith(".") && !specifier.startsWith("/")) { + if (!allowedSpecifiers.has(specifier)) { + throw new PluginSandboxError( + `Import denied for module '${specifier}'. Add an explicit sandbox allow-list entry.`, + ); + } + + const binding = allowedModules[specifier]; + if (!binding) { + throw new PluginSandboxError( + `Bare module '${specifier}' is allow-listed but no host binding is registered.`, + ); + } + + return binding; + } + + const candidatePath = path.resolve(path.dirname(realPath), specifier); + return loadModuleSync(candidatePath); + }; + + // Inject the CJS module arguments into the context so the script can call + // the wrapper immediately. This is critical: the timeout in runInContext + // only applies during script evaluation. By including the self-invocation + // `(fn)(exports, module, ...)` in the script text, the timeout also covers + // the actual module body execution — preventing infinite loops from hanging. + const sandboxArgs = { + __paperclip_exports: module.exports, + __paperclip_module: module, + __paperclip_require: requireInSandbox, + __paperclip_filename: realPath, + __paperclip_dirname: path.dirname(realPath), + }; + // Temporarily inject args into the context, run, then remove to avoid pollution. + Object.assign(context, sandboxArgs); + const wrapped = `(function (exports, module, require, __filename, __dirname) {\n${code}\n})(__paperclip_exports, __paperclip_module, __paperclip_require, __paperclip_filename, __paperclip_dirname)`; + const script = new vm.Script(wrapped, { filename: realPath }); + try { + script.runInContext(context, { timeout: timeoutMs }); + } finally { + for (const key of Object.keys(sandboxArgs)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (context as Record)[key]; + } + } + + const normalizedExports = normalizeModuleExports(module.exports); + moduleCache.set(realPath, normalizedExports); + return normalizedExports; + }; + + const entryExports = loadModuleSync(entrypointPath); + + return { + namespace: { ...entryExports }, + }; +} + +function resolveModulePathSync(candidatePath: string): string { + for (const suffix of MODULE_PATH_SUFFIXES) { + const fullPath = `${candidatePath}${suffix}`; + if (existsSync(fullPath)) { + return fullPath; + } + } + + throw new PluginSandboxError(`Unable to resolve module import at path '${candidatePath}'`); +} + +/** + * True when `targetPath` is inside `rootPath` (or equals rootPath), false otherwise. + * Uses `path.relative` so sibling-prefix paths (e.g. `/root-a` vs `/root`) cannot bypass checks. + */ +function isWithinRoot(targetPath: string, rootPath: string): boolean { + const relative = path.relative(rootPath, targetPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function readModuleSourceSync(modulePath: string): string { + try { + return readFileSync(modulePath, "utf8"); + } catch (error) { + throw new PluginSandboxError( + `Failed to read sandbox module '${modulePath}': ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function normalizeModuleExports(exportsValue: unknown): Record { + if (typeof exportsValue === "object" && exportsValue !== null) { + return exportsValue as Record; + } + + return { default: exportsValue }; +} + +/** + * Lightweight guard to reject ESM syntax in the VM CommonJS loader. + */ +function looksLikeEsm(code: string): boolean { + return /(^|\n)\s*import\s+/m.test(code) || /(^|\n)\s*export\s+/m.test(code); +} diff --git a/server/src/services/plugin-secrets-handler.ts b/server/src/services/plugin-secrets-handler.ts new file mode 100644 index 00000000..a2156b38 --- /dev/null +++ b/server/src/services/plugin-secrets-handler.ts @@ -0,0 +1,367 @@ +/** + * Plugin secrets host-side handler — resolves secret references through the + * Paperclip secret provider system. + * + * When a plugin worker calls `ctx.secrets.resolve(secretRef)`, the JSON-RPC + * request arrives at the host with `{ secretRef }`. This module provides the + * concrete `HostServices.secrets` adapter that: + * + * 1. Parses the `secretRef` string to identify the secret. + * 2. Looks up the secret record and its latest version in the database. + * 3. Delegates to the configured `SecretProviderModule` to decrypt / + * resolve the raw value. + * 4. Returns the resolved plaintext value to the worker. + * + * ## Secret Reference Format + * + * A `secretRef` is a **secret UUID** — the primary key (`id`) of a row in + * the `company_secrets` table. Operators place these UUIDs into plugin + * config values; plugin workers resolve them at execution time via + * `ctx.secrets.resolve(secretId)`. + * + * ## Security Invariants + * + * - Resolved values are **never** logged, persisted, or included in error + * messages (per PLUGIN_SPEC.md §22). + * - The handler is capability-gated: only plugins with `secrets.read-ref` + * declared in their manifest may call it (enforced by `host-client-factory`). + * - The host handler itself does not cache resolved values. Each call goes + * through the secret provider to honour rotation. + * + * @see PLUGIN_SPEC.md §22 — Secrets + * @see host-client-factory.ts — capability gating + * @see services/secrets.ts — secretService used by agent env bindings + */ + +import { eq, and, desc } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db"; +import type { SecretProvider } from "@paperclipai/shared"; +import { getSecretProvider } from "../secrets/provider-registry.js"; +import { pluginRegistryService } from "./plugin-registry.js"; + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +/** + * Create a sanitised error that never leaks secret material. + * Only the ref identifier is included; never the resolved value. + */ +function secretNotFound(secretRef: string): Error { + const err = new Error(`Secret not found: ${secretRef}`); + err.name = "SecretNotFoundError"; + return err; +} + +function secretVersionNotFound(secretRef: string): Error { + const err = new Error(`No version found for secret: ${secretRef}`); + err.name = "SecretVersionNotFoundError"; + return err; +} + +function invalidSecretRef(secretRef: string): Error { + const err = new Error(`Invalid secret reference: ${secretRef}`); + err.name = "InvalidSecretRefError"; + return err; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** UUID v4 regex for validating secretRef format. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Check whether a secretRef looks like a valid UUID. + */ +function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +/** + * Collect the property paths (dot-separated keys) whose schema node declares + * `format: "secret-ref"`. Only top-level and nested `properties` are walked — + * this mirrors the flat/nested object shapes that `JsonSchemaForm` renders. + */ +function collectSecretRefPaths( + schema: Record | null | undefined, +): Set { + const paths = new Set(); + if (!schema || typeof schema !== "object") return paths; + + function walk(node: Record, prefix: string): void { + const props = node.properties as Record> | undefined; + if (!props || typeof props !== "object") return; + for (const [key, propSchema] of Object.entries(props)) { + if (!propSchema || typeof propSchema !== "object") continue; + const path = prefix ? `${prefix}.${key}` : key; + if (propSchema.format === "secret-ref") { + paths.add(path); + } + // Recurse into nested object schemas + if (propSchema.type === "object") { + walk(propSchema, path); + } + } + } + + walk(schema, ""); + return paths; +} + +/** + * Extract secret reference UUIDs from a plugin's configJson, scoped to only + * the fields annotated with `format: "secret-ref"` in the schema. + * + * When no schema is provided, falls back to collecting all UUID-shaped strings + * (backwards-compatible for plugins without a declared instanceConfigSchema). + */ +export function extractSecretRefsFromConfig( + configJson: unknown, + schema?: Record | null, +): Set { + const refs = new Set(); + if (configJson == null || typeof configJson !== "object") return refs; + + const secretPaths = collectSecretRefPaths(schema); + + // If schema declares secret-ref paths, extract only those values. + if (secretPaths.size > 0) { + for (const dotPath of secretPaths) { + const keys = dotPath.split("."); + let current: unknown = configJson; + for (const k of keys) { + if (current == null || typeof current !== "object") { current = undefined; break; } + current = (current as Record)[k]; + } + if (typeof current === "string" && isUuid(current)) { + refs.add(current); + } + } + return refs; + } + + // Fallback: no schema or no secret-ref annotations — collect all UUIDs. + // This preserves backwards compatibility for plugins that omit + // instanceConfigSchema. + function walkAll(value: unknown): void { + if (typeof value === "string") { + if (isUuid(value)) refs.add(value); + } else if (Array.isArray(value)) { + for (const item of value) walkAll(item); + } else if (value !== null && typeof value === "object") { + for (const v of Object.values(value as Record)) walkAll(v); + } + } + + walkAll(configJson); + return refs; +} + +// --------------------------------------------------------------------------- +// Handler factory +// --------------------------------------------------------------------------- + +/** + * Input shape for the `secrets.resolve` handler. + * + * Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`. + */ +export interface PluginSecretsResolveParams { + /** The secret reference string (a secret UUID). */ + secretRef: string; +} + +/** + * Options for creating the plugin secrets handler. + */ +export interface PluginSecretsHandlerOptions { + /** Database connection. */ + db: Db; + /** + * The plugin ID using this handler. + * Used for logging context only; never included in error payloads + * that reach the plugin worker. + */ + pluginId: string; +} + +/** + * The `HostServices.secrets` adapter for the plugin host-client factory. + */ +export interface PluginSecretsService { + /** + * Resolve a secret reference to its current plaintext value. + * + * @param params - Contains the `secretRef` (UUID of the secret) + * @returns The resolved secret value + * @throws {Error} If the secret is not found, has no versions, or + * the provider fails to resolve + */ + resolve(params: PluginSecretsResolveParams): Promise; +} + +/** + * Create a `HostServices.secrets` adapter for a specific plugin. + * + * The returned service looks up secrets by UUID, fetches the latest version + * material, and delegates to the appropriate `SecretProviderModule` for + * decryption. + * + * @example + * ```ts + * const secretsHandler = createPluginSecretsHandler({ db, pluginId }); + * const handlers = createHostClientHandlers({ + * pluginId, + * capabilities: manifest.capabilities, + * services: { + * secrets: secretsHandler, + * // ... + * }, + * }); + * ``` + * + * @param options - Database connection and plugin identity + * @returns A `PluginSecretsService` suitable for `HostServices.secrets` + */ +/** Simple sliding-window rate limiter for secret resolution attempts. */ +function createRateLimiter(maxAttempts: number, windowMs: number) { + const attempts = new Map(); + + return { + check(key: string): boolean { + const now = Date.now(); + const windowStart = now - windowMs; + const existing = (attempts.get(key) ?? []).filter((ts) => ts > windowStart); + if (existing.length >= maxAttempts) return false; + existing.push(now); + attempts.set(key, existing); + return true; + }, + }; +} + +export function createPluginSecretsHandler( + options: PluginSecretsHandlerOptions, +): PluginSecretsService { + const { db, pluginId } = options; + const registry = pluginRegistryService(db); + + // Rate limit: max 30 resolution attempts per plugin per minute + const rateLimiter = createRateLimiter(30, 60_000); + + let cachedAllowedRefs: Set | null = null; + let cachedAllowedRefsExpiry = 0; + const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL + + return { + async resolve(params: PluginSecretsResolveParams): Promise { + const { secretRef } = params; + + // --------------------------------------------------------------- + // 0. Rate limiting — prevent brute-force UUID enumeration + // --------------------------------------------------------------- + if (!rateLimiter.check(pluginId)) { + const err = new Error("Rate limit exceeded for secret resolution"); + err.name = "RateLimitExceededError"; + throw err; + } + + // --------------------------------------------------------------- + // 1. Validate the ref format + // --------------------------------------------------------------- + if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) { + throw invalidSecretRef(secretRef ?? ""); + } + + const trimmedRef = secretRef.trim(); + + if (!isUuid(trimmedRef)) { + throw invalidSecretRef(trimmedRef); + } + + // --------------------------------------------------------------- + // 1b. Scope check — only allow secrets referenced in this plugin's config + // --------------------------------------------------------------- + const now = Date.now(); + if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) { + const [configRow, plugin] = await Promise.all([ + db + .select() + .from(pluginConfig) + .where(eq(pluginConfig.pluginId, pluginId)) + .then((rows) => rows[0] ?? null), + registry.getById(pluginId), + ]); + + const schema = (plugin?.manifestJson as unknown as Record | null) + ?.instanceConfigSchema as Record | undefined; + cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema); + cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS; + } + + if (!cachedAllowedRefs.has(trimmedRef)) { + // Return "not found" to avoid leaking whether the secret exists + throw secretNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 2. Look up the secret record by UUID + // --------------------------------------------------------------- + const secret = await db + .select() + .from(companySecrets) + .where(eq(companySecrets.id, trimmedRef)) + .then((rows) => rows[0] ?? null); + + if (!secret) { + throw secretNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 2b. Verify the plugin is available for the secret's company. + // This prevents cross-company secret access via UUID guessing. + // --------------------------------------------------------------- + const companyId = (secret as { companyId?: string }).companyId; + if (companyId) { + const availability = await registry.getCompanyAvailability(companyId, pluginId); + if (!availability || !availability.available) { + // Return the same error as "not found" to avoid leaking existence + throw secretNotFound(trimmedRef); + } + } + + // --------------------------------------------------------------- + // 3. Fetch the latest version's material + // --------------------------------------------------------------- + const versionRow = await db + .select() + .from(companySecretVersions) + .where( + and( + eq(companySecretVersions.secretId, secret.id), + eq(companySecretVersions.version, secret.latestVersion), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!versionRow) { + throw secretVersionNotFound(trimmedRef); + } + + // --------------------------------------------------------------- + // 4. Resolve through the appropriate secret provider + // --------------------------------------------------------------- + const provider = getSecretProvider(secret.provider as SecretProvider); + const resolved = await provider.resolveVersion({ + material: versionRow.material as Record, + externalRef: secret.externalRef, + }); + + return resolved; + }, + }; +} diff --git a/server/src/services/plugin-state-store.ts b/server/src/services/plugin-state-store.ts new file mode 100644 index 00000000..94377dc0 --- /dev/null +++ b/server/src/services/plugin-state-store.ts @@ -0,0 +1,237 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { plugins, pluginState } from "@paperclipai/db"; +import type { + PluginStateScopeKind, + SetPluginState, + ListPluginState, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Default namespace used when the plugin does not specify one. */ +const DEFAULT_NAMESPACE = "default"; + +/** + * Build the WHERE clause conditions for a scoped state lookup. + * + * The five-part composite key is: + * `(pluginId, scopeKind, scopeId, namespace, stateKey)` + * + * `scopeId` may be null (for `instance` scope) or a non-empty string. + */ +function scopeConditions( + pluginId: string, + scopeKind: PluginStateScopeKind, + scopeId: string | undefined | null, + namespace: string, + stateKey: string, +) { + const conditions = [ + eq(pluginState.pluginId, pluginId), + eq(pluginState.scopeKind, scopeKind), + eq(pluginState.namespace, namespace), + eq(pluginState.stateKey, stateKey), + ]; + + if (scopeId != null && scopeId !== "") { + conditions.push(eq(pluginState.scopeId, scopeId)); + } else { + conditions.push(isNull(pluginState.scopeId)); + } + + return and(...conditions); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * Plugin State Store — scoped key-value persistence for plugin workers. + * + * Provides `get`, `set`, `delete`, and `list` operations over the + * `plugin_state` table. Each plugin's data is strictly namespaced by + * `pluginId` so plugins cannot read or write each other's state. + * + * This service implements the server-side backing for the `ctx.state` SDK + * client exposed to plugin workers. The host is responsible for: + * - enforcing `plugin.state.read` capability before calling `get` / `list` + * - enforcing `plugin.state.write` capability before calling `set` / `delete` + * + * @see PLUGIN_SPEC.md §14 — SDK Surface (`ctx.state`) + * @see PLUGIN_SPEC.md §15.1 — Capabilities: Plugin State + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` table + */ +export function pluginStateStore(db: Db) { + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + async function assertPluginExists(pluginId: string): Promise { + const rows = await db + .select({ id: plugins.id }) + .from(plugins) + .where(eq(plugins.id, pluginId)); + if (rows.length === 0) { + throw notFound(`Plugin not found: ${pluginId}`); + } + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + /** + * Read a state value. + * + * Returns the stored JSON value, or `null` if no entry exists for the + * given scope and key. + * + * Requires `plugin.state.read` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param scopeKind - Granularity of the scope + * @param scopeId - Identifier for the scoped entity (null for `instance` scope) + * @param stateKey - The key to read + * @param namespace - Sub-namespace (defaults to `"default"`) + */ + get: async ( + pluginId: string, + scopeKind: PluginStateScopeKind, + stateKey: string, + { + scopeId, + namespace = DEFAULT_NAMESPACE, + }: { scopeId?: string; namespace?: string } = {}, + ): Promise => { + const rows = await db + .select() + .from(pluginState) + .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); + + return rows[0]?.valueJson ?? null; + }, + + /** + * Write (create or replace) a state value. + * + * Uses an upsert so the caller does not need to check for prior existence. + * On conflict (same composite key) the existing row's `value_json` and + * `updated_at` are overwritten. + * + * Requires `plugin.state.write` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param input - Scope key and value to store + */ + set: async (pluginId: string, input: SetPluginState): Promise => { + await assertPluginExists(pluginId); + + const namespace = input.namespace ?? DEFAULT_NAMESPACE; + const scopeId = input.scopeId ?? null; + + await db + .insert(pluginState) + .values({ + pluginId, + scopeKind: input.scopeKind, + scopeId, + namespace, + stateKey: input.stateKey, + valueJson: input.value, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + pluginState.pluginId, + pluginState.scopeKind, + pluginState.scopeId, + pluginState.namespace, + pluginState.stateKey, + ], + set: { + valueJson: input.value, + updatedAt: new Date(), + }, + }); + }, + + /** + * Delete a state value. + * + * No-ops silently if the entry does not exist (idempotent by design). + * + * Requires `plugin.state.write` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param scopeKind - Granularity of the scope + * @param stateKey - The key to delete + * @param scopeId - Identifier for the scoped entity (null for `instance` scope) + * @param namespace - Sub-namespace (defaults to `"default"`) + */ + delete: async ( + pluginId: string, + scopeKind: PluginStateScopeKind, + stateKey: string, + { + scopeId, + namespace = DEFAULT_NAMESPACE, + }: { scopeId?: string; namespace?: string } = {}, + ): Promise => { + await db + .delete(pluginState) + .where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey)); + }, + + /** + * List all state entries for a plugin, optionally filtered by scope. + * + * Returns all matching rows as `PluginStateRecord`-shaped objects. + * The `valueJson` field contains the stored value. + * + * Requires `plugin.state.read` capability (enforced by the caller). + * + * @param pluginId - UUID of the owning plugin + * @param filter - Optional scope filters (scopeKind, scopeId, namespace) + */ + list: async (pluginId: string, filter: ListPluginState = {}): Promise => { + const conditions = [eq(pluginState.pluginId, pluginId)]; + + if (filter.scopeKind !== undefined) { + conditions.push(eq(pluginState.scopeKind, filter.scopeKind)); + } + if (filter.scopeId !== undefined) { + conditions.push(eq(pluginState.scopeId, filter.scopeId)); + } + if (filter.namespace !== undefined) { + conditions.push(eq(pluginState.namespace, filter.namespace)); + } + + return db + .select() + .from(pluginState) + .where(and(...conditions)); + }, + + /** + * Delete all state entries owned by a plugin. + * + * Called during plugin uninstall when `removeData = true`. Also useful + * for resetting a plugin's state during testing. + * + * @param pluginId - UUID of the owning plugin + */ + deleteAll: async (pluginId: string): Promise => { + await db + .delete(pluginState) + .where(eq(pluginState.pluginId, pluginId)); + }, + }; +} + +export type PluginStateStore = ReturnType; diff --git a/server/src/services/plugin-stream-bus.ts b/server/src/services/plugin-stream-bus.ts new file mode 100644 index 00000000..e39aca5e --- /dev/null +++ b/server/src/services/plugin-stream-bus.ts @@ -0,0 +1,81 @@ +/** + * In-memory pub/sub bus for plugin SSE streams. + * + * Workers emit stream events via JSON-RPC notifications. The bus fans out + * each event to all connected SSE clients that match the (pluginId, channel, + * companyId) tuple. + * + * @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming + */ + +/** Valid SSE event types for plugin streams. */ +export type StreamEventType = "message" | "open" | "close" | "error"; + +export type StreamSubscriber = (event: unknown, eventType: StreamEventType) => void; + +/** + * Composite key for stream subscriptions: pluginId:channel:companyId + */ +function streamKey(pluginId: string, channel: string, companyId: string): string { + return `${pluginId}:${channel}:${companyId}`; +} + +export interface PluginStreamBus { + /** + * Subscribe to stream events for a specific (pluginId, channel, companyId). + * Returns an unsubscribe function. + */ + subscribe( + pluginId: string, + channel: string, + companyId: string, + listener: StreamSubscriber, + ): () => void; + + /** + * Publish an event to all subscribers of (pluginId, channel, companyId). + * Called by the worker manager when it receives a stream notification. + */ + publish( + pluginId: string, + channel: string, + companyId: string, + event: unknown, + eventType?: StreamEventType, + ): void; +} + +/** + * Create a new PluginStreamBus instance. + */ +export function createPluginStreamBus(): PluginStreamBus { + const subscribers = new Map>(); + + return { + subscribe(pluginId, channel, companyId, listener) { + const key = streamKey(pluginId, channel, companyId); + let set = subscribers.get(key); + if (!set) { + set = new Set(); + subscribers.set(key, set); + } + set.add(listener); + + return () => { + set!.delete(listener); + if (set!.size === 0) { + subscribers.delete(key); + } + }; + }, + + publish(pluginId, channel, companyId, event, eventType: StreamEventType = "message") { + const key = streamKey(pluginId, channel, companyId); + const set = subscribers.get(key); + if (!set) return; + for (const listener of set) { + listener(event, eventType); + } + }, + }; +} diff --git a/server/src/services/plugin-tool-dispatcher.ts b/server/src/services/plugin-tool-dispatcher.ts new file mode 100644 index 00000000..18ea075b --- /dev/null +++ b/server/src/services/plugin-tool-dispatcher.ts @@ -0,0 +1,448 @@ +/** + * PluginToolDispatcher — orchestrates plugin tool discovery, lifecycle + * integration, and execution routing for the agent service. + * + * This service sits between the agent service and the lower-level + * `PluginToolRegistry` + `PluginWorkerManager`, providing a clean API that: + * + * - Discovers tools from loaded plugin manifests and registers them + * in the tool registry. + * - Hooks into `PluginLifecycleManager` events to automatically register + * and unregister tools when plugins are enabled or disabled. + * - Exposes the tool list in an agent-friendly format (with namespaced + * names, descriptions, parameter schemas). + * - Routes `executeTool` calls to the correct plugin worker and returns + * structured results. + * - Validates tool parameters against declared schemas before dispatch. + * + * The dispatcher is created once at server startup and shared across + * the application. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ + +import type { Db } from "@paperclipai/db"; +import type { + PaperclipPluginManifestV1, + PluginRecord, +} from "@paperclipai/shared"; +import type { ToolRunContext, ToolResult } from "@paperclipai/plugin-sdk"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; +import { + createPluginToolRegistry, + type PluginToolRegistry, + type RegisteredTool, + type ToolListFilter, + type ToolExecutionResult, +} from "./plugin-tool-registry.js"; +import { pluginRegistryService } from "./plugin-registry.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * An agent-facing tool descriptor — the shape returned when agents + * query for available tools. + * + * This is intentionally simpler than `RegisteredTool`, exposing only + * what agents need to decide whether and how to call a tool. + */ +export interface AgentToolDescriptor { + /** Fully namespaced tool name (e.g. `"acme.linear:search-issues"`). */ + name: string; + /** Human-readable display name. */ + displayName: string; + /** Description for the agent — explains when and how to use this tool. */ + description: string; + /** JSON Schema describing the tool's input parameters. */ + parametersSchema: Record; + /** The plugin that provides this tool. */ + pluginId: string; +} + +/** + * Options for creating the plugin tool dispatcher. + */ +export interface PluginToolDispatcherOptions { + /** The worker manager used to dispatch RPC calls to plugin workers. */ + workerManager?: PluginWorkerManager; + /** The lifecycle manager to listen for plugin state changes. */ + lifecycleManager?: PluginLifecycleManager; + /** Database connection for looking up plugin records. */ + db?: Db; +} + +// --------------------------------------------------------------------------- +// PluginToolDispatcher interface +// --------------------------------------------------------------------------- + +/** + * The plugin tool dispatcher — the primary integration point between the + * agent service and the plugin tool system. + * + * Agents use this service to: + * 1. List all available tools (for prompt construction / tool choice) + * 2. Execute a specific tool by its namespaced name + * + * The dispatcher handles lifecycle management internally — when a plugin + * is loaded or unloaded, its tools are automatically registered or removed. + */ +export interface PluginToolDispatcher { + /** + * Initialize the dispatcher — load tools from all currently-ready plugins + * and start listening for lifecycle events. + * + * Must be called once at server startup after the lifecycle manager + * and worker manager are ready. + */ + initialize(): Promise; + + /** + * Tear down the dispatcher — unregister lifecycle event listeners + * and clear all tool registrations. + * + * Called during server shutdown. + */ + teardown(): void; + + /** + * List all available tools for agents, optionally filtered. + * + * Returns tool descriptors in an agent-friendly format. + * + * @param filter - Optional filter criteria + * @returns Array of agent tool descriptors + */ + listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[]; + + /** + * Look up a tool by its namespaced name. + * + * @param namespacedName - e.g. `"acme.linear:search-issues"` + * @returns The registered tool, or `null` if not found + */ + getTool(namespacedName: string): RegisteredTool | null; + + /** + * Execute a tool by its namespaced name, routing to the correct + * plugin worker. + * + * @param namespacedName - Fully qualified tool name + * @param parameters - Input parameters matching the tool's schema + * @param runContext - Agent run context + * @returns The execution result with routing metadata + * @throws {Error} if the tool is not found, the worker is not running, + * or the tool execution fails + */ + executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise; + + /** + * Register all tools from a plugin manifest. + * + * This is called automatically when a plugin transitions to `ready`. + * Can also be called manually for testing or recovery scenarios. + * + * @param pluginId - The plugin's unique identifier + * @param manifest - The plugin manifest containing tool declarations + */ + registerPluginTools( + pluginId: string, + manifest: PaperclipPluginManifestV1, + ): void; + + /** + * Unregister all tools for a plugin. + * + * Called automatically when a plugin is disabled or unloaded. + * + * @param pluginId - The plugin to unregister + */ + unregisterPluginTools(pluginId: string): void; + + /** + * Get the total number of registered tools, optionally scoped to a plugin. + * + * @param pluginId - If provided, count only this plugin's tools + */ + toolCount(pluginId?: string): number; + + /** + * Access the underlying tool registry for advanced operations. + * + * This escape hatch exists for internal use (e.g. diagnostics). + * Prefer the dispatcher's own methods for normal operations. + */ + getRegistry(): PluginToolRegistry; +} + +// --------------------------------------------------------------------------- +// Factory: createPluginToolDispatcher +// --------------------------------------------------------------------------- + +/** + * Create a new `PluginToolDispatcher`. + * + * The dispatcher: + * 1. Creates and owns a `PluginToolRegistry` backed by the given worker manager. + * 2. Listens for lifecycle events (plugin.enabled, plugin.disabled, plugin.unloaded) + * to automatically register and unregister tools. + * 3. On `initialize()`, loads tools from all currently-ready plugins via the DB. + * + * @param options - Configuration options + * + * @example + * ```ts + * // At server startup + * const dispatcher = createPluginToolDispatcher({ + * workerManager, + * lifecycleManager, + * db, + * }); + * await dispatcher.initialize(); + * + * // In agent service — list tools for prompt construction + * const tools = dispatcher.listToolsForAgent(); + * + * // In agent service — execute a tool + * const result = await dispatcher.executeTool( + * "acme.linear:search-issues", + * { query: "auth bug" }, + * { agentId: "a-1", runId: "r-1", companyId: "c-1", projectId: "p-1" }, + * ); + * ``` + */ +export function createPluginToolDispatcher( + options: PluginToolDispatcherOptions = {}, +): PluginToolDispatcher { + const { workerManager, lifecycleManager, db } = options; + const log = logger.child({ service: "plugin-tool-dispatcher" }); + + // Create the underlying tool registry, backed by the worker manager + const registry = createPluginToolRegistry(workerManager); + + // Track lifecycle event listeners so we can remove them on teardown + let enabledListener: ((payload: { pluginId: string; pluginKey: string }) => void) | null = null; + let disabledListener: ((payload: { pluginId: string; pluginKey: string; reason?: string }) => void) | null = null; + let unloadedListener: ((payload: { pluginId: string; pluginKey: string; removeData: boolean }) => void) | null = null; + + let initialized = false; + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /** + * Attempt to register tools for a plugin by looking up its manifest + * from the DB. No-ops gracefully if the plugin or manifest is missing. + */ + async function registerFromDb(pluginId: string): Promise { + if (!db) { + log.warn( + { pluginId }, + "cannot register tools from DB — no database connection configured", + ); + return; + } + + const pluginRegistry = pluginRegistryService(db); + const plugin = await pluginRegistry.getById(pluginId) as PluginRecord | null; + + if (!plugin) { + log.warn({ pluginId }, "plugin not found in registry, cannot register tools"); + return; + } + + const manifest = plugin.manifestJson; + if (!manifest) { + log.warn({ pluginId }, "plugin has no manifest, cannot register tools"); + return; + } + + registry.registerPlugin(plugin.pluginKey, manifest, plugin.id); + } + + /** + * Convert a `RegisteredTool` to an `AgentToolDescriptor`. + */ + function toAgentDescriptor(tool: RegisteredTool): AgentToolDescriptor { + return { + name: tool.namespacedName, + displayName: tool.displayName, + description: tool.description, + parametersSchema: tool.parametersSchema, + pluginId: tool.pluginDbId, + }; + } + + // ----------------------------------------------------------------------- + // Lifecycle event handlers + // ----------------------------------------------------------------------- + + function handlePluginEnabled(payload: { pluginId: string; pluginKey: string }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin enabled — registering tools"); + // Async registration from DB — we fire-and-forget since the lifecycle + // event handler must be synchronous. Any errors are logged. + void registerFromDb(payload.pluginId).catch((err) => { + log.error( + { pluginId: payload.pluginId, err: err instanceof Error ? err.message : String(err) }, + "failed to register tools after plugin enabled", + ); + }); + } + + function handlePluginDisabled(payload: { pluginId: string; pluginKey: string; reason?: string }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin disabled — unregistering tools"); + registry.unregisterPlugin(payload.pluginKey); + } + + function handlePluginUnloaded(payload: { pluginId: string; pluginKey: string; removeData: boolean }): void { + log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin unloaded — unregistering tools"); + registry.unregisterPlugin(payload.pluginKey); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + async initialize(): Promise { + if (initialized) { + log.warn("dispatcher already initialized, skipping"); + return; + } + + log.info("initializing plugin tool dispatcher"); + + // Step 1: Load tools from all currently-ready plugins + if (db) { + const pluginRegistry = pluginRegistryService(db); + const readyPlugins = await pluginRegistry.listByStatus("ready") as PluginRecord[]; + + let totalTools = 0; + for (const plugin of readyPlugins) { + const manifest = plugin.manifestJson; + if (manifest?.tools && manifest.tools.length > 0) { + registry.registerPlugin(plugin.pluginKey, manifest, plugin.id); + totalTools += manifest.tools.length; + } + } + + log.info( + { readyPlugins: readyPlugins.length, registeredTools: totalTools }, + "loaded tools from ready plugins", + ); + } + + // Step 2: Subscribe to lifecycle events for dynamic updates + if (lifecycleManager) { + enabledListener = handlePluginEnabled; + disabledListener = handlePluginDisabled; + unloadedListener = handlePluginUnloaded; + + lifecycleManager.on("plugin.enabled", enabledListener); + lifecycleManager.on("plugin.disabled", disabledListener); + lifecycleManager.on("plugin.unloaded", unloadedListener); + + log.debug("subscribed to lifecycle events"); + } else { + log.warn("no lifecycle manager provided — tools will not auto-update on plugin state changes"); + } + + initialized = true; + log.info( + { totalTools: registry.toolCount() }, + "plugin tool dispatcher initialized", + ); + }, + + teardown(): void { + if (!initialized) return; + + // Unsubscribe from lifecycle events + if (lifecycleManager) { + if (enabledListener) lifecycleManager.off("plugin.enabled", enabledListener); + if (disabledListener) lifecycleManager.off("plugin.disabled", disabledListener); + if (unloadedListener) lifecycleManager.off("plugin.unloaded", unloadedListener); + + enabledListener = null; + disabledListener = null; + unloadedListener = null; + } + + // Note: we do NOT clear the registry here because teardown may be + // called during graceful shutdown where in-flight tool calls should + // still be able to resolve their tool entries. + + initialized = false; + log.info("plugin tool dispatcher torn down"); + }, + + listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[] { + return registry.listTools(filter).map(toAgentDescriptor); + }, + + getTool(namespacedName: string): RegisteredTool | null { + return registry.getTool(namespacedName); + }, + + async executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise { + log.debug( + { + tool: namespacedName, + agentId: runContext.agentId, + runId: runContext.runId, + }, + "dispatching tool execution", + ); + + const result = await registry.executeTool( + namespacedName, + parameters, + runContext, + ); + + log.debug( + { + tool: namespacedName, + pluginId: result.pluginId, + hasContent: !!result.result.content, + hasError: !!result.result.error, + }, + "tool execution completed", + ); + + return result; + }, + + registerPluginTools( + pluginId: string, + manifest: PaperclipPluginManifestV1, + ): void { + registry.registerPlugin(pluginId, manifest); + }, + + unregisterPluginTools(pluginId: string): void { + registry.unregisterPlugin(pluginId); + }, + + toolCount(pluginId?: string): number { + return registry.toolCount(pluginId); + }, + + getRegistry(): PluginToolRegistry { + return registry; + }, + }; +} diff --git a/server/src/services/plugin-tool-registry.ts b/server/src/services/plugin-tool-registry.ts new file mode 100644 index 00000000..cde0cf27 --- /dev/null +++ b/server/src/services/plugin-tool-registry.ts @@ -0,0 +1,449 @@ +/** + * PluginToolRegistry — host-side registry for plugin-contributed agent tools. + * + * Responsibilities: + * - Store tool declarations (from plugin manifests) alongside routing metadata + * so the host can resolve namespaced tool names to the owning plugin worker. + * - Namespace tools automatically: a tool `"search-issues"` from plugin + * `"acme.linear"` is exposed to agents as `"acme.linear:search-issues"`. + * - Route `executeTool` calls to the correct plugin worker via the + * `PluginWorkerManager`. + * - Provide tool discovery queries so agents can list available tools. + * - Clean up tool registrations when a plugin is unloaded or its worker stops. + * + * The registry is an in-memory structure — tool declarations are derived from + * the plugin manifest at load time and do not need persistence. When a plugin + * worker restarts, the host re-registers its manifest tools. + * + * @see PLUGIN_SPEC.md §11 — Agent Tools + * @see PLUGIN_SPEC.md §13.10 — `executeTool` + */ + +import type { + PaperclipPluginManifestV1, + PluginToolDeclaration, +} from "@paperclipai/shared"; +import type { ToolRunContext, ToolResult, ExecuteToolParams } from "@paperclipai/plugin-sdk"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Separator between plugin ID and tool name in the namespaced tool identifier. + * + * Example: `"acme.linear:search-issues"` + */ +export const TOOL_NAMESPACE_SEPARATOR = ":"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A registered tool entry stored in the registry. + * + * Combines the manifest-level declaration with routing metadata so the host + * can resolve a namespaced tool name → plugin worker in O(1). + */ +export interface RegisteredTool { + /** The plugin key used for namespacing (e.g. `"acme.linear"`). */ + pluginId: string; + /** + * The plugin's database UUID, used for worker routing and availability + * checks. Falls back to `pluginId` when not provided (e.g. in tests + * where `id === pluginKey`). + */ + pluginDbId: string; + /** The tool's bare name (without namespace prefix). */ + name: string; + /** Fully namespaced identifier: `":"`. */ + namespacedName: string; + /** Human-readable display name. */ + displayName: string; + /** Description provided to the agent so it knows when to use this tool. */ + description: string; + /** JSON Schema describing the tool's input parameters. */ + parametersSchema: Record; +} + +/** + * Filter criteria for listing available tools. + */ +export interface ToolListFilter { + /** Only return tools owned by this plugin. */ + pluginId?: string; +} + +/** + * Result of executing a tool, extending `ToolResult` with routing metadata. + */ +export interface ToolExecutionResult { + /** The plugin that handled the tool call. */ + pluginId: string; + /** The bare tool name that was executed. */ + toolName: string; + /** The result returned by the plugin's tool handler. */ + result: ToolResult; +} + +// --------------------------------------------------------------------------- +// PluginToolRegistry interface +// --------------------------------------------------------------------------- + +/** + * The host-side tool registry — held by the host process. + * + * Created once at server startup and shared across the application. Plugins + * register their tools when their worker starts, and unregister when the + * worker stops or the plugin is uninstalled. + */ +export interface PluginToolRegistry { + /** + * Register all tools declared in a plugin's manifest. + * + * Called when a plugin worker starts and its manifest is loaded. Any + * previously registered tools for the same plugin are replaced (idempotent). + * + * @param pluginId - The plugin's unique identifier (e.g. `"acme.linear"`) + * @param manifest - The plugin manifest containing the `tools` array + * @param pluginDbId - The plugin's database UUID, used for worker routing + * and availability checks. If omitted, `pluginId` is used (backwards-compat). + */ + registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void; + + /** + * Remove all tool registrations for a plugin. + * + * Called when a plugin worker stops, crashes, or is uninstalled. + * + * @param pluginId - The plugin to clear + */ + unregisterPlugin(pluginId: string): void; + + /** + * Look up a registered tool by its namespaced name. + * + * @param namespacedName - Fully qualified name, e.g. `"acme.linear:search-issues"` + * @returns The registered tool entry, or `null` if not found + */ + getTool(namespacedName: string): RegisteredTool | null; + + /** + * Look up a registered tool by plugin ID and bare tool name. + * + * @param pluginId - The owning plugin + * @param toolName - The bare tool name (without namespace prefix) + * @returns The registered tool entry, or `null` if not found + */ + getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null; + + /** + * List all registered tools, optionally filtered. + * + * @param filter - Optional filter criteria + * @returns Array of registered tool entries + */ + listTools(filter?: ToolListFilter): RegisteredTool[]; + + /** + * Parse a namespaced tool name into plugin ID and bare tool name. + * + * @param namespacedName - e.g. `"acme.linear:search-issues"` + * @returns `{ pluginId, toolName }` or `null` if the format is invalid + */ + parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null; + + /** + * Build a namespaced tool name from a plugin ID and bare tool name. + * + * @param pluginId - e.g. `"acme.linear"` + * @param toolName - e.g. `"search-issues"` + * @returns The namespaced name, e.g. `"acme.linear:search-issues"` + */ + buildNamespacedName(pluginId: string, toolName: string): string; + + /** + * Execute a tool by its namespaced name, routing to the correct plugin worker. + * + * Resolves the namespaced name to the owning plugin, validates the tool + * exists, and dispatches the `executeTool` RPC call to the worker. + * + * @param namespacedName - Fully qualified tool name (e.g. `"acme.linear:search-issues"`) + * @param parameters - The parsed parameters matching the tool's schema + * @param runContext - Agent run context + * @returns The execution result with routing metadata + * @throws {Error} if the tool is not found or the worker is not running + */ + executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise; + + /** + * Get the number of registered tools, optionally scoped to a plugin. + * + * @param pluginId - If provided, count only this plugin's tools + */ + toolCount(pluginId?: string): number; +} + +// --------------------------------------------------------------------------- +// Factory: createPluginToolRegistry +// --------------------------------------------------------------------------- + +/** + * Create a new `PluginToolRegistry`. + * + * The registry is backed by two in-memory maps: + * - `byNamespace`: namespaced name → `RegisteredTool` for O(1) lookups. + * - `byPlugin`: pluginId → Set of namespaced names for efficient per-plugin ops. + * + * @param workerManager - The worker manager used to dispatch `executeTool` RPC + * calls to plugin workers. If not provided, `executeTool` will throw. + * + * @example + * ```ts + * const toolRegistry = createPluginToolRegistry(workerManager); + * + * // Register tools from a plugin manifest + * toolRegistry.registerPlugin("acme.linear", linearManifest); + * + * // List all available tools for agents + * const tools = toolRegistry.listTools(); + * // → [{ namespacedName: "acme.linear:search-issues", ... }] + * + * // Execute a tool + * const result = await toolRegistry.executeTool( + * "acme.linear:search-issues", + * { query: "auth bug" }, + * { agentId: "agent-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" }, + * ); + * ``` + */ +export function createPluginToolRegistry( + workerManager?: PluginWorkerManager, +): PluginToolRegistry { + const log = logger.child({ service: "plugin-tool-registry" }); + + // Primary index: namespaced name → tool entry + const byNamespace = new Map(); + + // Secondary index: pluginId → set of namespaced names (for bulk operations) + const byPlugin = new Map>(); + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + function buildName(pluginId: string, toolName: string): string { + return `${pluginId}${TOOL_NAMESPACE_SEPARATOR}${toolName}`; + } + + function parseName(namespacedName: string): { pluginId: string; toolName: string } | null { + const sepIndex = namespacedName.lastIndexOf(TOOL_NAMESPACE_SEPARATOR); + if (sepIndex <= 0 || sepIndex >= namespacedName.length - 1) { + return null; + } + return { + pluginId: namespacedName.slice(0, sepIndex), + toolName: namespacedName.slice(sepIndex + 1), + }; + } + + function addTool(pluginId: string, decl: PluginToolDeclaration, pluginDbId: string): void { + const namespacedName = buildName(pluginId, decl.name); + + const entry: RegisteredTool = { + pluginId, + pluginDbId, + name: decl.name, + namespacedName, + displayName: decl.displayName, + description: decl.description, + parametersSchema: decl.parametersSchema, + }; + + byNamespace.set(namespacedName, entry); + + let pluginTools = byPlugin.get(pluginId); + if (!pluginTools) { + pluginTools = new Set(); + byPlugin.set(pluginId, pluginTools); + } + pluginTools.add(namespacedName); + } + + function removePluginTools(pluginId: string): number { + const pluginTools = byPlugin.get(pluginId); + if (!pluginTools) return 0; + + const count = pluginTools.size; + for (const name of pluginTools) { + byNamespace.delete(name); + } + byPlugin.delete(pluginId); + + return count; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + return { + registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void { + const dbId = pluginDbId ?? pluginId; + + // Remove any previously registered tools for this plugin (idempotent) + const previousCount = removePluginTools(pluginId); + if (previousCount > 0) { + log.debug( + { pluginId, previousCount }, + "cleared previous tool registrations before re-registering", + ); + } + + const tools = manifest.tools ?? []; + if (tools.length === 0) { + log.debug({ pluginId }, "plugin declares no tools"); + return; + } + + for (const decl of tools) { + addTool(pluginId, decl, dbId); + } + + log.info( + { + pluginId, + toolCount: tools.length, + tools: tools.map((t) => buildName(pluginId, t.name)), + }, + `registered ${tools.length} tool(s) for plugin`, + ); + }, + + unregisterPlugin(pluginId: string): void { + const removed = removePluginTools(pluginId); + if (removed > 0) { + log.info( + { pluginId, removedCount: removed }, + `unregistered ${removed} tool(s) for plugin`, + ); + } + }, + + getTool(namespacedName: string): RegisteredTool | null { + return byNamespace.get(namespacedName) ?? null; + }, + + getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null { + const namespacedName = buildName(pluginId, toolName); + return byNamespace.get(namespacedName) ?? null; + }, + + listTools(filter?: ToolListFilter): RegisteredTool[] { + if (filter?.pluginId) { + const pluginTools = byPlugin.get(filter.pluginId); + if (!pluginTools) return []; + const result: RegisteredTool[] = []; + for (const name of pluginTools) { + const tool = byNamespace.get(name); + if (tool) result.push(tool); + } + return result; + } + + return Array.from(byNamespace.values()); + }, + + parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null { + return parseName(namespacedName); + }, + + buildNamespacedName(pluginId: string, toolName: string): string { + return buildName(pluginId, toolName); + }, + + async executeTool( + namespacedName: string, + parameters: unknown, + runContext: ToolRunContext, + ): Promise { + // 1. Resolve the namespaced name + const parsed = parseName(namespacedName); + if (!parsed) { + throw new Error( + `Invalid tool name "${namespacedName}". Expected format: "${TOOL_NAMESPACE_SEPARATOR}"`, + ); + } + + const { pluginId, toolName } = parsed; + + // 2. Verify the tool is registered + const tool = byNamespace.get(namespacedName); + if (!tool) { + throw new Error( + `Tool "${namespacedName}" is not registered. ` + + `The plugin may not be installed or its worker may not be running.`, + ); + } + + // 3. Verify the worker manager is available + if (!workerManager) { + throw new Error( + `Cannot execute tool "${namespacedName}" — no worker manager configured. ` + + `Tool execution requires a PluginWorkerManager.`, + ); + } + + // 4. Verify the plugin worker is running (use DB UUID for worker lookup) + const dbId = tool.pluginDbId; + if (!workerManager.isRunning(dbId)) { + throw new Error( + `Cannot execute tool "${namespacedName}" — ` + + `worker for plugin "${pluginId}" is not running.`, + ); + } + + // 5. Dispatch the executeTool RPC call to the worker + log.debug( + { pluginId, pluginDbId: dbId, toolName, namespacedName, agentId: runContext.agentId, runId: runContext.runId }, + "executing tool via plugin worker", + ); + + const rpcParams: ExecuteToolParams = { + toolName, + parameters, + runContext, + }; + + const result = await workerManager.call(dbId, "executeTool", rpcParams); + + log.debug( + { + pluginId, + toolName, + namespacedName, + hasContent: !!result.content, + hasData: result.data !== undefined, + hasError: !!result.error, + }, + "tool execution completed", + ); + + return { pluginId, toolName, result }; + }, + + toolCount(pluginId?: string): number { + if (pluginId !== undefined) { + return byPlugin.get(pluginId)?.size ?? 0; + } + return byNamespace.size; + }, + }; +} diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts new file mode 100644 index 00000000..b55ba1bc --- /dev/null +++ b/server/src/services/plugin-worker-manager.ts @@ -0,0 +1,1342 @@ +/** + * PluginWorkerManager — spawns and manages out-of-process plugin worker child + * processes, routes JSON-RPC 2.0 calls over stdio, and handles lifecycle + * management including crash recovery with exponential backoff. + * + * Each installed plugin gets one dedicated worker process. The host sends + * JSON-RPC requests over the child's stdin and reads responses from stdout. + * Worker stderr is captured and forwarded to the host logger. + * + * Process Model (from PLUGIN_SPEC.md §12): + * - One worker process per installed plugin + * - Failure isolation: plugin crashes do not affect the host + * - Graceful shutdown: 10-second drain, then SIGTERM, then SIGKILL + * - Automatic restart with exponential backoff on unexpected exits + * + * @see PLUGIN_SPEC.md §12 — Process Model + * @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy + * @see PLUGIN_SPEC.md §13 — Host-Worker Protocol + */ + +import { fork, type ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createInterface, type Interface as ReadlineInterface } from "node:readline"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + JSONRPC_VERSION, + JSONRPC_ERROR_CODES, + PLUGIN_RPC_ERROR_CODES, + createRequest, + createErrorResponse, + parseMessage, + serializeMessage, + isJsonRpcResponse, + isJsonRpcRequest, + isJsonRpcNotification, + isJsonRpcSuccessResponse, + JsonRpcParseError, + JsonRpcCallError, +} from "@paperclipai/plugin-sdk"; +import type { + JsonRpcId, + JsonRpcResponse, + JsonRpcRequest, + JsonRpcNotification, + HostToWorkerMethodName, + HostToWorkerMethods, + WorkerToHostMethodName, + WorkerToHostMethods, + InitializeParams, +} from "@paperclipai/plugin-sdk"; +import { logger } from "../middleware/logger.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default timeout for RPC calls in milliseconds. */ +const DEFAULT_RPC_TIMEOUT_MS = 30_000; + +/** Hard upper bound for any RPC timeout (5 minutes). Prevents unbounded waits. */ +const MAX_RPC_TIMEOUT_MS = 5 * 60 * 1_000; + +/** Timeout for the initialize RPC call. */ +const INITIALIZE_TIMEOUT_MS = 15_000; + +/** Timeout for the shutdown RPC call before escalating to SIGTERM. */ +const SHUTDOWN_DRAIN_MS = 10_000; + +/** Time to wait after SIGTERM before sending SIGKILL. */ +const SIGTERM_GRACE_MS = 5_000; + +/** Minimum backoff delay for crash recovery (1 second). */ +const MIN_BACKOFF_MS = 1_000; + +/** Maximum backoff delay for crash recovery (5 minutes). */ +const MAX_BACKOFF_MS = 5 * 60 * 1_000; + +/** Backoff multiplier on each consecutive crash. */ +const BACKOFF_MULTIPLIER = 2; + +/** Maximum number of consecutive crashes before giving up on auto-restart. */ +const MAX_CONSECUTIVE_CRASHES = 10; + +/** Time window in which crashes are considered consecutive (10 minutes). */ +const CRASH_WINDOW_MS = 10 * 60 * 1_000; + +/** Maximum number of stderr characters retained for worker failure context. */ +const MAX_STDERR_EXCERPT_CHARS = 8_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Status of a managed worker process. + */ +export type WorkerStatus = + | "stopped" + | "starting" + | "running" + | "stopping" + | "crashed" + | "backoff"; + +/** + * Worker-to-host method handler. The host registers these to service calls + * that the plugin worker makes back to the host (e.g. state.get, events.emit). + */ +export type WorkerToHostHandler = ( + params: WorkerToHostMethods[M][0], +) => Promise; + +/** + * A map of all worker-to-host method handlers provided by the host. + */ +export type WorkerToHostHandlers = { + [M in WorkerToHostMethodName]?: WorkerToHostHandler; +}; + +/** + * Events emitted by a PluginWorkerHandle. + */ +export interface WorkerHandleEvents { + /** Worker process started and is ready (initialize succeeded). */ + "ready": { pluginId: string }; + /** Worker process exited. */ + "exit": { pluginId: string; code: number | null; signal: NodeJS.Signals | null }; + /** Worker process crashed unexpectedly. */ + "crash": { pluginId: string; code: number | null; signal: NodeJS.Signals | null; willRestart: boolean }; + /** Worker process errored (e.g. spawn failure). */ + "error": { pluginId: string; error: Error }; + /** Worker status changed. */ + "status": { pluginId: string; status: WorkerStatus; previousStatus: WorkerStatus }; +} + +type WorkerHandleEventName = keyof WorkerHandleEvents; + +export function appendStderrExcerpt(current: string, chunk: string): string { + const next = current ? `${current}\n${chunk}` : chunk; + return next.length <= MAX_STDERR_EXCERPT_CHARS + ? next + : next.slice(-MAX_STDERR_EXCERPT_CHARS); +} + +export function formatWorkerFailureMessage(message: string, stderrExcerpt: string): string { + const excerpt = stderrExcerpt.trim(); + if (!excerpt) return message; + if (message.includes(excerpt)) return message; + return `${message}\n\nWorker stderr:\n${excerpt}`; +} + +/** + * Options for starting a worker process. + */ +export interface WorkerStartOptions { + /** Absolute path to the plugin worker entrypoint (CJS bundle). */ + entrypointPath: string; + /** Plugin manifest. */ + manifest: PaperclipPluginManifestV1; + /** Resolved plugin configuration. */ + config: Record; + /** Host instance information for the initialize call. */ + instanceInfo: { + instanceId: string; + hostVersion: string; + }; + /** Host API version. */ + apiVersion: number; + /** Handlers for worker→host RPC calls. */ + hostHandlers: WorkerToHostHandlers; + /** Default timeout for RPC calls (ms). Defaults to 30s. */ + rpcTimeoutMs?: number; + /** Whether to auto-restart on crash. Defaults to true. */ + autoRestart?: boolean; + /** Node.js execArgv passed to the child process. */ + execArgv?: string[]; + /** Environment variables passed to the child process. */ + env?: Record; + /** + * Callback for stream notifications from the worker (streams.open/emit/close). + * The host wires this to the PluginStreamBus to fan out events to SSE clients. + */ + onStreamNotification?: (method: string, params: Record) => void; +} + +/** + * A pending RPC call waiting for a response from the worker. + */ +interface PendingRequest { + /** The request ID. */ + id: JsonRpcId; + /** Method name (for logging). */ + method: string; + /** Resolve the promise with the response. */ + resolve: (response: JsonRpcResponse) => void; + /** Timeout timer handle. */ + timer: ReturnType; + /** Timestamp when the request was sent. */ + sentAt: number; +} + +// --------------------------------------------------------------------------- +// PluginWorkerHandle — manages a single worker process +// --------------------------------------------------------------------------- + +/** + * Handle for a single plugin worker process. + * + * Callers use `start()` to spawn the worker, `call()` to send RPC requests, + * and `stop()` to gracefully shut down. The handle manages crash recovery + * with exponential backoff automatically when `autoRestart` is enabled. + */ +export interface PluginWorkerHandle { + /** The plugin ID this worker serves. */ + readonly pluginId: string; + + /** Current worker status. */ + readonly status: WorkerStatus; + + /** Start the worker process. Resolves when initialize completes. */ + start(): Promise; + + /** + * Stop the worker process gracefully. + * + * Sends a `shutdown` RPC call, waits up to 10 seconds for the worker to + * exit, then escalates to SIGTERM, and finally SIGKILL if needed. + */ + stop(): Promise; + + /** + * Restart the worker process (stop + start). + */ + restart(): Promise; + + /** + * Send a typed host→worker RPC call. + * + * @param method - The RPC method name + * @param params - Method parameters + * @param timeoutMs - Optional per-call timeout override + * @returns The method result + * @throws {JsonRpcCallError} if the worker returns an error response + * @throws {Error} if the worker is not running or the call times out + */ + call( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise; + + /** + * Send a fire-and-forget notification to the worker (no response expected). + */ + notify(method: string, params: unknown): void; + + /** Subscribe to worker events. */ + on( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ): void; + + /** Unsubscribe from worker events. */ + off( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ): void; + + /** Optional methods the worker reported during initialization. */ + readonly supportedMethods: string[]; + + /** Get diagnostic info about the worker. */ + diagnostics(): WorkerDiagnostics; +} + +/** + * Diagnostic information about a worker process. + */ +export interface WorkerDiagnostics { + pluginId: string; + status: WorkerStatus; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; +} + +// --------------------------------------------------------------------------- +// PluginWorkerManager — manages all plugin workers +// --------------------------------------------------------------------------- + +/** + * The top-level manager that holds all plugin worker handles. + * + * Provides a registry of workers keyed by plugin ID, with convenience methods + * for starting/stopping all workers and routing RPC calls. + */ +export interface PluginWorkerManager { + /** + * Register and start a worker for a plugin. + * + * @returns The worker handle + * @throws if a worker is already registered for this plugin + */ + startWorker(pluginId: string, options: WorkerStartOptions): Promise; + + /** + * Stop and unregister a specific plugin worker. + */ + stopWorker(pluginId: string): Promise; + + /** + * Get the worker handle for a plugin. + */ + getWorker(pluginId: string): PluginWorkerHandle | undefined; + + /** + * Check if a worker is registered and running for a plugin. + */ + isRunning(pluginId: string): boolean; + + /** + * Stop all managed workers. Called during server shutdown. + */ + stopAll(): Promise; + + /** + * Get diagnostic info for all workers. + */ + diagnostics(): WorkerDiagnostics[]; + + /** + * Send an RPC call to a specific plugin worker. + * + * @throws if the worker is not running + */ + call( + pluginId: string, + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise; +} + +// --------------------------------------------------------------------------- +// Implementation: createPluginWorkerHandle +// --------------------------------------------------------------------------- + +/** + * Create a handle for a single plugin worker process. + * + * @internal Exported for testing; consumers should use `createPluginWorkerManager`. + */ +export function createPluginWorkerHandle( + pluginId: string, + options: WorkerStartOptions, +): PluginWorkerHandle { + const log = logger.child({ service: "plugin-worker", pluginId }); + const emitter = new EventEmitter(); + /** + * Higher than default (10) to accommodate multiple subscribers to + * crash/ready/exit events during integration tests and runtime monitoring. + */ + emitter.setMaxListeners(50); + + // Worker process state + let childProcess: ChildProcess | null = null; + let readline: ReadlineInterface | null = null; + let stderrReadline: ReadlineInterface | null = null; + let status: WorkerStatus = "stopped"; + let startedAt: number | null = null; + let stderrExcerpt = ""; + + // Pending RPC requests awaiting a response + const pendingRequests = new Map(); + let nextRequestId = 1; + + // Optional methods reported by the worker during initialization + let supportedMethods: string[] = []; + + // Crash tracking for exponential backoff + let consecutiveCrashes = 0; + let totalCrashes = 0; + let lastCrashAt: number | null = null; + let backoffTimer: ReturnType | null = null; + let nextRestartAt: number | null = null; + + // Track open stream channels so we can emit synthetic close on crash. + // Maps channel → companyId. + const openStreamChannels = new Map(); + + // Shutdown coordination + let intentionalStop = false; + + const rpcTimeoutMs = options.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + const autoRestart = options.autoRestart ?? true; + + // ----------------------------------------------------------------------- + // Status management + // ----------------------------------------------------------------------- + + function setStatus(newStatus: WorkerStatus): void { + const prev = status; + if (prev === newStatus) return; + status = newStatus; + log.debug({ from: prev, to: newStatus }, "worker status change"); + emitter.emit("status", { pluginId, status: newStatus, previousStatus: prev }); + } + + // ----------------------------------------------------------------------- + // JSON-RPC message sending + // ----------------------------------------------------------------------- + + function sendMessage(message: unknown): void { + if (!childProcess?.stdin?.writable) { + throw new Error(`Worker process for plugin "${pluginId}" is not writable`); + } + const serialized = serializeMessage(message as any); + childProcess.stdin.write(serialized); + } + + // ----------------------------------------------------------------------- + // Incoming message handling + // ----------------------------------------------------------------------- + + function handleLine(line: string): void { + if (!line.trim()) return; + + let message: unknown; + try { + message = parseMessage(line); + } catch (err) { + if (err instanceof JsonRpcParseError) { + log.warn({ rawLine: line.slice(0, 200) }, "unparseable message from worker"); + } else { + log.warn({ err }, "error parsing worker message"); + } + return; + } + + if (isJsonRpcResponse(message)) { + handleResponse(message); + } else if (isJsonRpcRequest(message)) { + handleWorkerRequest(message as JsonRpcRequest); + } else if (isJsonRpcNotification(message)) { + handleWorkerNotification(message as JsonRpcNotification); + } else { + log.warn("unknown message type from worker"); + } + } + + /** + * Handle a JSON-RPC response from the worker (matching a pending request). + */ + function handleResponse(response: JsonRpcResponse): void { + const id = response.id; + if (id === null || id === undefined) { + log.warn("received response with null/undefined id"); + return; + } + + const pending = pendingRequests.get(id); + if (!pending) { + log.warn({ id }, "received response for unknown request id"); + return; + } + + clearTimeout(pending.timer); + pendingRequests.delete(id); + pending.resolve(response); + } + + /** + * Handle a JSON-RPC request from the worker (worker→host call). + */ + async function handleWorkerRequest(request: JsonRpcRequest): Promise { + const method = request.method as WorkerToHostMethodName; + const handler = options.hostHandlers[method] as + | ((params: unknown) => Promise) + | undefined; + + if (!handler) { + log.warn({ method }, "worker called unregistered host method"); + try { + sendMessage( + createErrorResponse( + request.id, + JSONRPC_ERROR_CODES.METHOD_NOT_FOUND, + `Host does not handle method "${method}"`, + ), + ); + } catch { + // Worker may have exited, ignore send error + } + return; + } + + try { + const result = await handler(request.params); + sendMessage({ + jsonrpc: JSONRPC_VERSION, + id: request.id, + result: result ?? null, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error({ method, err: errorMessage }, "host handler error"); + try { + sendMessage( + createErrorResponse( + request.id, + JSONRPC_ERROR_CODES.INTERNAL_ERROR, + errorMessage, + ), + ); + } catch { + // Worker may have exited, ignore send error + } + } + } + + /** + * Handle a JSON-RPC notification from the worker (fire-and-forget). + * + * The `log` notification is the primary case — worker `ctx.logger` calls + * arrive here. We append structured plugin context (pluginId, timestamp, + * level) so that every log entry is queryable per the spec (§26.1). + */ + function handleWorkerNotification(notification: JsonRpcNotification): void { + if (notification.method === "log") { + const params = notification.params as { + level?: string; + message?: string; + meta?: Record; + } | null; + const level = params?.level ?? "info"; + const msg = params?.message ?? ""; + const meta = params?.meta; + + // Build a structured log object that includes the plugin context fields + // required by §26.1: pluginId, timestamp, level, message, and metadata. + // The child logger already carries `pluginId` in its bindings, but we + // add explicit `pluginLogLevel` and `pluginTimestamp` so downstream + // consumers (log storage, UI queries) can filter without parsing. + const logFields: Record = { + ...meta, + pluginLogLevel: level, + pluginTimestamp: new Date().toISOString(), + }; + + if (level === "error") { + log.error(logFields, `[plugin] ${msg}`); + } else if (level === "warn") { + log.warn(logFields, `[plugin] ${msg}`); + } else if (level === "debug") { + log.debug(logFields, `[plugin] ${msg}`); + } else { + log.info(logFields, `[plugin] ${msg}`); + } + return; + } + + // Stream notifications: forward to the stream bus via callback + if ( + notification.method === "streams.open" || + notification.method === "streams.emit" || + notification.method === "streams.close" + ) { + const params = (notification.params ?? {}) as Record; + + // Track open channels so we can emit synthetic close on crash + if (notification.method === "streams.open") { + const ch = String(params.channel ?? ""); + const co = String(params.companyId ?? ""); + if (ch) openStreamChannels.set(ch, co); + } else if (notification.method === "streams.close") { + openStreamChannels.delete(String(params.channel ?? "")); + } + + if (options.onStreamNotification) { + try { + options.onStreamNotification(notification.method, params); + } catch (err) { + log.error( + { + method: notification.method, + err: err instanceof Error ? err.message : String(err), + }, + "stream notification handler failed", + ); + } + } + return; + } + + log.debug({ method: notification.method }, "received notification from worker"); + } + + // ----------------------------------------------------------------------- + // Process lifecycle + // ----------------------------------------------------------------------- + + function spawnProcess(): ChildProcess { + // Security: Do NOT spread process.env into the worker. Plugins should only + // receive a minimal, controlled environment to prevent leaking host + // secrets (like DATABASE_URL, internal API keys, etc.). + const workerEnv: Record = { + ...options.env, + PATH: process.env.PATH ?? "", + NODE_PATH: process.env.NODE_PATH ?? "", + PAPERCLIP_PLUGIN_ID: pluginId, + NODE_ENV: process.env.NODE_ENV ?? "production", + TZ: process.env.TZ ?? "UTC", + }; + + const child = fork(options.entrypointPath, [], { + stdio: ["pipe", "pipe", "pipe", "ipc"], + execArgv: options.execArgv ?? [], + env: workerEnv, + // Don't let the child keep the parent alive + detached: false, + }); + + return child; + } + + function attachStdioHandlers(child: ChildProcess): void { + // Read NDJSON from stdout + if (child.stdout) { + readline = createInterface({ input: child.stdout }); + readline.on("line", handleLine); + } + + // Capture stderr for logging + if (child.stderr) { + stderrReadline = createInterface({ input: child.stderr }); + stderrReadline.on("line", (line: string) => { + stderrExcerpt = appendStderrExcerpt(stderrExcerpt, line); + log.warn({ stream: "stderr" }, `[plugin stderr] ${line}`); + }); + } + + // Handle process exit + child.on("exit", (code, signal) => { + handleProcessExit(code, signal); + }); + + // Handle process errors (e.g. spawn failure) + child.on("error", (err) => { + log.error({ err: err.message }, "worker process error"); + emitter.emit("error", { pluginId, error: err }); + if (status === "starting") { + setStatus("crashed"); + rejectAllPending( + new Error(formatWorkerFailureMessage( + `Worker process failed to start: ${err.message}`, + stderrExcerpt, + )), + ); + } + }); + } + + function handleProcessExit( + code: number | null, + signal: NodeJS.Signals | null, + ): void { + const wasIntentional = intentionalStop; + + // Clean up readline interfaces + if (readline) { + readline.close(); + readline = null; + } + if (stderrReadline) { + stderrReadline.close(); + stderrReadline = null; + } + childProcess = null; + startedAt = null; + + // Reject all pending requests + rejectAllPending( + new Error(formatWorkerFailureMessage( + `Worker process exited (code=${code}, signal=${signal})`, + stderrExcerpt, + )), + ); + + // Emit synthetic close for any orphaned stream channels so SSE clients + // are notified instead of hanging indefinitely. + if (openStreamChannels.size > 0 && options.onStreamNotification) { + for (const [channel, companyId] of openStreamChannels) { + try { + options.onStreamNotification("streams.close", { channel, companyId }); + } catch { + // Best-effort cleanup — don't let it interfere with exit handling + } + } + openStreamChannels.clear(); + } + + emitter.emit("exit", { pluginId, code, signal }); + + if (wasIntentional) { + // Graceful stop — status is already "stopping" or will be set to "stopped" + setStatus("stopped"); + log.info({ code, signal }, "worker process stopped"); + return; + } + + // Unexpected exit — crash recovery + totalCrashes++; + const now = Date.now(); + + // Reset consecutive crash counter if enough time passed + if (lastCrashAt !== null && now - lastCrashAt > CRASH_WINDOW_MS) { + consecutiveCrashes = 0; + } + consecutiveCrashes++; + lastCrashAt = now; + + log.error( + { code, signal, consecutiveCrashes, totalCrashes }, + "worker process crashed", + ); + + const willRestart = + autoRestart && consecutiveCrashes <= MAX_CONSECUTIVE_CRASHES; + + setStatus("crashed"); + emitter.emit("crash", { pluginId, code, signal, willRestart }); + + if (willRestart) { + scheduleRestart(); + } else { + log.error( + { consecutiveCrashes, maxCrashes: MAX_CONSECUTIVE_CRASHES }, + "max consecutive crashes reached, not restarting", + ); + } + } + + function rejectAllPending(error: Error): void { + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.resolve( + createErrorResponse( + pending.id, + PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE, + error.message, + ) as JsonRpcResponse, + ); + } + pendingRequests.clear(); + } + + // ----------------------------------------------------------------------- + // Crash recovery with exponential backoff + // ----------------------------------------------------------------------- + + function computeBackoffMs(): number { + // Exponential backoff: MIN_BACKOFF * MULTIPLIER^(consecutiveCrashes - 1) + const delay = + MIN_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, consecutiveCrashes - 1); + // Add jitter: ±25% + const jitter = delay * 0.25 * (Math.random() * 2 - 1); + return Math.min(Math.round(delay + jitter), MAX_BACKOFF_MS); + } + + function scheduleRestart(): void { + const delay = computeBackoffMs(); + nextRestartAt = Date.now() + delay; + + setStatus("backoff"); + + log.info( + { delayMs: delay, consecutiveCrashes }, + "scheduling restart with backoff", + ); + + backoffTimer = setTimeout(async () => { + backoffTimer = null; + nextRestartAt = null; + try { + await startInternal(); + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + "restart after backoff failed", + ); + } + }, delay); + } + + function cancelPendingRestart(): void { + if (backoffTimer !== null) { + clearTimeout(backoffTimer); + backoffTimer = null; + nextRestartAt = null; + } + } + + // ----------------------------------------------------------------------- + // Start / Stop + // ----------------------------------------------------------------------- + + async function startInternal(): Promise { + if (status === "running" || status === "starting") { + throw new Error(`Worker for plugin "${pluginId}" is already ${status}`); + } + + intentionalStop = false; + setStatus("starting"); + stderrExcerpt = ""; + + const child = spawnProcess(); + childProcess = child; + attachStdioHandlers(child); + startedAt = Date.now(); + + // Send the initialize RPC call + const initParams: InitializeParams = { + manifest: options.manifest, + config: options.config, + instanceInfo: options.instanceInfo, + apiVersion: options.apiVersion, + }; + + try { + const result = await callInternal( + "initialize", + initParams, + INITIALIZE_TIMEOUT_MS, + ) as { ok?: boolean; supportedMethods?: string[] } | undefined; + if (!result || !result.ok) { + throw new Error("Worker initialize returned ok=false"); + } + supportedMethods = result.supportedMethods ?? []; + } catch (err) { + // Initialize failed — kill the process and propagate + const msg = err instanceof Error ? err.message : String(err); + log.error({ err: msg }, "worker initialize failed"); + await killProcess(); + setStatus("crashed"); + throw new Error(`Worker initialize failed for "${pluginId}": ${msg}`); + } + + // Reset crash counter on successful start + consecutiveCrashes = 0; + setStatus("running"); + emitter.emit("ready", { pluginId }); + log.info({ pid: child.pid }, "worker process started and initialized"); + } + + async function stopInternal(): Promise { + cancelPendingRestart(); + + if (status === "stopped" || status === "stopping") { + return; + } + + intentionalStop = true; + setStatus("stopping"); + + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 1: Send shutdown RPC and wait for the worker to exit gracefully. + // We race the shutdown call against a timeout. The worker should process + // the shutdown and exit on its own within the drain period. + try { + await Promise.race([ + callInternal("shutdown", {} as Record, SHUTDOWN_DRAIN_MS), + waitForExit(SHUTDOWN_DRAIN_MS), + ]); + } catch { + // Shutdown call failed or timed out — proceed to kill + log.warn("shutdown RPC failed or timed out, escalating to SIGTERM"); + } + + // Give the process a brief moment to exit after the shutdown response + if (childProcess) { + await waitForExit(500); + } + + // Check if process already exited + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 2: Send SIGTERM and wait + log.info("worker did not exit after shutdown RPC, sending SIGTERM"); + await killWithSignal("SIGTERM", SIGTERM_GRACE_MS); + + if (!childProcess) { + setStatus("stopped"); + return; + } + + // Step 3: Forcefully kill with SIGKILL + log.warn("worker did not exit after SIGTERM, sending SIGKILL"); + await killWithSignal("SIGKILL", 2_000); + + if (childProcess) { + log.error("worker process still alive after SIGKILL — this should not happen"); + } + + setStatus("stopped"); + } + + /** + * Wait for the child process to exit, up to `timeoutMs`. + * Resolves immediately if the process is already gone. + */ + function waitForExit(timeoutMs: number): Promise { + return new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(); + }, timeoutMs); + + childProcess.once("exit", () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(); + }); + }); + } + + function killWithSignal( + signal: NodeJS.Signals, + waitMs: number, + ): Promise { + return new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + + const timer = setTimeout(() => { + resolve(); + }, waitMs); + + childProcess.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + + try { + childProcess.kill(signal); + } catch { + clearTimeout(timer); + resolve(); + } + }); + } + + async function killProcess(): Promise { + if (!childProcess) return; + intentionalStop = true; + try { + childProcess.kill("SIGKILL"); + } catch { + // Process may already be dead + } + // Wait briefly for exit event + await new Promise((resolve) => { + if (!childProcess) { + resolve(); + return; + } + const timer = setTimeout(() => { + resolve(); + }, 1_000); + childProcess.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + // ----------------------------------------------------------------------- + // RPC call implementation + // ----------------------------------------------------------------------- + + function callInternal( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + return new Promise((resolve, reject) => { + if (!childProcess?.stdin?.writable) { + reject( + new Error( + `Cannot call "${method}" — worker for "${pluginId}" is not running`, + ), + ); + return; + } + + const id = nextRequestId++; + const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS); + + // Guard against double-settlement. When a process exits all pending + // requests are rejected via rejectAllPending(), but the timeout timer + // may still be running. Without this guard the timer's reject fires on + // an already-settled promise, producing an unhandled rejection. + let settled = false; + + const settle = (fn: (value: T) => void, value: T): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + pendingRequests.delete(id); + fn(value); + }; + + const timer = setTimeout(() => { + settle( + reject, + new JsonRpcCallError({ + code: PLUGIN_RPC_ERROR_CODES.TIMEOUT, + message: `RPC call "${method}" timed out after ${timeout}ms`, + }), + ); + }, timeout); + + const pending: PendingRequest = { + id, + method, + resolve: (response: JsonRpcResponse) => { + if (isJsonRpcSuccessResponse(response)) { + settle(resolve, response.result as HostToWorkerMethods[M][1]); + } else if ("error" in response && response.error) { + settle(reject, new JsonRpcCallError(response.error)); + } else { + settle(reject, new Error(`Unexpected response format for "${method}"`)); + } + }, + timer, + sentAt: Date.now(), + }; + + pendingRequests.set(id, pending); + + try { + const request = createRequest(method, params, id); + sendMessage(request); + } catch (err) { + clearTimeout(timer); + pendingRequests.delete(id); + reject( + new Error( + `Failed to send "${method}" to worker: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + }); + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + const handle: PluginWorkerHandle = { + get pluginId() { + return pluginId; + }, + + get status() { + return status; + }, + + get supportedMethods() { + return supportedMethods; + }, + + async start() { + await startInternal(); + }, + + async stop() { + await stopInternal(); + }, + + async restart() { + await stopInternal(); + await startInternal(); + }, + + call( + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + if (status !== "running" && status !== "starting") { + return Promise.reject( + new Error( + `Cannot call "${method}" — worker for "${pluginId}" is ${status}`, + ), + ); + } + return callInternal(method, params, timeoutMs); + }, + + notify(method: string, params: unknown) { + if (status !== "running") return; + try { + sendMessage({ + jsonrpc: JSONRPC_VERSION, + method, + params, + }); + } catch { + log.warn({ method }, "failed to send notification to worker"); + } + }, + + on( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ) { + emitter.on(event, listener); + }, + + off( + event: K, + listener: (payload: WorkerHandleEvents[K]) => void, + ) { + emitter.off(event, listener); + }, + + diagnostics(): WorkerDiagnostics { + return { + pluginId, + status, + pid: childProcess?.pid ?? null, + uptime: + startedAt !== null && status === "running" + ? Date.now() - startedAt + : null, + consecutiveCrashes, + totalCrashes, + pendingRequests: pendingRequests.size, + lastCrashAt, + nextRestartAt, + }; + }, + }; + + return handle; +} + +// --------------------------------------------------------------------------- +// Implementation: createPluginWorkerManager +// --------------------------------------------------------------------------- + +/** + * Options for creating a PluginWorkerManager. + */ +export interface PluginWorkerManagerOptions { + /** + * Optional callback invoked when a worker emits a lifecycle event + * (crash, restart). Used by the server to publish global live events. + */ + onWorkerEvent?: (event: { + type: "plugin.worker.crashed" | "plugin.worker.restarted"; + pluginId: string; + code?: number | null; + signal?: string | null; + willRestart?: boolean; + }) => void; +} + +/** + * Create a new PluginWorkerManager. + * + * The manager holds all plugin worker handles and provides a unified API for + * starting, stopping, and communicating with plugin workers. + * + * @example + * ```ts + * const manager = createPluginWorkerManager(); + * + * const handle = await manager.startWorker("acme.linear", { + * entrypointPath: "/path/to/worker.cjs", + * manifest, + * config: resolvedConfig, + * instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" }, + * apiVersion: 1, + * hostHandlers: { "config.get": async () => resolvedConfig, ... }, + * }); + * + * // Send RPC call to the worker + * const health = await manager.call("acme.linear", "health", {}); + * + * // Shutdown all workers on server exit + * await manager.stopAll(); + * ``` + */ +export function createPluginWorkerManager( + managerOptions?: PluginWorkerManagerOptions, +): PluginWorkerManager { + const log = logger.child({ service: "plugin-worker-manager" }); + const workers = new Map(); + /** Per-plugin startup locks to prevent concurrent spawn races. */ + const startupLocks = new Map>(); + + return { + async startWorker( + pluginId: string, + options: WorkerStartOptions, + ): Promise { + // Mutex: if a start is already in-flight for this plugin, wait for it + const inFlight = startupLocks.get(pluginId); + if (inFlight) { + log.warn({ pluginId }, "concurrent startWorker call — waiting for in-flight start"); + return inFlight; + } + + const existing = workers.get(pluginId); + if (existing && existing.status !== "stopped") { + throw new Error( + `Worker already registered for plugin "${pluginId}" (status: ${existing.status})`, + ); + } + + const handle = createPluginWorkerHandle(pluginId, options); + workers.set(pluginId, handle); + + // Subscribe to crash/ready events for live event forwarding + if (managerOptions?.onWorkerEvent) { + const notify = managerOptions.onWorkerEvent; + handle.on("crash", (payload) => { + notify({ + type: "plugin.worker.crashed", + pluginId: payload.pluginId, + code: payload.code, + signal: payload.signal, + willRestart: payload.willRestart, + }); + }); + handle.on("ready", (payload) => { + // Only emit restarted if this was a crash recovery (totalCrashes > 0) + const diag = handle.diagnostics(); + if (diag.totalCrashes > 0) { + notify({ + type: "plugin.worker.restarted", + pluginId: payload.pluginId, + }); + } + }); + } + + log.info({ pluginId }, "starting plugin worker"); + + // Set the lock before awaiting start() to prevent concurrent spawns + const startPromise = handle.start().then(() => handle).finally(() => { + startupLocks.delete(pluginId); + }); + startupLocks.set(pluginId, startPromise); + + return startPromise; + }, + + async stopWorker(pluginId: string): Promise { + const handle = workers.get(pluginId); + if (!handle) { + log.warn({ pluginId }, "no worker registered for plugin, nothing to stop"); + return; + } + + log.info({ pluginId }, "stopping plugin worker"); + await handle.stop(); + workers.delete(pluginId); + }, + + getWorker(pluginId: string): PluginWorkerHandle | undefined { + return workers.get(pluginId); + }, + + isRunning(pluginId: string): boolean { + const handle = workers.get(pluginId); + return handle?.status === "running"; + }, + + async stopAll(): Promise { + log.info({ count: workers.size }, "stopping all plugin workers"); + const promises = Array.from(workers.values()).map(async (handle) => { + try { + await handle.stop(); + } catch (err) { + log.error( + { + pluginId: handle.pluginId, + err: err instanceof Error ? err.message : String(err), + }, + "error stopping worker during shutdown", + ); + } + }); + await Promise.all(promises); + workers.clear(); + }, + + diagnostics(): WorkerDiagnostics[] { + return Array.from(workers.values()).map((h) => h.diagnostics()); + }, + + call( + pluginId: string, + method: M, + params: HostToWorkerMethods[M][0], + timeoutMs?: number, + ): Promise { + const handle = workers.get(pluginId); + if (!handle) { + return Promise.reject( + new Error(`No worker registered for plugin "${pluginId}"`), + ); + } + return handle.call(method, params, timeoutMs); + }, + }; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1cfdd9df..a05bbcec 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -24,6 +24,9 @@ import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; import { InstanceSettings } from "./pages/InstanceSettings"; +import { PluginManager } from "./pages/PluginManager"; +import { PluginSettings } from "./pages/PluginSettings"; +import { PluginPage } from "./pages/PluginPage"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -113,6 +116,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -162,7 +166,7 @@ function InboxRootRedirect() { function LegacySettingsRedirect() { const location = useLocation(); - return ; + return ; } function OnboardingRoutePage() { @@ -295,9 +299,12 @@ export function App() { }> } /> } /> - } /> + } /> }> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index b1b4f648..1071ba8f 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -41,6 +41,8 @@ export const api = { request(path, { method: "POST", body: JSON.stringify(body) }), postForm: (path: string, body: FormData) => request(path, { method: "POST", body }), + put: (path: string, body: unknown) => + request(path, { method: "PUT", body: JSON.stringify(body) }), patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts new file mode 100644 index 00000000..f2b7d31b --- /dev/null +++ b/ui/src/api/plugins.ts @@ -0,0 +1,469 @@ +/** + * @fileoverview Frontend API client for the Paperclip plugin system. + * + * All functions in `pluginsApi` map 1:1 to REST endpoints on + * `server/src/routes/plugins.ts`. Call sites should consume these functions + * through React Query hooks (`useQuery` / `useMutation`) and reference cache + * keys from `queryKeys.plugins.*`. + * + * @see ui/src/lib/queryKeys.ts for cache key definitions. + * @see server/src/routes/plugins.ts for endpoint implementation details. + */ + +import type { + PluginLauncherDeclaration, + PluginLauncherRenderContextSnapshot, + PluginUiSlotDeclaration, + PluginRecord, + PluginConfig, + PluginStatus, + CompanyPluginAvailability, +} from "@paperclipai/shared"; +import { api } from "./client"; + +/** + * Normalized UI contribution record returned by `GET /api/plugins/ui-contributions`. + * + * Only populated for plugins in `ready` state that declare at least one UI slot + * or launcher. The `slots` array is sourced from `manifest.ui.slots`. The + * `launchers` array aggregates both legacy `manifest.launchers` and + * `manifest.ui.launchers`. + */ +export type PluginUiContribution = { + pluginId: string; + pluginKey: string; + displayName: string; + version: string; + updatedAt?: string; + /** + * Relative filename of the UI entry module within the plugin's UI directory. + * The host constructs the full import URL as + * `/_plugins/${pluginId}/ui/${uiEntryFile}`. + */ + uiEntryFile: string; + slots: PluginUiSlotDeclaration[]; + launchers: PluginLauncherDeclaration[]; +}; + +/** + * Health check result returned by `GET /api/plugins/:pluginId/health`. + * + * The `healthy` flag summarises whether all checks passed. Individual check + * results are available in `checks` for detailed diagnostics display. + */ +export interface PluginHealthCheckResult { + pluginId: string; + /** The plugin's current lifecycle status at time of check. */ + status: string; + /** True if all health checks passed. */ + healthy: boolean; + /** Individual diagnostic check results. */ + checks: Array<{ + name: string; + passed: boolean; + /** Human-readable description of a failure, if any. */ + message?: string; + }>; + /** The most recent error message if the plugin is in `error` state. */ + lastError?: string; +} + +/** + * Worker diagnostics returned as part of the dashboard response. + */ +export interface PluginWorkerDiagnostics { + status: string; + pid: number | null; + uptime: number | null; + consecutiveCrashes: number; + totalCrashes: number; + pendingRequests: number; + lastCrashAt: number | null; + nextRestartAt: number | null; +} + +/** + * A recent job run entry returned in the dashboard response. + */ +export interface PluginDashboardJobRun { + id: string; + jobId: string; + jobKey?: string; + trigger: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; +} + +/** + * A recent webhook delivery entry returned in the dashboard response. + */ +export interface PluginDashboardWebhookDelivery { + id: string; + webhookKey: string; + status: string; + durationMs: number | null; + error: string | null; + startedAt: string | null; + finishedAt: string | null; + createdAt: string; +} + +/** + * Aggregated health dashboard data returned by `GET /api/plugins/:pluginId/dashboard`. + * + * Contains worker diagnostics, recent job runs, recent webhook deliveries, + * and the current health check result — all in a single response. + */ +export interface PluginDashboardData { + pluginId: string; + /** Worker process diagnostics, or null if no worker is registered. */ + worker: PluginWorkerDiagnostics | null; + /** Recent job execution history (newest first, max 10). */ + recentJobRuns: PluginDashboardJobRun[]; + /** Recent inbound webhook deliveries (newest first, max 10). */ + recentWebhookDeliveries: PluginDashboardWebhookDelivery[]; + /** Current health check results. */ + health: PluginHealthCheckResult; + /** ISO 8601 timestamp when the dashboard data was generated. */ + checkedAt: string; +} + +export interface AvailablePluginExample { + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: "example"; +} + +/** + * Plugin management API client. + * + * All methods are thin wrappers around the `api` base client. They return + * promises that resolve to typed JSON responses or throw on HTTP errors. + * + * @example + * ```tsx + * // In a component: + * const { data: plugins } = useQuery({ + * queryKey: queryKeys.plugins.all, + * queryFn: () => pluginsApi.list(), + * }); + * ``` + */ +export const pluginsApi = { + /** + * List all installed plugins, optionally filtered by lifecycle status. + * + * @param status - Optional filter; must be a valid `PluginStatus` value. + * Invalid values are rejected by the server with HTTP 400. + */ + list: (status?: PluginStatus) => + api.get(`/plugins${status ? `?status=${status}` : ""}`), + + /** + * List bundled example plugins available from the current repo checkout. + */ + listExamples: () => + api.get("/plugins/examples"), + + /** + * Fetch a single plugin record by its UUID or plugin key. + * + * @param pluginId - The plugin's UUID (from `PluginRecord.id`) or plugin key. + */ + get: (pluginId: string) => + api.get(`/plugins/${pluginId}`), + + /** + * Install a plugin from npm or a local path. + * + * On success, the plugin is registered in the database and transitioned to + * `ready` state. The response is the newly created `PluginRecord`. + * + * @param params.packageName - npm package name (e.g. `@paperclip/plugin-linear`) + * or a filesystem path when `isLocalPath` is `true`. + * @param params.version - Target npm version tag/range (optional; defaults to latest). + * @param params.isLocalPath - Set to `true` when `packageName` is a local path. + */ + install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) => + api.post("/plugins/install", params), + + /** + * Uninstall a plugin. + * + * @param pluginId - UUID of the plugin to remove. + * @param purge - If `true`, permanently delete all plugin data (hard delete). + * Otherwise the plugin is soft-deleted with a 30-day data retention window. + */ + uninstall: (pluginId: string, purge?: boolean) => + api.delete<{ ok: boolean }>(`/plugins/${pluginId}${purge ? "?purge=true" : ""}`), + + /** + * Transition a plugin from `error` state back to `ready`. + * No-ops if the plugin is already enabled. + * + * @param pluginId - UUID of the plugin to enable. + */ + enable: (pluginId: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/enable`, {}), + + /** + * Disable a plugin (transition to `error` state with an operator sentinel). + * The plugin's worker is stopped; it will not process events until re-enabled. + * + * @param pluginId - UUID of the plugin to disable. + * @param reason - Optional human-readable reason stored in `lastError`. + */ + disable: (pluginId: string, reason?: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/disable`, reason ? { reason } : {}), + + /** + * Run health diagnostics for a plugin. + * + * Only meaningful for plugins in `ready` state. Returns the result of all + * registered health checks. Called on a 30-second polling interval by + * {@link PluginSettings}. + * + * @param pluginId - UUID of the plugin to health-check. + */ + health: (pluginId: string) => + api.get(`/plugins/${pluginId}/health`), + + /** + * Fetch aggregated health dashboard data for a plugin. + * + * Returns worker diagnostics, recent job runs, recent webhook deliveries, + * and the current health check result in a single request. Used by the + * {@link PluginSettings} page to render the runtime dashboard section. + * + * @param pluginId - UUID of the plugin. + */ + dashboard: (pluginId: string) => + api.get(`/plugins/${pluginId}/dashboard`), + + /** + * Fetch recent log entries for a plugin. + * + * @param pluginId - UUID of the plugin. + * @param options - Optional filters: limit, level, since. + */ + logs: (pluginId: string, options?: { limit?: number; level?: string; since?: string }) => { + const params = new URLSearchParams(); + if (options?.limit) params.set("limit", String(options.limit)); + if (options?.level) params.set("level", options.level); + if (options?.since) params.set("since", options.since); + const qs = params.toString(); + return api.get | null; createdAt: string }>>( + `/plugins/${pluginId}/logs${qs ? `?${qs}` : ""}`, + ); + }, + + /** + * Upgrade a plugin to a newer version. + * + * If the new version declares additional capabilities, the plugin is + * transitioned to `upgrade_pending` state awaiting operator approval. + * + * @param pluginId - UUID of the plugin to upgrade. + * @param version - Target version (optional; defaults to latest published). + */ + upgrade: (pluginId: string, version?: string) => + api.post<{ ok: boolean }>(`/plugins/${pluginId}/upgrade`, version ? { version } : {}), + + /** + * Returns normalized UI contribution declarations for ready plugins. + * Used by the slot host runtime and launcher discovery surfaces. + * + * When `companyId` is provided, the server filters out plugins that are + * disabled for that company before returning contributions. + * + * Response shape: + * - `slots`: concrete React mount declarations from `manifest.ui.slots` + * - `launchers`: host-owned entry points from `manifest.ui.launchers` plus + * the legacy top-level `manifest.launchers` + * + * @example + * ```ts + * const rows = await pluginsApi.listUiContributions(companyId); + * const toolbarLaunchers = rows.flatMap((row) => + * row.launchers.filter((launcher) => launcher.placementZone === "toolbarButton"), + * ); + * ``` + */ + listUiContributions: (companyId?: string) => + api.get( + `/plugins/ui-contributions${companyId ? `?companyId=${encodeURIComponent(companyId)}` : ""}`, + ), + + /** + * List plugin availability/settings for a specific company. + * + * @param companyId - UUID of the company. + * @param available - Optional availability filter. + */ + listForCompany: (companyId: string, available?: boolean) => + api.get( + `/companies/${companyId}/plugins${available === undefined ? "" : `?available=${available}`}`, + ), + + /** + * Fetch a single company-scoped plugin availability/settings record. + * + * @param companyId - UUID of the company. + * @param pluginId - Plugin UUID or plugin key. + */ + getForCompany: (companyId: string, pluginId: string) => + api.get(`/companies/${companyId}/plugins/${pluginId}`), + + /** + * Create, update, or clear company-scoped plugin settings. + * + * Company availability is enabled by default. This endpoint stores explicit + * overrides in `plugin_company_settings` so the selected company can be + * disabled without affecting the global plugin installation. + */ + saveForCompany: ( + companyId: string, + pluginId: string, + params: { + available: boolean; + settingsJson?: Record; + lastError?: string | null; + }, + ) => + api.put(`/companies/${companyId}/plugins/${pluginId}`, params), + + // =========================================================================== + // Plugin configuration endpoints + // =========================================================================== + + /** + * Fetch the current configuration for a plugin. + * + * Returns the `PluginConfig` record if one exists, or `null` if the plugin + * has not yet been configured. + * + * @param pluginId - UUID of the plugin. + */ + getConfig: (pluginId: string) => + api.get(`/plugins/${pluginId}/config`), + + /** + * Save (create or update) the configuration for a plugin. + * + * The server validates `configJson` against the plugin's `instanceConfigSchema` + * and returns the persisted `PluginConfig` record on success. + * + * @param pluginId - UUID of the plugin. + * @param configJson - Configuration values matching the plugin's `instanceConfigSchema`. + */ + saveConfig: (pluginId: string, configJson: Record) => + api.post(`/plugins/${pluginId}/config`, { configJson }), + + /** + * Call the plugin's `validateConfig` RPC method to test the configuration + * without persisting it. + * + * Returns `{ valid: true }` on success, or `{ valid: false, message: string }` + * when the plugin reports a validation failure. + * + * Only available when the plugin declares a `validateConfig` RPC handler. + * + * @param pluginId - UUID of the plugin. + * @param configJson - Configuration values to validate. + */ + testConfig: (pluginId: string, configJson: Record) => + api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }), + + // =========================================================================== + // Bridge proxy endpoints — used by the plugin UI bridge runtime + // =========================================================================== + + /** + * Proxy a `getData` call from a plugin UI component to its worker backend. + * + * This is the HTTP transport for `usePluginData(key, params)`. The bridge + * runtime calls this method and maps the response into `PluginDataResult`. + * + * On success, the response is `{ data: T }`. + * On failure, the response body is a `PluginBridgeError`-shaped object + * with `code`, `message`, and optional `details`. + * + * @param pluginId - UUID of the plugin whose worker should handle the request + * @param key - Plugin-defined data key (e.g. `"sync-health"`) + * @param params - Optional query parameters forwarded to the worker handler + * @param companyId - Optional company scope. When present, the server rejects + * the call with HTTP 403 if the plugin is disabled for that company. + * @param renderEnvironment - Optional launcher/page snapshot forwarded for + * launcher-backed UI so workers can distinguish modal, drawer, popover, and + * page execution. + * + * Error responses: + * - `401`/`403` when auth or company access checks fail + * - `404` when the plugin or handler key does not exist + * - `409` when the plugin is not in a callable runtime state + * - `5xx` with a `PluginBridgeError`-shaped body when the worker throws + * + * @see PLUGIN_SPEC.md §13.8 — `getData` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + bridgeGetData: ( + pluginId: string, + key: string, + params?: Record, + companyId?: string | null, + renderEnvironment?: PluginLauncherRenderContextSnapshot | null, + ) => + api.post<{ data: unknown }>(`/plugins/${pluginId}/data/${encodeURIComponent(key)}`, { + companyId: companyId ?? undefined, + params, + renderEnvironment: renderEnvironment ?? undefined, + }), + + /** + * Proxy a `performAction` call from a plugin UI component to its worker backend. + * + * This is the HTTP transport for `usePluginAction(key)`. The bridge runtime + * calls this method when the action function is invoked. + * + * On success, the response is `{ data: T }`. + * On failure, the response body is a `PluginBridgeError`-shaped object + * with `code`, `message`, and optional `details`. + * + * @param pluginId - UUID of the plugin whose worker should handle the request + * @param key - Plugin-defined action key (e.g. `"resync"`) + * @param params - Optional parameters forwarded to the worker handler + * @param companyId - Optional company scope. When present, the server rejects + * the call with HTTP 403 if the plugin is disabled for that company. + * @param renderEnvironment - Optional launcher/page snapshot forwarded for + * launcher-backed UI so workers can distinguish modal, drawer, popover, and + * page execution. + * + * Error responses: + * - `401`/`403` when auth or company access checks fail + * - `404` when the plugin or handler key does not exist + * - `409` when the plugin is not in a callable runtime state + * - `5xx` with a `PluginBridgeError`-shaped body when the worker throws + * + * @see PLUGIN_SPEC.md §13.9 — `performAction` + * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge + */ + bridgePerformAction: ( + pluginId: string, + key: string, + params?: Record, + companyId?: string | null, + renderEnvironment?: PluginLauncherRenderContextSnapshot | null, + ) => + api.post<{ data: unknown }>(`/plugins/${pluginId}/actions/${encodeURIComponent(key)}`, { + companyId: companyId ?? undefined, + params, + renderEnvironment: renderEnvironment ?? undefined, + }), +}; diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index ac933aa1..0b396ef9 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -1,4 +1,4 @@ -import { Clock3, Settings } from "lucide-react"; +import { Clock3, Puzzle, Settings } from "lucide-react"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -13,7 +13,8 @@ export function InstanceSidebar() { diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx new file mode 100644 index 00000000..e185bf4b --- /dev/null +++ b/ui/src/components/JsonSchemaForm.tsx @@ -0,0 +1,1048 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Plus, + Trash2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Threshold for string length above which a Textarea is used instead of a standard Input. + */ +const TEXTAREA_THRESHOLD = 200; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Subset of JSON Schema properties we understand for form rendering. + * We intentionally keep this loose (`Record`) at the top + * level to match the `JsonSchema` type in shared, but narrow internally. + */ +export interface JsonSchemaNode { + type?: string | string[]; + title?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + const?: unknown; + format?: string; + + // String constraints + minLength?: number; + maxLength?: number; + pattern?: string; + + // Number constraints + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + + // Object + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JsonSchemaNode; + + // Array + items?: JsonSchemaNode; + minItems?: number; + maxItems?: number; + + // Metadata + readOnly?: boolean; + writeOnly?: boolean; + + // Allow extra keys + [key: string]: unknown; +} + +export interface JsonSchemaFormProps { + /** The JSON Schema to render. */ + schema: JsonSchemaNode; + /** Current form values. */ + values: Record; + /** Called whenever any field value changes. */ + onChange: (values: Record) => void; + /** Validation errors keyed by JSON pointer path (e.g. "/apiKey"). */ + errors?: Record; + /** If true, all fields are disabled. */ + disabled?: boolean; + /** Additional CSS class for the root container. */ + className?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Resolve the primary type string from a schema node. */ +export function resolveType(schema: JsonSchemaNode): string { + if (schema.enum) return "enum"; + if (schema.const !== undefined) return "const"; + if (schema.format === "secret-ref") return "secret-ref"; + if (Array.isArray(schema.type)) { + // Use the first non-null type + return schema.type.find((t) => t !== "null") ?? "string"; + } + return schema.type ?? "string"; +} + +/** Human-readable label from schema title or property key. */ +export function labelFromKey(key: string, schema: JsonSchemaNode): string { + if (schema.title) return schema.title; + // Convert camelCase / snake_case to Title Case + return key + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Produce a sensible default value for a schema node. */ +export function getDefaultForSchema(schema: JsonSchemaNode): unknown { + if (schema.default !== undefined) return schema.default; + + const type = resolveType(schema); + switch (type) { + case "string": + case "secret-ref": + return ""; + case "number": + case "integer": + return schema.minimum ?? 0; + case "boolean": + return false; + case "enum": + return schema.enum?.[0] ?? ""; + case "array": + return []; + case "object": { + if (!schema.properties) return {}; + const obj: Record = {}; + for (const [key, propSchema] of Object.entries(schema.properties)) { + obj[key] = getDefaultForSchema(propSchema); + } + return obj; + } + default: + return ""; + } +} + +/** Validate a single field value against schema constraints. Returns error string or null. */ +export function validateField( + value: unknown, + schema: JsonSchemaNode, + isRequired: boolean, +): string | null { + const type = resolveType(schema); + + // Required check + if (isRequired && (value === undefined || value === null || value === "")) { + return "This field is required"; + } + + // Skip further validation if empty and not required + if (value === undefined || value === null || value === "") return null; + + if (type === "string" || type === "secret-ref") { + const str = String(value); + if (schema.minLength != null && str.length < schema.minLength) { + return `Must be at least ${schema.minLength} characters`; + } + if (schema.maxLength != null && str.length > schema.maxLength) { + return `Must be at most ${schema.maxLength} characters`; + } + if (schema.pattern) { + // Guard against ReDoS: reject overly complex patterns from plugin JSON Schemas. + // Limit pattern length and run the regex with a defensive try/catch. + const MAX_PATTERN_LENGTH = 512; + if (schema.pattern.length <= MAX_PATTERN_LENGTH) { + try { + const re = new RegExp(schema.pattern); + if (!re.test(str)) { + return `Must match pattern: ${schema.pattern}`; + } + } catch { + // Invalid regex in schema — skip + } + } + } + } + + if (type === "number" || type === "integer") { + const num = Number(value); + if (isNaN(num)) return "Must be a valid number"; + if (schema.minimum != null && num < schema.minimum) { + return `Must be at least ${schema.minimum}`; + } + if (schema.maximum != null && num > schema.maximum) { + return `Must be at most ${schema.maximum}`; + } + if (schema.exclusiveMinimum != null && num <= schema.exclusiveMinimum) { + return `Must be greater than ${schema.exclusiveMinimum}`; + } + if (schema.exclusiveMaximum != null && num >= schema.exclusiveMaximum) { + return `Must be less than ${schema.exclusiveMaximum}`; + } + if (type === "integer" && !Number.isInteger(num)) { + return "Must be a whole number"; + } + if (schema.multipleOf != null && num % schema.multipleOf !== 0) { + return `Must be a multiple of ${schema.multipleOf}`; + } + } + + if (type === "array") { + const arr = value as unknown[]; + if (schema.minItems != null && arr.length < schema.minItems) { + return `Must have at least ${schema.minItems} items`; + } + if (schema.maxItems != null && arr.length > schema.maxItems) { + return `Must have at most ${schema.maxItems} items`; + } + } + + return null; +} + +/** Public API for validation */ +export function validateJsonSchemaForm( + schema: JsonSchemaNode, + values: Record, + path: string[] = [], +): Record { + const errors: Record = {}; + const properties = schema.properties ?? {}; + const requiredFields = new Set(schema.required ?? []); + + for (const [key, propSchema] of Object.entries(properties)) { + const fieldPath = [...path, key]; + const errorKey = `/${fieldPath.join("/")}`; + const value = values[key]; + const isRequired = requiredFields.has(key); + const type = resolveType(propSchema); + + // Per-field validation + const fieldErr = validateField(value, propSchema, isRequired); + if (fieldErr) { + errors[errorKey] = fieldErr; + } + + // Recurse into objects + if (type === "object" && propSchema.properties && typeof value === "object" && value !== null) { + Object.assign( + errors, + validateJsonSchemaForm(propSchema, value as Record, fieldPath), + ); + } + + // Recurse into arrays + if (type === "array" && propSchema.items && Array.isArray(value)) { + const itemSchema = propSchema.items as JsonSchemaNode; + const isObjectItem = resolveType(itemSchema) === "object"; + + value.forEach((item, index) => { + const itemPath = [...fieldPath, String(index)]; + const itemErrorKey = `/${itemPath.join("/")}`; + + if (isObjectItem) { + Object.assign( + errors, + validateJsonSchemaForm( + itemSchema, + item as Record, + itemPath, + ), + ); + } else { + const itemErr = validateField(item, itemSchema, false); + if (itemErr) { + errors[itemErrorKey] = itemErr; + } + } + }); + } + } + + return errors; +} + +/** Public API for default values */ +export function getDefaultValues(schema: JsonSchemaNode): Record { + const result: Record = {}; + const properties = schema.properties ?? {}; + + for (const [key, propSchema] of Object.entries(properties)) { + const def = getDefaultForSchema(propSchema); + if (def !== undefined) { + result[key] = def; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Internal Components +// --------------------------------------------------------------------------- + +interface FieldWrapperProps { + label: string; + description?: string; + required?: boolean; + error?: string; + disabled?: boolean; + children: React.ReactNode; +} + +/** + * Common wrapper for form fields that handles labels, descriptions, and error messages. + */ +const FieldWrapper = React.memo(({ + label, + description, + required, + error, + disabled, + children, +}: FieldWrapperProps) => { + return ( +
    +
    + {label && ( + + )} +
    + {children} + {description && ( +

    + {description} +

    + )} + {error && ( +

    {error}

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

    {description}

    + )} + {error && ( +

    {error}

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