Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master:
  Drop lockfile from watcher change
  Tighten plugin dev file watching
  Fix plugin smoke example typecheck
  Fix plugin dev watcher and migration snapshot
  Clarify plugin authoring and external dev workflow
  Expand kitchen sink plugin demos
  fix: set AGENT_HOME env var for agent processes
  Add kitchen sink plugin example
  Simplify plugin runtime and cleanup lifecycle
  Add plugin framework and settings UI

# Conflicts:
#	packages/db/src/migrations/meta/0029_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta
2026-03-14 13:56:09 -05:00
141 changed files with 47521 additions and 961 deletions

View File

@@ -37,3 +37,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";

View File

@@ -0,0 +1,41 @@
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { plugins } from "./plugins.js";
/**
* `plugin_company_settings` table — stores operator-managed plugin settings
* scoped to a specific company.
*
* This is distinct from `plugin_config`, which stores instance-wide plugin
* configuration. Each company can have at most one settings row per plugin.
*
* Rows represent explicit overrides from the default company behavior:
* - no row => plugin is enabled for the company by default
* - row with `enabled = false` => plugin is disabled for that company
* - row with `enabled = true` => plugin remains enabled and stores company settings
*/
export const pluginCompanySettings = pgTable(
"plugin_company_settings",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id")
.notNull()
.references(() => companies.id, { onDelete: "cascade" }),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
table.companyId,
table.pluginId,
),
}),
);

View File

