mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin subsystem persists state into four tables (`plugin_entities`, `plugin_job_runs`, `plugin_logs`, `plugin_webhook_deliveries`) and those rows currently have no notion of an owning tenant — so company-deletion doesn't cascade plugin state, and operators have no way to query "what does this plugin own for company X?" > - The fix is one thin slice of tenant-isolation hygiene that doesn't change any external API: add a nullable `company_id` FK with `ON DELETE CASCADE` to the four plugin tables, index it, and scope the `plugin_entities` external-id uniqueness per-tenant > - The benefit is plugin-row tenant attribution + cascade cleanup, with zero impact on single-tenant local-first deploys (`NULL` continues to mean instance-scope) > **Rebase note (scope narrowed):** This PR originally also hardened the schedulers (`heartbeat.tickTimers` / `resumeQueuedRuns` / `enqueueWakeup` and `routines.tickScheduledTriggers`) to skip archived companies. That half has since landed on `master` via #7478 (`93206f73`, "Stop archived companies from waking agents") with a stricter implementation (`status != 'active'` plus a skipped-request audit row). On rebase those changes were dropped as redundant — `heartbeat.ts` and `routines.ts` are now identical to `master`, and the scheduler-specific tests were removed. **This PR is now DB-only.** ## Linked Issues or Issue Description No existing issue covers this directly — problem described in-PR: - Four plugin tables (`plugin_entities`, `plugin_job_runs`, `plugin_logs`, `plugin_webhook_deliveries`) persist rows with no notion of an owning tenant. - Company deletion therefore does not cascade plugin state, and operators have no way to query "what does this plugin own for company X?" - One thin slice of tenant-isolation hygiene fixes this without changing any external API: a nullable `company_id` FK with `ON DELETE CASCADE`, an index, and per-tenant scoping of the `plugin_entities` external-id uniqueness. - Part of the multi-tenant hardening initiative alongside #3967 (cross-tenant 404 oracle) and #5864 (per-company JWT keys). ## What Changed **Schema (`packages/db/src/schema/plugin_*.ts`):** - Nullable `companyId` FK with `onDelete: "cascade"` added to `plugin_entities`, `plugin_job_runs`, `plugin_logs`, `plugin_webhook_deliveries`. - A btree index on each new `company_id` column (`<table>_company_idx`). - `plugin_entities_external_idx` rescoped from `(plugin_id, entity_type, external_id)` to `(company_id, plugin_id, entity_type, external_id)` and switched to `UNIQUE … NULLS NOT DISTINCT` so instance-scope rows (`company_id IS NULL`) keep their dedup guarantee while tenants get their own namespace. **Migration:** - `0095_plugin_company_id_tenant_isolation.sql` — 14 statements: 4 column adds + 4 FK constraints (`ON DELETE CASCADE`) + 4 indexes + drop/recreate of the external-id unique constraint. - Journal entry + regenerated `0095_snapshot.json`. **Tests:** - `server/src/__tests__/plugin-tenant-isolation.test.ts` — `NULL` preserves backward compat; `CASCADE` on company delete across all four tables; per-tenant external-id namespacing; NULL-NULL collision rejected (`NULLS NOT DISTINCT`). ## Verification - `pnpm --filter @paperclipai/db run check:migrations` — pass. - `pnpm --filter @paperclipai/db typecheck` (`tsc`) — pass. - `vitest run plugin-tenant-isolation` — **4/4 pass** (embedded Postgres applies `0095` and exercises cascade + NULLS NOT DISTINCT). ## Notes - **Clean snapshot, no drift.** The earlier revision of this PR shipped a ~17.6k-line meta snapshot that was almost entirely pre-existing drift. On rebase the migration was renumbered (the old `0090_brainy_darkhawk` collided with `master`'s `0090_resource_memberships … 0094`) and regenerated from the current `master` baseline via `drizzle-kit generate`. The result is a 14-statement migration containing **only** the plugin-table changes — no unrelated drift. - **Backward-compatible.** `NULL company_id` continues to mean instance-scope (cron jobs, public webhooks). No new env vars, no API surface change. Single-tenant local-first deploys unaffected. ## Risks - **Migration is additive and nullable** — `0095` adds nullable `company_id` columns, FK constraints, and indexes; existing rows stay valid (`NULL` keeps meaning instance-scope) and no backfill is required. - **`ON DELETE CASCADE` is a behavioral change**: deleting a company now removes its plugin rows across all four tables. Intended (it is the point of the PR), but operators relying on plugin rows surviving company deletion would be affected. Covered by the cascade tests. - **Uniqueness semantics change on `plugin_entities`**: the external-id constraint is rescoped per-tenant and switched to `UNIQUE … NULLS NOT DISTINCT`, so two instance-scope rows (`company_id IS NULL`) with the same external id are now rejected instead of coexisting. Covered by the NULL-NULL collision test. - **No API surface change, no new env vars.** Single-tenant local-first deploys unaffected. (Section added retroactively to match the PR template; distilled from the What Changed / Notes sections above.) ## Model Used Same authoring setup as #5864 (same series, same day): Claude Opus 4.7 (1M context), extended thinking mode. (Section added retroactively.) ## Checklist - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] Tests run locally and pass (plugin-tenant-isolation 4/4) - [x] `check:migrations` + db typecheck pass - [x] No UI changes - [x] Migration carries only the intended changes (no snapshot drift) - [x] Scheduler half dropped as superseded by #7478 Part of the multi-tenant hardening initiative — see also #3967 (cross-tenant 404 oracle) and #5864 (per-company JWT keys). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Devin Foley <devin@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
import {
|
|
pgTable,
|
|
uuid,
|
|
text,
|
|
integer,
|
|
timestamp,
|
|
jsonb,
|
|
index,
|
|
uniqueIndex,
|
|
} from "drizzle-orm/pg-core";
|
|
import { companies } from "./companies.js";
|
|
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" }),
|
|
/** Company scope — NULL for instance-level jobs. */
|
|
companyId: uuid("company_id").references(() => companies.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),
|
|
companyIdx: index("plugin_job_runs_company_idx").on(table.companyId),
|
|
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
|
}),
|
|
);
|