Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View File

@@ -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];

View File

@@ -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";

View File

@@ -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";

View File

@@ -0,0 +1,545 @@
import type {
PluginStatus,
PluginCategory,
PluginCapability,
PluginUiSlotType,
PluginUiSlotEntityType,
PluginStateScopeKind,
PluginLauncherPlacementZone,
PluginLauncherAction,
PluginLauncherBounds,
PluginLauncherRenderEnvironment,
} from "../constants.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder plugins declare config schemas as JSON Schema
// ---------------------------------------------------------------------------
/**
* A JSON Schema object used for plugin config schemas and tool parameter schemas.
* Plugins provide these as plain JSON Schema compatible objects.
*/
export type JsonSchema = Record<string, unknown>;
// ---------------------------------------------------------------------------
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
// ---------------------------------------------------------------------------
/**
* Declares a scheduled job a plugin can run.
*
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
*/
export interface PluginJobDeclaration {
/** Stable identifier for this job, unique within the plugin. */
jobKey: string;
/** Human-readable name shown in the operator UI. */
displayName: string;
/** Optional description of what the job does. */
description?: string;
/** Cron expression for the schedule (e.g. "star/15 star star star star" or "0 * * * *"). */
schedule?: string;
}
/**
* Declares a webhook endpoint the plugin can receive.
* Route: `POST /api/plugins/:pluginId/webhooks/:endpointKey`
*
* @see PLUGIN_SPEC.md §18 — Webhooks
*/
export interface PluginWebhookDeclaration {
/** Stable identifier for this endpoint, unique within the plugin. */
endpointKey: string;
/** Human-readable name shown in the operator UI. */
displayName: string;
/** Optional description of what this webhook handles. */
description?: string;
}
/**
* Declares an agent tool contributed by the plugin. Tools are namespaced
* by plugin ID at runtime (e.g. `linear:search-issues`).
*
* Requires the `agent.tools.register` capability.
*
* @see PLUGIN_SPEC.md §11 — Agent Tools
*/
export interface PluginToolDeclaration {
/** Tool name, unique within the plugin. Namespaced by plugin ID at runtime. */
name: string;
/** Human-readable name shown to agents and in the UI. */
displayName: string;
/** Description provided to the agent so it knows when to use this tool. */
description: string;
/** JSON Schema describing the tool's input parameters. */
parametersSchema: JsonSchema;
}
/**
* Declares a UI extension slot the plugin fills with a React component.
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
*/
export interface PluginUiSlotDeclaration {
/** The type of UI mount point (page, detailTab, taskDetailView, toolbarButton, etc.). */
type: PluginUiSlotType;
/** Unique slot identifier within the plugin. */
id: string;
/** Human-readable name shown in navigation or tab labels. */
displayName: string;
/** Which export name in the UI bundle provides this component. */
exportName: string;
/**
* Entity targets for context-sensitive slots.
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional ordering hint within a slot surface. Lower numbers appear first.
* Defaults to host-defined ordering if omitted.
*/
order?: number;
}
/**
* Describes the action triggered by a plugin launcher surface.
*/
export interface PluginLauncherActionDeclaration {
/** What kind of launch behavior the host should perform. */
type: PluginLauncherAction;
/**
* Stable target identifier or URL. The meaning depends on `type`
* (for example a route, tab key, action key, or external URL).
*/
target: string;
/** Optional arbitrary parameters passed along to the target. */
params?: Record<string, unknown>;
}
/**
* Optional render metadata for the destination opened by a launcher.
*/
export interface PluginLauncherRenderDeclaration {
/** High-level container the launcher expects the host to use. */
environment: PluginLauncherRenderEnvironment;
/** Optional size hint for the destination surface. */
bounds?: PluginLauncherBounds;
}
/**
* Serializable runtime snapshot of the host launcher/container environment.
*/
export interface PluginLauncherRenderContextSnapshot {
/** The current launcher/container environment selected by the host. */
environment: PluginLauncherRenderEnvironment | null;
/** Launcher id that opened this surface, if any. */
launcherId: string | null;
/** Current host-applied bounds hint for the environment, if any. */
bounds: PluginLauncherBounds | null;
}
/**
* Declares a plugin launcher surface independent of the low-level slot
* implementation that mounts it.
*/
export interface PluginLauncherDeclaration {
/** Stable identifier for this launcher, unique within the plugin. */
id: string;
/** Human-readable label shown for the launcher. */
displayName: string;
/** Optional description for operator-facing docs or future UI affordances. */
description?: string;
/** Where in the host UI this launcher should be placed. */
placementZone: PluginLauncherPlacementZone;
/** Optional export name in the UI bundle when the launcher has custom UI. */
exportName?: string;
/**
* Optional entity targeting for context-sensitive launcher zones.
* Reuses the same entity union as UI slots for consistency.
*/
entityTypes?: PluginUiSlotEntityType[];
/** Optional ordering hint within the placement zone. */
order?: number;
/** What should happen when the launcher is activated. */
action: PluginLauncherActionDeclaration;
/** Optional render/container hints for the launched destination. */
render?: PluginLauncherRenderDeclaration;
}
/**
* Lower-bound semver requirement for the Paperclip host.
*
* The host should reject installation when its running version is lower than
* the declared minimum.
*/
export type PluginMinimumHostVersion = string;
/**
* Groups plugin UI declarations that are served from the shared UI bundle
* root declared in `entrypoints.ui`.
*/
export interface PluginUiDeclaration {
/** UI extension slots this plugin fills. */
slots?: PluginUiSlotDeclaration[];
/** Declarative launcher metadata for host-mounted plugin entry points. */
launchers?: PluginLauncherDeclaration[];
}
// ---------------------------------------------------------------------------
// Plugin Manifest V1
// ---------------------------------------------------------------------------
/**
* The manifest shape every plugin package must export.
* See PLUGIN_SPEC.md §10.1 for the normative definition.
*/
export interface PaperclipPluginManifestV1 {
/** Globally unique plugin identifier (e.g. `"acme.linear-sync"`). Must be lowercase alphanumeric with dots, hyphens, or underscores. */
id: string;
/** Plugin API version. Must be `1` for the current spec. */
apiVersion: 1;
/** Semver version of the plugin package (e.g. `"1.2.0"`). */
version: string;
/** Human-readable name (max 100 chars). */
displayName: string;
/** Short description (max 500 chars). */
description: string;
/** Author name (max 200 chars). May include email in angle brackets, e.g. `"Jane Doe <jane@example.com>"`. */
author: string;
/** One or more categories classifying this plugin. */
categories: PluginCategory[];
/**
* Minimum host version required (semver lower bound).
* Preferred generic field for new manifests.
*/
minimumHostVersion?: PluginMinimumHostVersion;
/**
* Legacy alias for `minimumHostVersion`.
* Kept for backwards compatibility with existing manifests and docs.
*/
minimumPaperclipVersion?: PluginMinimumHostVersion;
/** Capabilities this plugin requires from the host. Enforced at runtime. */
capabilities: PluginCapability[];
/** Entrypoint paths relative to the package root. */
entrypoints: {
/** Path to the worker entrypoint (required). */
worker: string;
/** Path to the UI bundle directory (required when `ui.slots` is declared). */
ui?: string;
};
/** JSON Schema for operator-editable instance configuration. */
instanceConfigSchema?: JsonSchema;
/** Scheduled jobs this plugin declares. Requires `jobs.schedule` capability. */
jobs?: PluginJobDeclaration[];
/** Webhook endpoints this plugin declares. Requires `webhooks.receive` capability. */
webhooks?: PluginWebhookDeclaration[];
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
tools?: PluginToolDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
*/
launchers?: PluginLauncherDeclaration[];
/** UI bundle declarations. Requires `entrypoints.ui` when populated. */
ui?: PluginUiDeclaration;
}
// ---------------------------------------------------------------------------
// Plugin Record represents a row in the `plugins` table
// ---------------------------------------------------------------------------
/**
* Domain type for an installed plugin as persisted in the `plugins` table.
* See PLUGIN_SPEC.md §21.3 for the schema definition.
*/
export interface PluginRecord {
/** UUID primary key. */
id: string;
/** Unique key derived from `manifest.id`. Used for lookups. */
pluginKey: string;
/** npm package name (e.g. `"@acme/plugin-linear"`). */
packageName: string;
/** Installed semver version. */
version: string;
/** Plugin API version from the manifest. */
apiVersion: number;
/** Plugin categories from the manifest. */
categories: PluginCategory[];
/** Full manifest snapshot persisted at install/upgrade time. */
manifestJson: PaperclipPluginManifestV1;
/** Current lifecycle status. */
status: PluginStatus;
/** Deterministic load order (null if not yet assigned). */
installOrder: number | null;
/** Resolved package path for local-path installs; used to find worker entrypoint. */
packagePath: string | null;
/** Most recent error message, or operator-provided disable reason. */
lastError: string | null;
/** Timestamp when the plugin was first installed. */
installedAt: Date;
/** Timestamp of the most recent status or metadata change. */
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Plugin State represents a row in the `plugin_state` table
// ---------------------------------------------------------------------------
/**
* Domain type for a single scoped key-value entry in the `plugin_state` table.
* Plugins read and write these entries through `ctx.state` in the SDK.
*
* The five-part composite key `(pluginId, scopeKind, scopeId, namespace, stateKey)`
* uniquely identifies a state entry.
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
*/
export interface PluginStateRecord {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Granularity of the scope. */
scopeKind: PluginStateScopeKind;
/**
* UUID or text identifier for the scoped object.
* `null` for `instance` scope (no associated entity).
*/
scopeId: string | null;
/**
* Sub-namespace within the scope to avoid key collisions.
* Defaults to `"default"` if not explicitly set by the plugin.
*/
namespace: string;
/** The key for this state entry within the namespace. */
stateKey: string;
/** Stored JSON value. May be any JSON-serializable type. */
valueJson: unknown;
/** Timestamp of the most recent write. */
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Plugin Config represents a row in the `plugin_config` table
// ---------------------------------------------------------------------------
/**
* Domain type for a plugin's instance configuration as persisted in the
* `plugin_config` table.
* See PLUGIN_SPEC.md §21.3 for the schema definition.
*/
export interface PluginConfig {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
pluginId: string;
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
configJson: Record<string, unknown>;
/** Most recent config validation error, if any. */
lastError: string | null;
/** Timestamp when the config row was created. */
createdAt: Date;
/** Timestamp of the most recent config update. */
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Company Plugin Availability / Settings
// ---------------------------------------------------------------------------
/**
* Domain type for a plugin's company-scoped settings row as persisted in the
* `plugin_company_settings` table.
*
* This is separate from instance-wide `PluginConfig`: the plugin remains
* installed globally, while each company can store its own plugin settings and
* availability state independently.
*/
export interface PluginCompanySettings {
/** UUID primary key. */
id: string;
/** FK to `companies.id`. */
companyId: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Explicit availability override for this company/plugin pair. */
enabled: boolean;
/** Company-scoped plugin settings payload. */
settingsJson: Record<string, unknown>;
/** Most recent company-scoped validation or availability error, if any. */
lastError: string | null;
/** Timestamp when the settings row was created. */
createdAt: Date;
/** Timestamp of the most recent settings update. */
updatedAt: Date;
}
/**
* API response shape describing whether a plugin is available to a specific
* company and, when present, the company-scoped settings row backing that
* availability.
*/
export interface CompanyPluginAvailability {
companyId: string;
pluginId: string;
/** Stable manifest/plugin key for display and route generation. */
pluginKey: string;
/** Human-readable plugin name. */
pluginDisplayName: string;
/** Current instance-wide plugin lifecycle status. */
pluginStatus: PluginStatus;
/**
* Whether the plugin is currently available to the company.
* When no `plugin_company_settings` row exists yet, the plugin is enabled
* by default for the company.
*/
available: boolean;
/** Company-scoped settings, defaulting to an empty object when unavailable. */
settingsJson: Record<string, unknown>;
/** Most recent company-scoped error, if any. */
lastError: string | null;
/** Present when availability is backed by a persisted settings row. */
createdAt: Date | null;
/** Present when availability is backed by a persisted settings row. */
updatedAt: Date | null;
}
/**
* Query filter for `ctx.entities.list`.
*/
export interface PluginEntityQuery {
/** Optional filter by entity type (e.g. 'project', 'issue'). */
entityType?: string;
/** Optional filter by external system identifier. */
externalId?: string;
/** Maximum number of records to return. Defaults to 100. */
limit?: number;
/** Number of records to skip. Defaults to 0. */
offset?: number;
}
// ---------------------------------------------------------------------------
// Plugin Entity represents a row in the `plugin_entities` table
// ---------------------------------------------------------------------------
/**
* Domain type for an external entity mapping as persisted in the `plugin_entities` table.
*/
export interface PluginEntityRecord {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Plugin-defined entity type. */
entityType: string;
/** Scope where this entity lives. */
scopeKind: PluginStateScopeKind;
/** UUID or text identifier for the scoped object. */
scopeId: string | null;
/** External identifier in the remote system. */
externalId: string | null;
/** Human-readable title. */
title: string | null;
/** Optional status string. */
status: string | null;
/** Full entity data blob. */
data: Record<string, unknown>;
/** ISO 8601 creation timestamp. */
createdAt: Date;
/** ISO 8601 last-updated timestamp. */
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Plugin Job represents a row in the `plugin_jobs` table
// ---------------------------------------------------------------------------
/**
* Domain type for a registered plugin job as persisted in the `plugin_jobs` table.
*/
export interface PluginJobRecord {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Job key matching the manifest declaration. */
jobKey: string;
/** Cron expression for the schedule. */
schedule: string;
/** Current job status. */
status: "active" | "paused" | "failed";
/** Last time the job was executed. */
lastRunAt: Date | null;
/** Next scheduled execution time. */
nextRunAt: Date | null;
/** ISO 8601 creation timestamp. */
createdAt: Date;
/** ISO 8601 last-updated timestamp. */
updatedAt: Date;
}
// ---------------------------------------------------------------------------
// Plugin Job Run represents a row in the `plugin_job_runs` table
// ---------------------------------------------------------------------------
/**
* Domain type for a job execution history record.
*/
export interface PluginJobRunRecord {
/** UUID primary key. */
id: string;
/** FK to `plugin_jobs.id`. */
jobId: string;
/** FK to `plugins.id`. */
pluginId: string;
/** What triggered this run. */
trigger: "schedule" | "manual" | "retry";
/** Current run status. */
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
/** Run duration in milliseconds. */
durationMs: number | null;
/** Error message if the run failed. */
error: string | null;
/** Run logs. */
logs: string[];
/** ISO 8601 start timestamp. */
startedAt: Date | null;
/** ISO 8601 finish timestamp. */
finishedAt: Date | null;
/** ISO 8601 creation timestamp. */
createdAt: Date;
}
// ---------------------------------------------------------------------------
// Plugin Webhook Delivery represents a row in the `plugin_webhook_deliveries` table
// ---------------------------------------------------------------------------
/**
* Domain type for an inbound webhook delivery record.
*/
export interface PluginWebhookDeliveryRecord {
/** UUID primary key. */
id: string;
/** FK to `plugins.id`. */
pluginId: string;
/** Webhook endpoint key matching the manifest. */
webhookKey: string;
/** External identifier from the remote system. */
externalId: string | null;
/** Delivery status. */
status: "pending" | "success" | "failed";
/** Processing duration in milliseconds. */
durationMs: number | null;
/** Error message if processing failed. */
error: string | null;
/** Webhook payload. */
payload: Record<string, unknown>;
/** Webhook headers. */
headers: Record<string, string>;
/** ISO 8601 start timestamp. */
startedAt: Date | null;
/** ISO 8601 finish timestamp. */
finishedAt: Date | null;
/** ISO 8601 creation timestamp. */
createdAt: Date;
}

View File

@@ -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";

View File

@@ -0,0 +1,694 @@
import { z } from "zod";
import {
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
PLUGIN_CAPABILITIES,
PLUGIN_UI_SLOT_TYPES,
PLUGIN_UI_SLOT_ENTITY_TYPES,
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
PLUGIN_LAUNCHER_ACTIONS,
PLUGIN_LAUNCHER_BOUNDS,
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
PLUGIN_STATE_SCOPE_KINDS,
} from "../constants.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder a permissive validator for JSON Schema objects
// ---------------------------------------------------------------------------
/**
* Permissive validator for JSON Schema objects. Accepts any `Record<string, unknown>`
* that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`).
* Empty objects are also accepted.
*
* Used to validate `instanceConfigSchema` and `parametersSchema` fields in the
* plugin manifest without fully parsing JSON Schema.
*
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
*/
export const jsonSchemaSchema = z.record(z.unknown()).refine(
(val) => {
// Must have a "type" field if non-empty, or be a valid JSON Schema object
if (Object.keys(val).length === 0) return true;
return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined;
},
{ message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" },
);
// ---------------------------------------------------------------------------
// Manifest sub-type schemas
// ---------------------------------------------------------------------------
/**
* Validates a {@link PluginJobDeclaration} — a scheduled job declared in the
* plugin manifest. Requires `jobKey` and `displayName`; `description` and
* `schedule` (cron expression) are optional.
*
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
*/
/**
* Validates a cron expression has exactly 5 whitespace-separated fields,
* each containing only valid cron characters (digits, *, /, -, ,).
*
* Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists.
*/
const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/;
function isValidCronExpression(expression: string): boolean {
const trimmed = expression.trim();
if (!trimmed) return false;
const fields = trimmed.split(/\s+/);
if (fields.length !== 5) return false;
return fields.every((f) => CRON_FIELD_PATTERN.test(f));
}
export const pluginJobDeclarationSchema = z.object({
jobKey: z.string().min(1),
displayName: z.string().min(1),
description: z.string().optional(),
schedule: z.string().refine(
(val) => isValidCronExpression(val),
{ message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" },
).optional(),
});
export type PluginJobDeclarationInput = z.infer<typeof pluginJobDeclarationSchema>;
/**
* Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared
* in the plugin manifest. Requires `endpointKey` and `displayName`.
*
* @see PLUGIN_SPEC.md §18 — Webhooks
*/
export const pluginWebhookDeclarationSchema = z.object({
endpointKey: z.string().min(1),
displayName: z.string().min(1),
description: z.string().optional(),
});
export type PluginWebhookDeclarationInput = z.infer<typeof pluginWebhookDeclarationSchema>;
/**
* Validates a {@link PluginToolDeclaration} — an agent tool contributed by the
* plugin. Requires `name`, `displayName`, `description`, and a valid
* `parametersSchema`. Requires the `agent.tools.register` capability.
*
* @see PLUGIN_SPEC.md §11 — Agent Tools
*/
export const pluginToolDeclarationSchema = z.object({
name: z.string().min(1),
displayName: z.string().min(1),
description: z.string().min(1),
parametersSchema: jsonSchemaSchema,
});
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
/**
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
* fills with a React component. Includes `superRefine` checks for slot-specific
* requirements such as `entityTypes` for context-sensitive slots.
*
* @see PLUGIN_SPEC.md §19 — UI Extension Model
*/
export const pluginUiSlotDeclarationSchema = z.object({
type: z.enum(PLUGIN_UI_SLOT_TYPES),
id: z.string().min(1),
displayName: z.string().min(1),
exportName: z.string().min(1),
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
order: z.number().int().optional(),
}).superRefine((value, ctx) => {
// context-sensitive slots require explicit entity targeting.
const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"];
if (
entityScopedTypes.includes(value.type)
&& (!value.entityTypes || value.entityTypes.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${value.type} slots require at least one entityType`,
path: ["entityTypes"],
});
}
// projectSidebarItem only makes sense for entityType "project".
if (value.type === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "projectSidebarItem slots require entityTypes to include \"project\"",
path: ["entityTypes"],
});
}
// commentAnnotation only makes sense for entityType "comment".
if (value.type === "commentAnnotation" && value.entityTypes && !value.entityTypes.includes("comment")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "commentAnnotation slots require entityTypes to include \"comment\"",
path: ["entityTypes"],
});
}
// commentContextMenuItem only makes sense for entityType "comment".
if (value.type === "commentContextMenuItem" && value.entityTypes && !value.entityTypes.includes("comment")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "commentContextMenuItem slots require entityTypes to include \"comment\"",
path: ["entityTypes"],
});
}
});
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
const entityScopedLauncherPlacementZones = [
"detailTab",
"taskDetailView",
"contextMenuItem",
"commentAnnotation",
"commentContextMenuItem",
"projectSidebarItem",
] as const;
const launcherBoundsByEnvironment: Record<
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number],
readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][]
> = {
hostInline: ["inline", "compact", "default"],
hostOverlay: ["compact", "default", "wide", "full"],
hostRoute: ["default", "wide", "full"],
external: [],
iframe: ["compact", "default", "wide", "full"],
};
/**
* Validates the action payload for a declarative plugin launcher.
*/
export const pluginLauncherActionDeclarationSchema = z.object({
type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
target: z.string().min(1),
params: z.record(z.unknown()).optional(),
}).superRefine((value, ctx) => {
if (value.type === "performAction" && value.target.includes("/")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "performAction launchers must target an action key, not a route or URL",
path: ["target"],
});
}
if (value.type === "navigate" && /^https?:\/\//.test(value.target)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "navigate launchers must target a host route, not an absolute URL",
path: ["target"],
});
}
});
export type PluginLauncherActionDeclarationInput =
z.infer<typeof pluginLauncherActionDeclarationSchema>;
/**
* Validates optional render hints for a plugin launcher destination.
*/
export const pluginLauncherRenderDeclarationSchema = z.object({
environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS),
bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(),
}).superRefine((value, ctx) => {
if (!value.bounds) {
return;
}
const supportedBounds = launcherBoundsByEnvironment[value.environment];
if (!supportedBounds.includes(value.bounds)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`,
path: ["bounds"],
});
}
});
export type PluginLauncherRenderDeclarationInput =
z.infer<typeof pluginLauncherRenderDeclarationSchema>;
/**
* Validates declarative launcher metadata in a plugin manifest.
*/
export const pluginLauncherDeclarationSchema = z.object({
id: z.string().min(1),
displayName: z.string().min(1),
description: z.string().optional(),
placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES),
exportName: z.string().min(1).optional(),
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
order: z.number().int().optional(),
action: pluginLauncherActionDeclarationSchema,
render: pluginLauncherRenderDeclarationSchema.optional(),
}).superRefine((value, ctx) => {
if (
entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone)
&& (!value.entityTypes || value.entityTypes.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${value.placementZone} launchers require at least one entityType`,
path: ["entityTypes"],
});
}
if (
value.placementZone === "projectSidebarItem"
&& value.entityTypes
&& !value.entityTypes.includes("project")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "projectSidebarItem launchers require entityTypes to include \"project\"",
path: ["entityTypes"],
});
}
if (value.action.type === "performAction" && value.render) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "performAction launchers cannot declare render hints",
path: ["render"],
});
}
if (
["openModal", "openDrawer", "openPopover"].includes(value.action.type)
&& !value.render
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${value.action.type} launchers require render metadata`,
path: ["render"],
});
}
if (value.action.type === "openModal" && value.render?.environment === "hostInline") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "openModal launchers cannot use the hostInline render environment",
path: ["render", "environment"],
});
}
if (
value.action.type === "openDrawer"
&& value.render
&& !["hostOverlay", "iframe"].includes(value.render.environment)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "openDrawer launchers must use hostOverlay or iframe render environments",
path: ["render", "environment"],
});
}
if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "openPopover launchers cannot use the hostRoute render environment",
path: ["render", "environment"],
});
}
});
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
// ---------------------------------------------------------------------------
// Plugin Manifest V1 schema
// ---------------------------------------------------------------------------
/**
* Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime
* validator for plugin manifests read at install time.
*
* Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules):
*
* | Field | Type | Constraints |
* |--------------------------|------------|----------------------------------------------|
* | `id` | string | `^[a-z0-9][a-z0-9._-]*$` |
* | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` |
* | `version` | string | semver (`\d+\.\d+\.\d+`) |
* | `displayName` | string | 1100 chars |
* | `description` | string | 1500 chars |
* | `author` | string | 1200 chars |
* | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES |
* | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`|
* | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` |
* | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES|
* | `entrypoints.worker` | string | min 1 char |
* | `entrypoints.ui` | string? | required when `ui.slots` is declared |
*
* Cross-field rules enforced via `superRefine`:
* - `entrypoints.ui` required when `ui.slots` declared
* - `agent.tools.register` capability required when `tools` declared
* - `jobs.schedule` capability required when `jobs` declared
* - `webhooks.receive` capability required when `webhooks` declared
* - duplicate `jobs[].jobKey` values are rejected
* - duplicate `webhooks[].endpointKey` values are rejected
* - duplicate `tools[].name` values are rejected
* - duplicate `ui.slots[].id` values are rejected
*
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
* @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type
*/
export const pluginManifestV1Schema = z.object({
id: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
),
apiVersion: z.literal(1),
version: z.string().min(1).regex(
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
"Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)",
),
displayName: z.string().min(1).max(100),
description: z.string().min(1).max(500),
author: z.string().min(1).max(200),
categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1),
minimumHostVersion: z.string().regex(
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
"minimumHostVersion must follow semver (e.g. 1.0.0)",
).optional(),
minimumPaperclipVersion: z.string().regex(
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
).optional(),
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
entrypoints: z.object({
worker: z.string().min(1),
ui: z.string().min(1).optional(),
}),
instanceConfigSchema: jsonSchemaSchema.optional(),
jobs: z.array(pluginJobDeclarationSchema).optional(),
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
tools: z.array(pluginToolDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
}).optional(),
}).superRefine((manifest, ctx) => {
// ── Entrypoint ↔ UI slot consistency ──────────────────────────────────
// Plugins that declare UI slots must also declare a UI entrypoint so the
// host knows where to load the bundle from (PLUGIN_SPEC.md §10.1).
const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0;
const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0;
if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "entrypoints.ui is required when ui.slots or ui.launchers are declared",
path: ["entrypoints", "ui"],
});
}
if (
manifest.minimumHostVersion
&& manifest.minimumPaperclipVersion
&& manifest.minimumHostVersion !== manifest.minimumPaperclipVersion
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared",
path: ["minimumHostVersion"],
});
}
// ── Capability ↔ feature declaration consistency ───────────────────────
// The host enforces capabilities at install and runtime. A plugin must
// declare every capability it needs up-front; silently having more features
// than capabilities would cause runtime rejections.
// tools require agent.tools.register (PLUGIN_SPEC.md §11)
if (manifest.tools && manifest.tools.length > 0) {
if (!manifest.capabilities.includes("agent.tools.register")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'agent.tools.register' is required when tools are declared",
path: ["capabilities"],
});
}
}
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
if (manifest.jobs && manifest.jobs.length > 0) {
if (!manifest.capabilities.includes("jobs.schedule")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'jobs.schedule' is required when jobs are declared",
path: ["capabilities"],
});
}
}
// webhooks require webhooks.receive (PLUGIN_SPEC.md §18)
if (manifest.webhooks && manifest.webhooks.length > 0) {
if (!manifest.capabilities.includes("webhooks.receive")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'webhooks.receive' is required when webhooks are declared",
path: ["capabilities"],
});
}
}
// ── Uniqueness checks ──────────────────────────────────────────────────
// Duplicate keys within a plugin's own manifest are always a bug. The host
// would not know which declaration takes precedence, so we reject early.
// job keys must be unique within the plugin (used as identifiers in the DB)
if (manifest.jobs) {
const jobKeys = manifest.jobs.map((j) => j.jobKey);
const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["jobs"],
});
}
}
// webhook endpoint keys must be unique within the plugin (used in routes)
if (manifest.webhooks) {
const endpointKeys = manifest.webhooks.map((w) => w.endpointKey);
const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["webhooks"],
});
}
}
// tool names must be unique within the plugin (namespaced at runtime)
if (manifest.tools) {
const toolNames = manifest.tools.map((t) => t.name);
const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`,
path: ["tools"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {
const slotIds = manifest.ui.slots.map((s) => s.id);
const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`,
path: ["ui", "slots"],
});
}
}
}
// launcher ids must be unique within the plugin
const allLaunchers = [
...(manifest.launchers ?? []),
...(manifest.ui?.launchers ?? []),
];
if (allLaunchers.length > 0) {
const launcherIds = allLaunchers.map((launcher) => launcher.id);
const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`,
path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"],
});
}
}
});
export type PluginManifestV1Input = z.infer<typeof pluginManifestV1Schema>;
// ---------------------------------------------------------------------------
// Plugin installation / registration request
// ---------------------------------------------------------------------------
/**
* Schema for installing (registering) a plugin.
* The server receives the packageName and resolves the manifest from the
* installed package.
*/
export const installPluginSchema = z.object({
packageName: z.string().min(1),
version: z.string().min(1).optional(),
/** Set by loader for local-path installs so the worker can be resolved. */
packagePath: z.string().min(1).optional(),
});
export type InstallPlugin = z.infer<typeof installPluginSchema>;
// ---------------------------------------------------------------------------
// Plugin config (instance configuration) schemas
// ---------------------------------------------------------------------------
/**
* Schema for creating or updating a plugin's instance configuration.
* configJson is validated permissively here; runtime validation against
* the plugin's instanceConfigSchema is done at the service layer.
*/
export const upsertPluginConfigSchema = z.object({
configJson: z.record(z.unknown()),
});
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
/**
* Schema for partially updating a plugin's instance configuration.
* Allows a partial merge of config values.
*/
export const patchPluginConfigSchema = z.object({
configJson: z.record(z.unknown()),
});
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
// ---------------------------------------------------------------------------
// Company plugin availability / settings schemas
// ---------------------------------------------------------------------------
/**
* Schema for creating or replacing company-scoped plugin settings.
*
* Company-specific settings are stored separately from instance-level
* `plugin_config`, allowing the host to expose a company availability toggle
* without changing the global install state of the plugin.
*/
export const upsertPluginCompanySettingsSchema = z.object({
settingsJson: z.record(z.unknown()).optional().default({}),
lastError: z.string().nullable().optional(),
});
export type UpsertPluginCompanySettings = z.infer<typeof upsertPluginCompanySettingsSchema>;
/**
* Schema for mutating a plugin's availability for a specific company.
*
* `available=false` lets callers disable access without uninstalling the
* plugin globally. Optional `settingsJson` supports carrying company-specific
* configuration alongside the availability update.
*/
export const updateCompanyPluginAvailabilitySchema = z.object({
available: z.boolean(),
settingsJson: z.record(z.unknown()).optional(),
lastError: z.string().nullable().optional(),
});
export type UpdateCompanyPluginAvailability = z.infer<typeof updateCompanyPluginAvailabilitySchema>;
/**
* Query schema for company plugin availability list endpoints.
*/
export const listCompanyPluginAvailabilitySchema = z.object({
available: z.boolean().optional(),
});
export type ListCompanyPluginAvailability = z.infer<typeof listCompanyPluginAvailabilitySchema>;
// ---------------------------------------------------------------------------
// Plugin status update
// ---------------------------------------------------------------------------
/**
* Schema for updating a plugin's lifecycle status. Used by the lifecycle
* manager to persist state transitions.
*
* @see {@link PLUGIN_STATUSES} for the valid status values
*/
export const updatePluginStatusSchema = z.object({
status: z.enum(PLUGIN_STATUSES),
lastError: z.string().nullable().optional(),
});
export type UpdatePluginStatus = z.infer<typeof updatePluginStatusSchema>;
// ---------------------------------------------------------------------------
// Plugin uninstall
// ---------------------------------------------------------------------------
/** Schema for the uninstall request. `removeData` controls hard vs soft delete. */
export const uninstallPluginSchema = z.object({
removeData: z.boolean().optional().default(false),
});
export type UninstallPlugin = z.infer<typeof uninstallPluginSchema>;
// ---------------------------------------------------------------------------
// Plugin state (key-value storage) schemas
// ---------------------------------------------------------------------------
/**
* Schema for a plugin state scope key — identifies the exact location where
* state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and
* `ctx.state.delete()` SDK methods.
*
* @see PLUGIN_SPEC.md §21.3 `plugin_state`
*/
export const pluginStateScopeKeySchema = z.object({
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
scopeId: z.string().min(1).optional(),
namespace: z.string().min(1).optional(),
stateKey: z.string().min(1),
});
export type PluginStateScopeKey = z.infer<typeof pluginStateScopeKeySchema>;
/**
* Schema for setting a plugin state value.
*/
export const setPluginStateSchema = z.object({
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
scopeId: z.string().min(1).optional(),
namespace: z.string().min(1).optional(),
stateKey: z.string().min(1),
/** JSON-serializable value to store. */
value: z.unknown(),
});
export type SetPluginState = z.infer<typeof setPluginStateSchema>;
/**
* Schema for querying plugin state entries. All fields are optional to allow
* flexible list queries (e.g. all state for a plugin within a scope).
*/
export const listPluginStateSchema = z.object({
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(),
scopeId: z.string().min(1).optional(),
namespace: z.string().min(1).optional(),
});
export type ListPluginState = z.infer<typeof listPluginStateSchema>;