@@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_config` table — stores operator-provided instance configuration
* for each plugin (one row per plugin, enforced by a unique index on
* `plugin_id`).
*
* The `config_json` column holds the values that the operator enters in the
* plugin settings UI. These values are validated at runtime against the
* plugin's `instanceConfigSchema` from the manifest.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginConfig = pgTable(
"plugin_config",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
}),
);

View File

@@ -0,0 +1,54 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginStateScopeKind } from "@paperclipai/shared";
/**
* `plugin_entities` table — persistent high-level mapping between Paperclip
* objects and external plugin-defined entities.
*
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
* to their respective external IDs for projects, issues, etc. and to store
* their custom data.
*
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
* is intended for structured object mappings that the host can understand
* and query for cross-plugin UI integration.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const pluginEntities = pgTable(
"plugin_entities",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
externalId: text("external_id"), // ID in the external system
title: text("title"),
status: text("status"),
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
table.pluginId,
table.entityType,
table.externalId,
),
}),
);

View File

@@ -0,0 +1,102 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
/**
* `plugin_jobs` table — registration and runtime configuration for
* scheduled jobs declared by plugins in their manifests.
*
* Each row represents one scheduled job entry for a plugin. The
* `job_key` matches the key declared in the manifest's `jobs` array.
* The `schedule` column stores the cron expression or interval string
* used by the job scheduler to decide when to fire the job.
*
* Status values:
* - `active` — job is enabled and will run on schedule
* - `paused` — job is temporarily disabled by the operator
* - `error` — job has been disabled due to repeated failures
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
*/
export const pluginJobs = pgTable(
"plugin_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `jobs` array. */
jobKey: text("job_key").notNull(),
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
schedule: text("schedule").notNull(),
/** Current scheduling state. */
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
/** Timestamp of the most recent successful execution. */
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
/** Pre-computed timestamp of the next scheduled execution. */
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
}),
);
/**
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
*
* Each row is created when a job run begins and updated when it completes.
* Rows are never modified after `status` reaches a terminal value
* (`succeeded` | `failed` | `cancelled`).
*
* Trigger values:
* - `scheduled` — fired automatically by the cron/interval scheduler
* - `manual` — triggered by an operator via the admin UI or API
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
*/
export const pluginJobRuns = pgTable(
"plugin_job_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the parent job definition. Cascades on delete. */
jobId: uuid("job_id")
.notNull()
.references(() => pluginJobs.id, { onDelete: "cascade" }),
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** What caused this run to start (`"scheduled"` or `"manual"`). */
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
/** Current lifecycle state of this run. */
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
/** Wall-clock duration in milliseconds. Null until the run finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Ordered list of log lines emitted during this run. */
logs: jsonb("logs").$type<string[]>().notNull().default([]),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
}),
);

View File

@@ -0,0 +1,43 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
/**
* `plugin_logs` table — structured log storage for plugin workers.
*
* Each row stores a single log entry emitted by a plugin worker via
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
* time range to support the operator logs panel and debugging workflows.
*
* Rows are inserted by the host when handling `log` notifications from
* the worker process. A capped retention policy can be applied via
* periodic cleanup (e.g. delete rows older than 7 days).
*
* @see PLUGIN_SPEC.md §26 — Observability
*/
export const pluginLogs = pgTable(
"plugin_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
level: text("level").notNull().default("info"),
message: text("message").notNull(),
meta: jsonb("meta").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
table.pluginId,
table.createdAt,
),
levelIdx: index("plugin_logs_level_idx").on(table.level),
}),
);

View File

@@ -0,0 +1,90 @@
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
index,
unique,
} from "drizzle-orm/pg-core";
import type { PluginStateScopeKind } from "@paperclipai/shared";
import { plugins } from "./plugins.js";
/**
* `plugin_state` table — scoped key-value storage for plugin workers.
*
* Each row stores a single JSON value identified by
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
* this table through `ctx.state.get()`, `ctx.state.set()`, and
* `ctx.state.delete()` in the SDK.
*
* Scope kinds determine the granularity of isolation:
* - `instance` — one value shared across the whole Paperclip instance
* - `company` — one value per company
* - `project` — one value per project
* - `project_workspace` — one value per project workspace
* - `agent` — one value per agent
* - `issue` — one value per issue
* - `goal` — one value per goal
* - `run` — one value per agent run
*
* The `namespace` column defaults to `"default"` and can be used to
* logically group keys without polluting the root namespace.
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
*/
export const pluginState = pgTable(
"plugin_state",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
/**
* UUID or text identifier for the scoped object.
* Null for `instance` scope (which has no associated entity).
*/
scopeId: text("scope_id"),
/**
* Sub-namespace to avoid key collisions within a scope.
* Defaults to `"default"` if the plugin does not specify one.
*/
namespace: text("namespace").notNull().default("default"),
/** The key identifying this state entry within the namespace. */
stateKey: text("state_key").notNull(),
/** JSON-serializable value stored by the plugin. */
valueJson: jsonb("value_json").notNull(),
/** Timestamp of the most recent write. */
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
/**
* Unique constraint enforces that there is at most one value per
* (plugin, scope kind, scope id, namespace, key) tuple.
*
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
* (used by `instance` scope) are treated as equal by PostgreSQL rather
* than as distinct nulls — otherwise the upsert target in `set()` would
* fail to match existing rows and create duplicates.
*
* Requires PostgreSQL 15+.
*/
uniqueEntry: unique("plugin_state_unique_entry_idx")
.on(
table.pluginId,
table.scopeKind,
table.scopeId,
table.namespace,
table.stateKey,
)
.nullsNotDistinct(),
/** Speed up lookups by plugin + scope kind (most common access pattern). */
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
table.pluginId,
table.scopeKind,
),
}),
);

View File

@@ -0,0 +1,65 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
} from "drizzle-orm/pg-core";
import { plugins } from "./plugins.js";
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
/**
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
*
* When an external system sends an HTTP POST to a plugin's registered webhook
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
* creates a row in this table before dispatching the payload to the plugin
* worker. This provides an auditable log of every delivery attempt.
*
* The `webhook_key` matches the key declared in the plugin manifest's
* `webhooks` array. `external_id` is an optional identifier supplied by the
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
* and reject duplicate deliveries.
*
* Status values:
* - `pending` — received but not yet dispatched to the worker
* - `processing` — currently being handled by the plugin worker
* - `succeeded` — worker processed the payload successfully
* - `failed` — worker returned an error or timed out
*
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
*/
export const pluginWebhookDeliveries = pgTable(
"plugin_webhook_deliveries",
{
id: uuid("id").primaryKey().defaultRandom(),
/** FK to the owning plugin. Cascades on delete. */
pluginId: uuid("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
webhookKey: text("webhook_key").notNull(),
/** Optional de-duplication ID provided by the external system. */
externalId: text("external_id"),
/** Current delivery state. */
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
durationMs: integer("duration_ms"),
/** Error message if `status === "failed"`. */
error: text("error"),
/** Raw JSON body of the inbound HTTP request. */
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
}),
);

View File

@@ -0,0 +1,45 @@
import {
pgTable,
uuid,
text,
integer,
timestamp,
jsonb,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* `plugins` table — stores one row per installed plugin.
*
* Each plugin is uniquely identified by `plugin_key` (derived from
* the manifest `id`). The full manifest is persisted as JSONB in
* `manifest_json` so the host can reconstruct capability and UI
* slot information without loading the plugin package.
*
* @see PLUGIN_SPEC.md §21.3
*/
export const plugins = pgTable(
"plugins",
{
id: uuid("id").primaryKey().defaultRandom(),
pluginKey: text("plugin_key").notNull(),
packageName: text("package_name").notNull(),
version: text("version").notNull(),
apiVersion: integer("api_version").notNull().default(1),
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
status: text("status").$type<PluginStatus>().notNull().default("installed"),
installOrder: integer("install_order"),
/** Resolved package path for local-path installs; used to find worker entrypoint. */
packagePath: text("package_path"),
lastError: text("last_error"),
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
statusIdx: index("plugins_status_idx").on(table.status),
}),
);