Files
paperclip/server/src/__tests__/plugin-managed-routines.test.ts
T
Dotta 3c73ed26b5 Expand plugin host surface (#5205)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The plugin system is the extension boundary for optional product
capabilities
> - Rich plugins need more than a worker entrypoint: they need scoped
database storage, local project folders, managed agents/routines, host
navigation, and reusable UI components
> - The LLM Wiki work exposed those missing host surfaces while keeping
plugin code outside the core control plane
> - This pull request expands the core plugin host, SDK, server APIs,
and UI bridge so plugins can declare and use those surfaces
> - The benefit is that future plugins can integrate with Paperclip
through documented, validated contracts instead of bespoke server or UI
imports

## What Changed

- Added plugin-managed database namespaces and migration tracking,
including Drizzle schema/migration files and SQL validation for
namespace isolation.
- Added server support for plugin local folders, managed agents, managed
routines, scoped plugin APIs, and plugin operation visibility.
- Expanded shared plugin manifest/types/validators and SDK
host/testing/UI exports for richer plugin surfaces.
- Added reusable UI pieces for file trees, managed routines, resizable
sidebars, route sidebars, and plugin bridge initialization.
- Updated plugin docs and example plugins to use the expanded host and
SDK surface.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
packages/shared/src/validators/plugin.test.ts
server/src/__tests__/plugin-database.test.ts
server/src/__tests__/plugin-local-folders.test.ts
server/src/__tests__/plugin-managed-agents.test.ts
server/src/__tests__/plugin-managed-routines.test.ts
server/src/__tests__/plugin-orchestration-apis.test.ts
ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx
ui/src/components/ResizableSidebarPane.test.tsx
ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed:
11 files, 67 tests.
- Confirmed this PR changes 89 files and does not include
`pnpm-lock.yaml` or `.github/workflows/*`.

## Risks

- Medium: this expands plugin host contracts across db/shared/server/ui
and includes a new core migration (`0076_useful_elektra.sql`).
- The plugin database namespace validator is intentionally restrictive;
plugin authors may need follow-up affordances for SQL patterns that
remain blocked.
- Merge this before the LLM Wiki plugin PR so the plugin can resolve the
new SDK and host APIs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub
workflow. Context window size was not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-05 07:42:57 -05:00

250 lines
8.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentConfigRevisions,
agents,
companies,
createDb,
issues,
pluginManagedResources,
plugins,
projects,
routineRuns,
routineTriggers,
routines,
} from "@paperclipai/db";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { routineService } from "../services/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function createEventBusStub() {
return {
forPlugin() {
return {
emit: async () => {},
subscribe: () => {},
};
},
} as any;
}
function issuePrefix(id: string) {
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
}
function manifest(): PaperclipPluginManifestV1 {
return {
id: "paperclip.managed-routines-test",
apiVersion: 1,
version: "0.1.0",
displayName: "Managed Routines Test",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
entrypoints: { worker: "./dist/worker.js" },
agents: [{
agentKey: "wiki-maintainer",
displayName: "Wiki Maintainer",
role: "engineer",
adapterType: "process",
adapterConfig: { command: "pnpm wiki:maintain" },
}],
projects: [{
projectKey: "operations",
displayName: "Plugin Operations",
description: "Plugin operation inspection",
status: "in_progress",
}],
routines: [{
routineKey: "nightly-lint",
title: "Nightly lint",
description: "Lint plugin state",
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
projectRef: { resourceKind: "project", resourceKey: "operations" },
status: "active",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
triggers: [{
kind: "schedule",
label: "Nightly",
cronExpression: "0 3 * * *",
timezone: "UTC",
}],
issueTemplate: {
surfaceVisibility: "plugin_operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
},
}],
};
}
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("plugin-managed routines", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routineRuns);
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(issues);
await db.delete(agentConfigRevisions);
await db.delete(activityLog);
await db.delete(pluginManagedResources);
await db.delete(agents);
await db.delete(projects);
await db.delete(plugins);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
const companyId = randomUUID();
const pluginId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: issuePrefix(companyId),
});
await db.insert(plugins).values({
id: pluginId,
pluginKey: pluginManifest.id,
packageName: "@paperclipai/plugin-managed-routines-test",
version: pluginManifest.version,
apiVersion: pluginManifest.apiVersion,
categories: pluginManifest.categories,
manifestJson: pluginManifest,
status: "ready",
installOrder: 1,
});
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
manifest: pluginManifest,
});
return { companyId, pluginId, pluginManifest, services };
}
it("resolves routine agent and project refs by stable managed keys", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(created.status).toBe("created");
expect(created.routine).toMatchObject({
title: "Nightly lint",
assigneeAgentId: agent.agentId,
projectId: project.projectId,
managedByPlugin: expect.objectContaining({
pluginKey: "paperclip.managed-routines-test",
resourceKind: "routine",
resourceKey: "nightly-lint",
}),
});
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
expect(trigger).toMatchObject({
kind: "schedule",
cronExpression: "0 3 * * *",
timezone: "UTC",
});
});
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(missing.status).toBe("missing_refs");
expect(missing.missingRefs).toEqual([
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
]);
const [agent] = await db.insert(agents).values({
companyId,
name: "Operator-selected maintainer",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
}).returning();
const [project] = await db.insert(projects).values({
companyId,
name: "Operator-selected project",
status: "in_progress",
}).returning();
const repaired = await services.routines.managedReconcile({
companyId,
routineKey: "nightly-lint",
assigneeAgentId: agent.id,
projectId: project.id,
});
expect(repaired.status).toBe("created");
expect(repaired.routine).toMatchObject({
assigneeAgentId: agent.id,
projectId: project.id,
});
await db
.update(routines)
.set({ title: "Operator renamed lint", updatedAt: new Date() })
.where(eq(routines.id, repaired.routineId!));
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
expect(reconciled.status).toBe("resolved");
expect(reconciled.routine?.title).toBe("Operator renamed lint");
});
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
const { companyId, services } = await seedCompanyAndPlugin();
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
expect(run.status).toBe("issue_created");
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
expect(issue).toMatchObject({
originKind: "plugin:paperclip.managed-routines-test:operation",
originId: "operation:nightly-lint",
billingCode: "plugin-test:nightly-lint",
projectId: project.projectId,
assigneeAgentId: agent.agentId,
});
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
reason: "issue_assigned",
}));
});
});