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>
This commit is contained in:
Dotta 2026-05-05 07:42:57 -05:00 committed by GitHub
parent d6bee62f02
commit 3c73ed26b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 27516 additions and 914 deletions

View file

@ -1,5 +1,5 @@
import { Buffer } from "node:buffer";
import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
@ -127,9 +127,11 @@ export interface IssueFilters {
descendantOf?: string;
labelId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
includeRoutineExecutions?: boolean;
excludeRoutineExecutions?: boolean;
includePluginOperations?: boolean;
includeBlockedBy?: boolean;
q?: string;
limit?: number;
@ -563,6 +565,19 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) {
`;
}
function nonPluginOperationIssueCondition() {
return sql<boolean>`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`;
}
function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) {
return Boolean(
filters?.includePluginOperations ||
filters?.originKind ||
filters?.originId ||
filters?.projectId,
);
}
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
@ -2201,7 +2216,11 @@ export function issueService(db: Db) {
}
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
if (!shouldIncludePluginOperationIssues(filters)) {
conditions.push(nonPluginOperationIssueCondition());
}
if (filters?.labelId) {
const labeledIssueIds = await db
.select({ issueId: issueLabels.issueId })
@ -2333,6 +2352,7 @@ export function issueService(db: Db) {
const conditions = [
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
nonPluginOperationIssueCondition(),
unreadForUserCondition(companyId, userId),
];
if (status) {

View file

@ -47,6 +47,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"companies.get": ["companies.read"],
"projects.list": ["projects.read"],
"projects.get": ["projects.read"],
"projects.managed.get": ["projects.managed"],
"projects.managed.reconcile": ["projects.managed"],
"projects.managed.reset": ["projects.managed"],
"routines.managed.get": ["routines.managed"],
"routines.managed.reconcile": ["routines.managed"],
"routines.managed.reset": ["routines.managed"],
"project.workspaces.list": ["project.workspaces.read"],
"project.workspaces.get": ["project.workspaces.read"],
"issues.list": ["issues.read"],
@ -56,6 +62,9 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"issue.comments.get": ["issue.comments.read"],
"agents.list": ["agents.read"],
"agents.get": ["agents.read"],
"agents.managed.get": ["agents.managed"],
"agents.managed.reconcile": ["agents.managed"],
"agents.managed.reset": ["agents.managed"],
"goals.list": ["goals.read"],
"goals.get": ["goals.read"],
"activity.list": ["activity.read"],
@ -65,6 +74,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
"db.namespace": ["database.namespace.read"],
"db.query": ["database.namespace.read"],
"localFolders.declarations": [],
"localFolders.configure": ["local.folders"],
"localFolders.status": ["local.folders"],
"localFolders.list": ["local.folders"],
"localFolders.readText": ["local.folders"],
"localFolders.writeTextAtomic": ["local.folders"],
// Data write operations
"issues.create": ["issues.create"],
@ -133,6 +148,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
commentAnnotation: "ui.commentAnnotation.register",
commentContextMenuItem: "ui.action.register",
settingsPage: "instance.settings.register",
routeSidebar: "ui.sidebar.register",
};
/**
@ -167,6 +183,9 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
webhooks: "webhooks.receive",
database: "database.namespace.migrate",
environmentDrivers: "environment.drivers.register",
agents: "agents.managed",
projects: "projects.managed",
routines: "routines.managed",
};
// ---------------------------------------------------------------------------

View file

@ -303,7 +303,19 @@ function resolveMigrationsDir(packageRoot: string, migrationsDir: string): strin
return resolvedDir;
}
export function pluginDatabaseService(db: Db) {
type PluginDatabaseClient = Pick<Db, "select" | "insert" | "update" | "execute">;
type PluginDatabaseRootClient = PluginDatabaseClient & Partial<Pick<Db, "transaction">>;
export interface ApplyPluginMigrationsOptions {
/**
* Persist failed migration ledger rows. Fresh install uses false because the
* caller owns a larger transaction and must roll back the plugin row and
* namespace together.
*/
persistFailure?: boolean;
}
export function pluginDatabaseService(db: PluginDatabaseRootClient) {
async function getPluginRecord(pluginId: string) {
const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1);
const plugin = rows[0];
@ -311,14 +323,18 @@ export function pluginDatabaseService(db: Db) {
return plugin;
}
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
async function ensureNamespaceWithClient(
client: PluginDatabaseClient,
pluginId: string,
manifest: PaperclipPluginManifestV1,
) {
if (!manifest.database) return null;
const namespaceName = derivePluginDatabaseNamespace(
manifest.id,
manifest.database.namespaceSlug,
);
await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
const rows = await db
await client.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
const rows = await client
.insert(pluginDatabaseNamespaces)
.values({
pluginId,
@ -341,6 +357,10 @@ export function pluginDatabaseService(db: Db) {
return rows[0] ?? null;
}
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
return ensureNamespaceWithClient(db, pluginId, manifest);
}
async function getNamespace(pluginId: string) {
const rows = await db
.select()
@ -358,7 +378,7 @@ export function pluginDatabaseService(db: Db) {
return namespace.namespaceName;
}
async function recordMigrationFailure(input: {
async function recordMigrationFailure(client: PluginDatabaseClient, input: {
pluginId: string;
pluginKey: string;
namespaceName: string;
@ -368,7 +388,7 @@ export function pluginDatabaseService(db: Db) {
error: unknown;
}): Promise<void> {
const message = input.error instanceof Error ? input.error.message : String(input.error);
await db
await client
.insert(pluginMigrations)
.values({
pluginId: input.pluginId,
@ -391,7 +411,7 @@ export function pluginDatabaseService(db: Db) {
appliedAt: null,
},
});
await db
await client
.update(pluginDatabaseNamespaces)
.set({ status: "migration_failed", updatedAt: new Date() })
.where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId));
@ -400,7 +420,12 @@ export function pluginDatabaseService(db: Db) {
return {
ensureNamespace,
async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) {
async applyMigrations(
pluginId: string,
manifest: PaperclipPluginManifestV1,
packageRoot: string,
options: ApplyPluginMigrationsOptions = {},
) {
if (!manifest.database) return null;
const namespace = await ensureNamespace(pluginId, manifest);
if (!namespace) return null;
@ -409,13 +434,14 @@ export function pluginDatabaseService(db: Db) {
const migrationFiles = await listSqlMigrationFiles(migrationDir);
const coreReadTables = manifest.database.coreReadTables ?? [];
const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16);
const persistFailure = options.persistFailure ?? true;
await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
const applyWithClient = async (client: PluginDatabaseClient) => {
await client.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
for (const migrationKey of migrationFiles) {
const content = await readFile(path.join(migrationDir, migrationKey), "utf8");
const checksum = createHash("sha256").update(content).digest("hex");
const existingRows = await tx
const existingRows = await client
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey)))
@ -435,9 +461,9 @@ export function pluginDatabaseService(db: Db) {
}
for (const statement of statements) {
validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables);
await tx.execute(sql.raw(statement));
await client.execute(sql.raw(statement));
}
await tx
await client
.insert(pluginMigrations)
.values({
pluginId,
@ -461,19 +487,27 @@ export function pluginDatabaseService(db: Db) {
},
});
} catch (error) {
await recordMigrationFailure({
pluginId,
pluginKey: manifest.id,
namespaceName: namespace.namespaceName,
migrationKey,
checksum,
pluginVersion: manifest.version,
error,
});
if (persistFailure) {
await recordMigrationFailure(db, {
pluginId,
pluginKey: manifest.id,
namespaceName: namespace.namespaceName,
migrationKey,
checksum,
pluginVersion: manifest.version,
error,
});
}
throw error;
}
}
});
};
if (typeof db.transaction === "function") {
await db.transaction(async (tx) => applyWithClient(tx as PluginDatabaseClient));
} else {
await applyWithClient(db);
}
return namespace;
},

View file

@ -22,6 +22,7 @@ import type {
PluginIssueOrchestrationSummary,
} from "@paperclipai/plugin-sdk";
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
@ -34,12 +35,27 @@ import { budgetService } from "./budgets.js";
import { issueApprovalService } from "./issue-approvals.js";
import { subscribeCompanyLiveEvents } from "./live-events.js";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { activityService } from "./activity.js";
import { costService } from "./costs.js";
import { assetService } from "./assets.js";
import { pluginRegistryService } from "./plugin-registry.js";
import { pluginStateStore } from "./plugin-state-store.js";
import { pluginDatabaseService } from "./plugin-database.js";
import { pluginManagedAgentService } from "./plugin-managed-agents.js";
import { pluginManagedRoutineService } from "./plugin-managed-routines.js";
import {
assertConfiguredLocalFolder,
assertWritableConfiguredLocalFolder,
getStoredLocalFolders,
inspectPluginLocalFolder,
listPluginLocalFolderEntries,
preparePluginLocalFolder,
readPluginLocalFolderText,
requireLocalFolderDeclaration,
setStoredLocalFolder,
writePluginLocalFolderTextAtomic,
} from "./plugin-local-folders.js";
import { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
import { logActivity } from "./activity-log.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
@ -460,7 +476,7 @@ export function buildHostServices(
pluginKey: string,
eventBus: PluginEventBus,
notifyWorker?: (method: string, params: unknown) => void,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 } = {},
): HostServices & { dispose(): void } {
const registry = pluginRegistryService(db);
const stateStore = pluginStateStore(db);
@ -468,6 +484,31 @@ export function buildHostServices(
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
const companies = companyService(db);
const agents = agentService(db);
const managedAgents = pluginManagedAgentService(db, {
pluginId,
pluginKey,
manifest: options.manifest,
instructionTemplateVariables: async (companyId) => {
const variables: Record<string, string | null | undefined> = {};
for (const declaration of options.manifest?.localFolders ?? []) {
const status = await inspectPluginLocalFolder({
folderKey: declaration.folderKey,
declaration,
storedConfig: await getStoredLocalFolderConfig(companyId, declaration.folderKey),
});
const prefix = `localFolders.${declaration.folderKey}`;
variables[`${prefix}.path`] = status.realPath ?? status.path ?? null;
variables[`${prefix}.agentsPath`] = status.realPath ? path.join(status.realPath, "AGENTS.md") : null;
}
return variables;
},
});
const managedRoutines = pluginManagedRoutineService(db, {
pluginId,
pluginKey,
manifest: options.manifest,
pluginWorkerManager: options.pluginWorkerManager,
});
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@ -518,6 +559,23 @@ export function buildHostServices(
*/
const ensurePluginAvailableForCompany = async (_companyId: string) => {};
const getLocalFolderDeclaration = (folderKey: string) =>
requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey);
const getStoredLocalFolderConfig = async (companyId: string, folderKey: string) => {
ensureCompanyId(companyId);
await ensurePluginAvailableForCompany(companyId);
const settings = await registry.getCompanySettings(pluginId, companyId);
return getStoredLocalFolders(settings?.settingsJson)[folderKey] ?? null;
};
const inspectStoredLocalFolder = async (companyId: string, folderKey: string) =>
inspectPluginLocalFolder({
folderKey,
declaration: getLocalFolderDeclaration(folderKey),
storedConfig: await getStoredLocalFolderConfig(companyId, folderKey),
});
const inCompany = <T extends { companyId: string | null | undefined }>(
record: T | null | undefined,
companyId: string,
@ -752,6 +810,86 @@ export function buildHostServices(
},
},
localFolders: {
async declarations() {
return options.manifest?.localFolders ?? [];
},
async configure(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const declaration = getLocalFolderDeclaration(params.folderKey);
const existing = await registry.getCompanySettings(pluginId, companyId);
const existingConfig = getStoredLocalFolders(existing?.settingsJson)[params.folderKey] ?? null;
await preparePluginLocalFolder({
folderKey: params.folderKey,
declaration,
storedConfig: existingConfig,
overrideConfig: {
path: params.path,
},
});
const status = await inspectPluginLocalFolder({
folderKey: params.folderKey,
declaration,
storedConfig: existingConfig,
overrideConfig: {
path: params.path,
},
});
const nextSettings = setStoredLocalFolder(existing?.settingsJson, params.folderKey, {
path: params.path,
access: status.access,
requiredDirectories: status.requiredDirectories,
requiredFiles: status.requiredFiles,
});
await registry.upsertCompanySettings(pluginId, companyId, {
enabled: existing?.enabled ?? true,
settingsJson: nextSettings,
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
});
return status;
},
async status(params) {
return inspectStoredLocalFolder(params.companyId, params.folderKey);
},
async list(params) {
const status = await inspectStoredLocalFolder(params.companyId, params.folderKey);
assertConfiguredLocalFolder(status);
const listing = await listPluginLocalFolderEntries(status.realPath!, {
relativePath: params.relativePath,
recursive: params.recursive,
maxEntries: params.maxEntries,
});
return { ...listing, folderKey: params.folderKey };
},
async readText(params) {
const status = await inspectStoredLocalFolder(params.companyId, params.folderKey);
assertConfiguredLocalFolder(status);
return readPluginLocalFolderText(status.realPath!, params.relativePath);
},
async writeTextAtomic(params) {
const companyId = ensureCompanyId(params.companyId);
await preparePluginLocalFolder({
folderKey: params.folderKey,
declaration: getLocalFolderDeclaration(params.folderKey),
storedConfig: await getStoredLocalFolderConfig(companyId, params.folderKey),
});
const status = await inspectStoredLocalFolder(companyId, params.folderKey);
assertWritableConfiguredLocalFolder(status);
if (status.access !== "readWrite" || !status.writable) {
throw new Error("Local folder is not configured for writes");
}
await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents);
return inspectStoredLocalFolder(companyId, params.folderKey);
},
},
state: {
async get(params) {
return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, {
@ -1013,6 +1151,77 @@ export function buildHostServices(
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
};
},
async getManaged(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return projects.resolveManagedProject({
companyId,
pluginId,
pluginKey,
projectKey: params.projectKey,
createIfMissing: false,
});
},
async reconcileManaged(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return projects.resolveManagedProject({
companyId,
pluginId,
pluginKey,
projectKey: params.projectKey,
});
},
async resetManaged(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return projects.resolveManagedProject({
companyId,
pluginId,
pluginKey,
projectKey: params.projectKey,
reset: true,
});
},
},
routines: {
async managedGet(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedRoutines.get(params.routineKey, companyId);
},
async managedReconcile(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedRoutines.reconcile(params.routineKey, companyId, {
assigneeAgentId: params.assigneeAgentId,
projectId: params.projectId,
});
},
async managedReset(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedRoutines.reset(params.routineKey, companyId, {
assigneeAgentId: params.assigneeAgentId,
projectId: params.projectId,
});
},
async managedUpdate(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedRoutines.update(params.routineKey, companyId, {
status: params.status,
});
},
async managedRun(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedRoutines.run(params.routineKey, companyId, {
assigneeAgentId: params.assigneeAgentId,
projectId: params.projectId,
});
},
},
issues: {
@ -1031,8 +1240,12 @@ export function buildHostServices(
async create(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params;
const normalizedOriginKind = normalizePluginOriginKind(originKind);
const { actorAgentId, actorUserId, actorRunId, originKind, surfaceVisibility, ...issueInput } = params;
const normalizedOriginKind = normalizePluginOriginKind(
surfaceVisibility === "plugin_operation" && !originKind
? pluginOperationIssueOriginKind(pluginKey)
: originKind,
);
const issue = (await issues.create(companyId, {
...(issueInput as any),
originKind: normalizedOriginKind,
@ -1641,6 +1854,21 @@ export function buildHostServices(
if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy");
return { runId: run.id };
},
async managedGet(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedAgents.get(params.agentKey, companyId);
},
async managedReconcile(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedAgents.reconcile(params.agentKey, companyId);
},
async managedReset(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return managedAgents.reset(params.agentKey, companyId);
},
},
goals: {

View file

@ -29,7 +29,7 @@ import { readdir, readFile, rm, stat } from "node:fs/promises";
import { execFile } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import type { Db } from "@paperclipai/db";
import type {
@ -248,6 +248,8 @@ export interface PluginRuntimeServices {
instanceInfo: {
instanceId: string;
hostVersion: string;
deploymentMode?: "local_trusted" | "authenticated";
deploymentExposure?: "private" | "public";
};
}
@ -932,7 +934,10 @@ export function pluginLoader(
try {
// Dynamic import works for both .js (ESM) and .cjs (CJS) manifests
const mod = await import(manifestPath) as Record<string, unknown>;
const manifestUrl = pathToFileURL(manifestPath);
const manifestStat = await stat(manifestPath);
manifestUrl.searchParams.set("mtime", String(Math.trunc(manifestStat.mtimeMs)));
const mod = await import(manifestUrl.href) as Record<string, unknown>;
// The manifest may be the default export or the module itself
raw = mod["default"] ?? mod;
} catch (err) {
@ -944,6 +949,51 @@ export function pluginLoader(
return manifestValidator.parseOrThrow(raw);
}
async function loadManifestFromPackageRoot(
packageRoot: string,
): Promise<PaperclipPluginManifestV1 | null> {
const pkgJson = await readPackageJson(packageRoot);
if (!pkgJson) return null;
const manifestPath = resolveManifestPath(packageRoot, pkgJson);
if (!manifestPath || !existsSync(manifestPath)) return null;
return loadManifestFromPath(manifestPath);
}
async function refreshPluginManifestFromPackage(
plugin: PluginRecord,
packageRoot: string,
): Promise<PluginRecord> {
const manifest = await loadManifestFromPackageRoot(packageRoot);
if (!manifest) {
throw new Error(`Plugin package ${plugin.packageName} no longer exposes a Paperclip manifest`);
}
if (manifest.id !== plugin.pluginKey) {
throw new Error(
`Plugin manifest ID '${manifest.id}' does not match installed plugin '${plugin.pluginKey}'`,
);
}
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
return plugin;
}
await registry.update(plugin.id, {
packageName: plugin.packageName,
version: manifest.version,
manifest,
});
return {
...plugin,
version: manifest.version,
apiVersion: manifest.apiVersion,
categories: manifest.categories,
manifestJson: manifest,
};
}
/**
* Build a DiscoveredPlugin from a resolved package directory, or null
* if the package is not a Paperclip plugin.
@ -1256,22 +1306,43 @@ export function pluginLoader(
async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> {
const discovered = await fetchAndValidate(installOptions);
const manifest = discovered.manifest!;
// Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved)
await registry.install(
{
packageName: discovered.packageName,
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
},
discovered.manifest!,
);
// Step 6: Persist install record and apply plugin-owned schema migrations
// in one database transaction. If migration validation fails, the plugin
// row, namespace record, migration ledger, and created schema all roll back.
const installDb = manifest.database ? migrationDb : db;
await installDb.transaction(async (tx) => {
const txDb = tx as unknown as Db;
const txRegistry = pluginRegistryService(txDb);
const installed = await txRegistry.install(
{
packageName: discovered.packageName,
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
},
manifest,
);
if (!installed) {
throw new Error(`Plugin install did not return a registry row: ${manifest.id}`);
}
if (manifest.database) {
await pluginDatabaseService(txDb).applyMigrations(
installed.id,
manifest,
discovered.packagePath,
{ persistFailure: false },
);
}
});
log.info(
{
pluginId: discovered.manifest!.id,
pluginId: manifest.id,
packageName: discovered.packageName,
version: discovered.version,
capabilities: discovered.manifest!.capabilities,
capabilities: manifest.capabilities,
},
"plugin-loader: plugin installed successfully",
);
@ -1663,9 +1734,10 @@ export function pluginLoader(
* `error` in the database when activation fails.
*/
async function activatePlugin(plugin: PluginRecord): Promise<PluginLoadResult> {
const manifest = plugin.manifestJson;
const pluginId = plugin.id;
const pluginKey = plugin.pluginKey;
let activePlugin = plugin;
let manifest = activePlugin.manifestJson;
const registered: PluginLoadResult["registered"] = {
worker: false,
@ -1705,8 +1777,10 @@ export function pluginLoader(
// ------------------------------------------------------------------
// 1. Resolve worker entrypoint
// ------------------------------------------------------------------
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir);
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
manifest = activePlugin.manifestJson;
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
// ------------------------------------------------------------------
// 2. Apply restricted database migrations before worker startup
@ -1746,12 +1820,16 @@ export function pluginLoader(
databaseNamespace,
hostHandlers,
autoRestart: true,
env: {
PAPERCLIP_DEPLOYMENT_MODE: instanceInfo.deploymentMode ?? "",
PAPERCLIP_DEPLOYMENT_EXPOSURE: instanceInfo.deploymentExposure ?? "",
},
};
// Repo-local plugin installs can resolve workspace TS sources at runtime
// (for example @paperclipai/shared exports). Run those workers through
// the tsx loader so first-party example plugins work in development.
if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) {
workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH];
}
@ -1842,13 +1920,13 @@ export function pluginLoader(
{
pluginId,
pluginKey,
version: plugin.version,
version: activePlugin.version,
registered,
},
"plugin-loader: plugin activated successfully",
);
return { plugin, success: true, registered };
return { plugin: activePlugin, success: true, registered };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@ -1872,7 +1950,7 @@ export function pluginLoader(
}
return {
plugin,
plugin: activePlugin,
success: false,
error: errorMessage,
registered,

View file

@ -0,0 +1,564 @@
import { constants as fsConstants, promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type {
PluginLocalFolderDeclaration,
PluginLocalFolderEntry,
PluginLocalFolderListing,
PluginLocalFolderProblem,
PluginLocalFolderStatus,
} from "@paperclipai/plugin-sdk";
import { badRequest, forbidden, notFound } from "../errors.js";
export interface StoredPluginLocalFolderConfig {
path: string;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
updatedAt?: string;
}
export interface PluginLocalFolderSettingsJson {
localFolders?: Record<string, StoredPluginLocalFolderConfig>;
[key: string]: unknown;
}
const LOCAL_FOLDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
function problem(
code: PluginLocalFolderProblem["code"],
message: string,
problemPath?: string,
): PluginLocalFolderProblem {
return { code, message, path: problemPath };
}
export function assertPluginLocalFolderKey(folderKey: string) {
if (!LOCAL_FOLDER_KEY_PATTERN.test(folderKey)) {
throw badRequest("folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens");
}
}
export function findLocalFolderDeclaration(
declarations: PluginLocalFolderDeclaration[] | undefined,
folderKey: string,
) {
return declarations?.find((declaration) => declaration.folderKey === folderKey) ?? null;
}
export function requireLocalFolderDeclaration(
declarations: PluginLocalFolderDeclaration[] | undefined,
folderKey: string,
) {
assertPluginLocalFolderKey(folderKey);
const declaration = findLocalFolderDeclaration(declarations, folderKey);
if (!declaration) {
throw badRequest("Local folder key is not declared by this plugin manifest");
}
return declaration;
}
function normalizeRelativePath(relativePath: string): string {
if (
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.includes("\\") ||
relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
) {
throw forbidden("Local folder relative paths must stay inside the configured root");
}
return relativePath;
}
function validateRequiredPath(pathValue: string, label: string): string {
try {
return normalizeRelativePath(pathValue);
} catch {
throw badRequest(`${label} must contain only relative paths without traversal, empty segments, or backslashes`);
}
}
function normalizeListRelativePath(relativePath: string | null | undefined): string | null {
const trimmed = relativePath?.trim();
if (!trimmed) return null;
return normalizeRelativePath(trimmed);
}
function normalizeMaxEntries(value: number | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 1000;
return Math.max(1, Math.min(5000, Math.floor(value)));
}
function mergeFolderConfig(
declaration: PluginLocalFolderDeclaration | null,
stored: StoredPluginLocalFolderConfig | null,
override?: Partial<StoredPluginLocalFolderConfig>,
): StoredPluginLocalFolderConfig | null {
const pathValue = override?.path ?? stored?.path;
if (!pathValue) return null;
return {
path: pathValue,
access: declaration?.access ?? override?.access ?? stored?.access ?? "readWrite",
requiredDirectories:
declaration?.requiredDirectories ?? override?.requiredDirectories ?? stored?.requiredDirectories ?? [],
requiredFiles:
declaration?.requiredFiles ?? override?.requiredFiles ?? stored?.requiredFiles ?? [],
updatedAt: stored?.updatedAt,
};
}
export function getStoredLocalFolders(settingsJson: Record<string, unknown> | null | undefined) {
const folders = (settingsJson as PluginLocalFolderSettingsJson | undefined)?.localFolders;
if (!folders || typeof folders !== "object") return {};
return folders;
}
export function setStoredLocalFolder(
settingsJson: Record<string, unknown> | null | undefined,
folderKey: string,
config: StoredPluginLocalFolderConfig,
): PluginLocalFolderSettingsJson {
return {
...(settingsJson ?? {}),
localFolders: {
...getStoredLocalFolders(settingsJson),
[folderKey]: {
...config,
updatedAt: new Date().toISOString(),
},
},
};
}
export async function inspectPluginLocalFolder(input: {
folderKey: string;
declaration?: PluginLocalFolderDeclaration | null;
storedConfig?: StoredPluginLocalFolderConfig | null;
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
}): Promise<PluginLocalFolderStatus> {
assertPluginLocalFolderKey(input.folderKey);
const config = mergeFolderConfig(
input.declaration ?? null,
input.storedConfig ?? null,
input.overrideConfig,
);
const access = config?.access ?? input.declaration?.access ?? "readWrite";
const requiredDirectories = (config?.requiredDirectories ?? []).map((item) =>
validateRequiredPath(item, "requiredDirectories"),
);
const requiredFiles = (config?.requiredFiles ?? []).map((item) =>
validateRequiredPath(item, "requiredFiles"),
);
const checkedAt = new Date().toISOString();
if (!config?.path) {
return {
folderKey: input.folderKey,
configured: false,
path: null,
realPath: null,
access,
readable: false,
writable: false,
requiredDirectories,
requiredFiles,
missingDirectories: requiredDirectories,
missingFiles: requiredFiles,
healthy: false,
problems: [problem("not_configured", "No local folder path is configured.")],
checkedAt,
};
}
const configuredPath = path.resolve(config.path);
const problems: PluginLocalFolderProblem[] = [];
const missingDirectories: string[] = [];
const missingFiles: string[] = [];
const markRequiredPathsMissing = () => {
missingDirectories.push(...requiredDirectories);
missingFiles.push(...requiredFiles);
};
let realPath: string | null = null;
let readable = false;
let writable = false;
if (!path.isAbsolute(config.path)) {
problems.push(problem("not_absolute", "Local folder path must be absolute.", config.path));
}
try {
const stat = await fs.stat(configuredPath);
if (!stat.isDirectory()) {
problems.push(problem("not_directory", "Configured local folder path is not a directory.", configuredPath));
markRequiredPathsMissing();
} else {
realPath = await fs.realpath(configuredPath);
try {
await fs.access(realPath, fsConstants.R_OK);
readable = true;
} catch {
problems.push(problem("not_readable", "Configured local folder is not readable.", configuredPath));
}
if (access === "readWrite") {
try {
await fs.access(realPath, fsConstants.W_OK);
const probePath = path.join(realPath, `.paperclip-local-folder-probe-${process.pid}-${Date.now()}`);
await fs.writeFile(probePath, "");
await fs.rm(probePath, { force: true });
writable = true;
} catch {
problems.push(problem("not_writable", "Configured local folder is not writable.", configuredPath));
}
}
for (const requiredDir of requiredDirectories) {
const requiredStatus = await inspectChildPath(realPath, requiredDir, "directory");
if (!requiredStatus.exists) {
missingDirectories.push(requiredDir);
problems.push(problem("missing_directory", "Required directory is missing.", requiredDir));
} else if (!requiredStatus.contained) {
problems.push(problem("symlink_escape", "Required directory escapes the configured root.", requiredDir));
} else if (!requiredStatus.matchesKind) {
missingDirectories.push(requiredDir);
problems.push(problem("missing_directory", "Required path is not a directory.", requiredDir));
}
}
for (const requiredFile of requiredFiles) {
const requiredStatus = await inspectChildPath(realPath, requiredFile, "file");
if (!requiredStatus.exists) {
missingFiles.push(requiredFile);
problems.push(problem("missing_file", "Required file is missing.", requiredFile));
} else if (!requiredStatus.contained) {
problems.push(problem("symlink_escape", "Required file escapes the configured root.", requiredFile));
} else if (!requiredStatus.matchesKind) {
missingFiles.push(requiredFile);
problems.push(problem("missing_file", "Required path is not a file.", requiredFile));
}
}
}
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
problems.push(problem(code === "ENOENT" ? "missing" : "not_readable", "Configured local folder cannot be inspected.", configuredPath));
if (code === "ENOENT") {
markRequiredPathsMissing();
}
}
return {
folderKey: input.folderKey,
configured: true,
path: configuredPath,
realPath,
access,
readable,
writable: access === "read" ? false : writable,
requiredDirectories,
requiredFiles,
missingDirectories,
missingFiles,
healthy:
problems.length === 0 &&
readable &&
(access === "read" || writable),
problems,
checkedAt,
};
}
function isInsideRoot(rootRealPath: string, candidateRealPath: string) {
const relative = path.relative(rootRealPath, candidateRealPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function assertPathInsideRoot(rootRealPath: string, candidatePath: string) {
const candidateRealPath = await fs.realpath(candidatePath);
if (!isInsideRoot(rootRealPath, candidateRealPath)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return candidateRealPath;
}
async function ensureDirectoryInsideRoot(rootRealPath: string, relativePath: string) {
const normalized = normalizeRelativePath(relativePath);
const segments = normalized.split("/");
let currentRealPath = rootRealPath;
for (const segment of segments) {
const nextPath = path.join(currentRealPath, segment);
try {
const stat = await fs.stat(nextPath);
if (!stat.isDirectory()) {
throw badRequest("Required directory path exists but is not a directory");
}
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT") throw error;
await fs.mkdir(nextPath);
}
const nextRealPath = await fs.realpath(nextPath);
if (!isInsideRoot(rootRealPath, nextRealPath)) {
throw forbidden("Local folder symlink escape is not allowed");
}
currentRealPath = nextRealPath;
}
}
export async function preparePluginLocalFolder(input: {
folderKey: string;
declaration?: PluginLocalFolderDeclaration | null;
storedConfig?: StoredPluginLocalFolderConfig | null;
overrideConfig?: Partial<StoredPluginLocalFolderConfig>;
}) {
assertPluginLocalFolderKey(input.folderKey);
const config = mergeFolderConfig(
input.declaration ?? null,
input.storedConfig ?? null,
input.overrideConfig,
);
const access = config?.access ?? input.declaration?.access ?? "readWrite";
if (!config?.path || access !== "readWrite" || !path.isAbsolute(config.path)) return;
const configuredPath = path.resolve(config.path);
try {
const stat = await fs.stat(configuredPath);
if (!stat.isDirectory()) return;
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT") return;
try {
await fs.mkdir(configuredPath, { recursive: true });
} catch {
return;
}
}
const rootRealPath = await fs.realpath(configuredPath);
for (const requiredDir of config.requiredDirectories ?? []) {
await ensureDirectoryInsideRoot(rootRealPath, validateRequiredPath(requiredDir, "requiredDirectories"));
}
}
async function inspectChildPath(
rootRealPath: string,
relativePath: string,
kind: "directory" | "file",
) {
let resolvedPath: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
try {
resolvedPath = await resolvePluginLocalFolderPath(rootRealPath, relativePath, {
mustExist: true,
allowMissingLeaf: true,
});
} catch {
return { exists: true, contained: false, matchesKind: false };
}
if (!resolvedPath.exists) {
return { exists: false, contained: true, matchesKind: false };
}
const stat = await fs.stat(resolvedPath.realPath);
return {
exists: true,
contained: true,
matchesKind: kind === "directory" ? stat.isDirectory() : stat.isFile(),
};
}
export async function resolvePluginLocalFolderPath(
rootPath: string,
relativePath: string,
options?: { mustExist?: boolean; allowMissingLeaf?: boolean },
) {
const normalized = normalizeRelativePath(relativePath);
const rootRealPath = await fs.realpath(rootPath);
const absolutePath = path.resolve(rootRealPath, normalized);
const relativeFromRoot = path.relative(rootRealPath, absolutePath);
if (relativeFromRoot.startsWith("..") || path.isAbsolute(relativeFromRoot)) {
throw forbidden("Local folder path traversal is not allowed");
}
try {
const realPath = await fs.realpath(absolutePath);
const realRelative = path.relative(rootRealPath, realPath);
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return { absolutePath, realPath, exists: true };
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
if (code !== "ENOENT" || options?.mustExist) {
if (options?.allowMissingLeaf && code === "ENOENT") {
return { absolutePath, realPath: absolutePath, exists: false };
}
throw error;
}
const parentRealPath = await fs.realpath(path.dirname(absolutePath));
const parentRelative = path.relative(rootRealPath, parentRealPath);
if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) {
throw forbidden("Local folder symlink escape is not allowed");
}
return { absolutePath, realPath: absolutePath, exists: false };
}
}
export async function readPluginLocalFolderText(rootPath: string, relativePath: string) {
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath, { mustExist: true });
const stat = await fs.stat(resolved.realPath);
if (!stat.isFile()) {
throw badRequest("Local folder read target must be a file");
}
return fs.readFile(resolved.realPath, "utf8");
}
export async function listPluginLocalFolderEntries(
rootPath: string,
options: { relativePath?: string | null; recursive?: boolean; maxEntries?: number } = {},
): Promise<PluginLocalFolderListing> {
const rootRealPath = await fs.realpath(rootPath);
const relativePath = normalizeListRelativePath(options.relativePath);
const target = relativePath
? await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true })
: { absolutePath: rootRealPath, realPath: rootRealPath, exists: true };
const targetStat = await fs.stat(target.realPath);
if (!targetStat.isDirectory()) {
throw badRequest("Local folder list target must be a directory");
}
const maxEntries = normalizeMaxEntries(options.maxEntries);
const entries: PluginLocalFolderEntry[] = [];
let truncated = false;
const visit = async (directoryRealPath: string, directoryRelativePath: string | null) => {
if (truncated) return;
const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true });
dirents.sort((a, b) => a.name.localeCompare(b.name));
for (const dirent of dirents) {
if (entries.length >= maxEntries) {
truncated = true;
return;
}
const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name;
let resolvedChild: Awaited<ReturnType<typeof resolvePluginLocalFolderPath>>;
try {
resolvedChild = await resolvePluginLocalFolderPath(rootRealPath, childRelativePath, { mustExist: true });
} catch {
continue;
}
const stat = await fs.stat(resolvedChild.realPath).catch(() => null);
if (!stat) continue;
const kind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
if (!kind) continue;
entries.push({
path: childRelativePath,
name: dirent.name,
kind,
size: kind === "file" ? stat.size : null,
modifiedAt: stat.mtime.toISOString(),
});
if (options.recursive && kind === "directory") {
await visit(resolvedChild.realPath, childRelativePath);
if (truncated) return;
}
}
};
await visit(target.realPath, relativePath);
return {
folderKey: "list-result",
relativePath,
entries,
truncated,
};
}
export async function writePluginLocalFolderTextAtomic(
rootPath: string,
relativePath: string,
contents: string,
) {
const rootRealPath = await fs.realpath(rootPath);
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath);
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
const tempPath = path.join(
path.dirname(resolved.absolutePath),
`.paperclip-${path.basename(resolved.absolutePath)}-${process.pid}-${randomUUID()}.tmp`,
);
let tempCreated = false;
try {
const handle = await fs.open(tempPath, "wx");
tempCreated = true;
try {
await assertPathInsideRoot(rootRealPath, tempPath);
await handle.writeFile(contents, "utf8");
await handle.sync();
} finally {
await handle.close();
}
} catch (error) {
if (tempCreated) {
await fs.rm(tempPath, { force: true });
}
throw error;
}
try {
await resolvePluginLocalFolderPath(rootRealPath, relativePath);
await fs.rename(tempPath, resolved.absolutePath);
await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true });
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
if (process.platform !== "win32") {
const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r");
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
}
return inspectPluginLocalFolder({
folderKey: "write-result",
storedConfig: {
path: rootPath,
access: "readWrite",
},
});
}
export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) {
return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey);
}
export function assertConfiguredLocalFolder(status: PluginLocalFolderStatus) {
if (!status.configured || !status.realPath || !status.readable) {
throw notFound("Local folder is not configured or readable");
}
if (!status.healthy) {
throw badRequest("Local folder is not healthy");
}
}
export function assertWritableConfiguredLocalFolder(status: PluginLocalFolderStatus) {
if (!status.configured || !status.realPath || !status.readable) {
throw notFound("Local folder is not configured or readable");
}
const onlyMissingRequiredPaths = status.problems.every((item) =>
item.code === "missing_directory" || item.code === "missing_file"
);
if (!status.healthy && !onlyMissingRequiredPaths) {
throw badRequest("Local folder is not healthy");
}
}

View file

@ -0,0 +1,508 @@
import { and, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
companies,
pluginEntities,
pluginManagedResources,
} from "@paperclipai/db";
import type {
Agent,
PaperclipPluginManifestV1,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
} from "@paperclipai/shared";
import { notFound } from "../errors.js";
import { agentService } from "./agents.js";
import { approvalService } from "./approvals.js";
import { logActivity } from "./activity-log.js";
import { agentInstructionsService } from "./agent-instructions.js";
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
interface PluginManagedAgentServiceOptions {
pluginId: string;
pluginKey: string;
manifest?: PaperclipPluginManifestV1 | null;
instructionTemplateVariables?: (companyId: string) => Promise<Record<string, string | null | undefined>>;
}
function bindingExternalId(companyId: string, agentKey: string) {
return `managed:agent:${companyId}:${agentKey}`;
}
function managedMetadata(
pluginId: string,
pluginKey: string,
declaration: PluginManagedAgentDeclaration,
existing?: Record<string, unknown> | null,
) {
return {
...(existing ?? {}),
paperclipManagedResource: {
pluginId,
pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
},
pluginManagedAgent: {
pluginId,
pluginKey,
agentKey: declaration.agentKey,
displayName: declaration.displayName,
instructions: declaration.instructions ?? null,
},
};
}
function normalizeAdapterType(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function fallbackAdapterType(declaration: PluginManagedAgentDeclaration) {
return normalizeAdapterType(declaration.adapterType) ?? DEFAULT_MANAGED_AGENT_ADAPTER_TYPE;
}
function adapterPreference(declaration: PluginManagedAgentDeclaration) {
const seen = new Set<string>();
const preference: string[] = [];
for (const value of declaration.adapterPreference ?? []) {
const adapterType = normalizeAdapterType(value);
if (!adapterType || seen.has(adapterType)) continue;
seen.add(adapterType);
preference.push(adapterType);
}
return preference;
}
function selectPreferredAdapterType(
declaration: PluginManagedAgentDeclaration,
usage: Array<{ adapterType: string; count: number }>,
) {
const fallback = fallbackAdapterType(declaration);
const preference = adapterPreference(declaration);
if (preference.length === 0) return fallback;
const rank = new Map(preference.map((adapterType, index) => [adapterType, index]));
let selected: { adapterType: string; count: number; rank: number } | null = null;
for (const entry of usage) {
const adapterRank = rank.get(entry.adapterType);
if (adapterRank === undefined) continue;
if (
!selected ||
entry.count > selected.count ||
(entry.count === selected.count && adapterRank < selected.rank)
) {
selected = { ...entry, rank: adapterRank };
}
}
return selected?.adapterType ?? fallback;
}
function declarationPatch(declaration: PluginManagedAgentDeclaration, input: { adapterType?: string } = {}) {
return {
name: declaration.displayName,
role: declaration.role ?? "general",
title: declaration.title ?? null,
icon: declaration.icon ?? null,
capabilities: declaration.capabilities ?? null,
adapterType: input.adapterType ?? fallbackAdapterType(declaration),
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
permissions: declaration.permissions ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
};
}
function applyInstructionTemplateVariables(
content: string,
variables: Record<string, string | null | undefined>,
) {
let next = content;
for (const [key, value] of Object.entries(variables)) {
next = next.replaceAll(`{{${key}}}`, value?.trim() || "(not configured)");
}
return next;
}
function rowIsManagedAgent(
row: typeof agents.$inferSelect,
pluginKey: string,
agentKey: string,
) {
const metadata = row.metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false;
const marker = (metadata as Record<string, unknown>).paperclipManagedResource;
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false;
const record = marker as Record<string, unknown>;
return (
record.pluginKey === pluginKey
&& record.resourceKind === "agent"
&& record.resourceKey === agentKey
);
}
export function pluginManagedAgentService(
db: Db,
options: PluginManagedAgentServiceOptions,
) {
const agentSvc = agentService(db);
const approvalSvc = approvalService(db);
const instructions = agentInstructionsService();
function declarationFor(agentKey: string) {
const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey);
if (!declaration) {
throw notFound(`Managed agent declaration not found: ${agentKey}`);
}
return declaration;
}
async function getBinding(companyId: string, agentKey: string) {
return db
.select()
.from(pluginEntities)
.where(
and(
eq(pluginEntities.pluginId, options.pluginId),
eq(pluginEntities.entityType, MANAGED_AGENT_ENTITY_TYPE),
eq(pluginEntities.externalId, bindingExternalId(companyId, agentKey)),
),
)
.then((rows) => rows[0] ?? null);
}
async function upsertBinding(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agentId: string,
extraData: Record<string, unknown> = {},
effectiveAdapterType?: string,
) {
const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration));
const defaultsJson = {
agentKey: declaration.agentKey,
displayName: declaration.displayName,
role: declaration.role ?? "general",
title: declaration.title ?? null,
icon: declaration.icon ?? null,
capabilities: declaration.capabilities ?? null,
adapterType,
adapterPreference: declaration.adapterPreference ?? null,
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
permissions: declaration.permissions ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
instructions: declaration.instructions ?? null,
};
const managedResource = await db
.select({ id: pluginManagedResources.id })
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, "agent"),
eq(pluginManagedResources.resourceKey, declaration.agentKey),
))
.then((rows) => rows[0] ?? null);
if (managedResource) {
await db
.update(pluginManagedResources)
.set({ resourceId: agentId, defaultsJson, updatedAt: new Date() })
.where(eq(pluginManagedResources.id, managedResource.id));
} else {
await db.insert(pluginManagedResources).values({
companyId,
pluginId: options.pluginId,
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
resourceId: agentId,
defaultsJson,
});
}
const externalId = bindingExternalId(companyId, declaration.agentKey);
const data = {
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
agentId,
adapterType,
declarationSnapshot: declaration,
lastReconciledAt: new Date().toISOString(),
...extraData,
};
const existing = await getBinding(companyId, declaration.agentKey);
if (existing) {
return db
.update(pluginEntities)
.set({
scopeKind: "company",
scopeId: companyId,
title: declaration.displayName,
status: "resolved",
data,
updatedAt: new Date(),
})
.where(eq(pluginEntities.id, existing.id))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginEntities)
.values({
pluginId: options.pluginId,
entityType: MANAGED_AGENT_ENTITY_TYPE,
scopeKind: "company",
scopeId: companyId,
externalId,
title: declaration.displayName,
status: "resolved",
data,
})
.returning()
.then((rows) => rows[0]);
}
async function findRelinkCandidate(companyId: string, declaration: PluginManagedAgentDeclaration) {
const rows = await db
.select()
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null;
}
async function companyAdapterUsage(companyId: string) {
const rows = await db
.select({ adapterType: agents.adapterType })
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const counts = new Map<string, number>();
for (const row of rows) {
const adapterType = normalizeAdapterType(row.adapterType);
if (!adapterType) continue;
counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1);
}
return [...counts.entries()]
.map(([adapterType, count]) => ({ adapterType, count }))
.sort((a, b) => b.count - a.count || a.adapterType.localeCompare(b.adapterType))
.slice(0, 10);
}
async function resolveManagedAdapterType(companyId: string, declaration: PluginManagedAgentDeclaration) {
return selectPreferredAdapterType(declaration, await companyAdapterUsage(companyId));
}
async function materializeDeclaredInstructions(
companyId: string,
agent: Agent,
declaration: PluginManagedAgentDeclaration,
options: { replaceExisting: boolean },
): Promise<Agent> {
const instructionDeclaration = declaration.instructions;
if (!instructionDeclaration?.content) return agent;
const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md";
const variables = await optionsForInstructionVariables(companyId);
const materialized = await instructions.materializeManagedBundle(
agent,
{ [entryFile]: applyInstructionTemplateVariables(instructionDeclaration.content, variables) },
{
entryFile,
replaceExisting: options.replaceExisting,
clearLegacyPromptTemplate: true,
},
);
const updated = await agentSvc.update(agent.id, {
adapterConfig: materialized.adapterConfig,
}, {
recordRevision: {
source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`,
},
});
return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
}
async function optionsForInstructionVariables(companyId: string) {
return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {};
}
function optionsForRevisionSource() {
return options.pluginKey;
}
function resolution(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agent: Agent | null,
status: PluginManagedAgentResolution["status"],
approvalId?: string | null,
): PluginManagedAgentResolution {
return {
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
companyId,
agentId: agent?.id ?? null,
agent,
status,
approvalId: approvalId ?? null,
};
}
async function createManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration) {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const requiresApproval = company.requireBoardApprovalForNewAgents;
const adapterType = await resolveManagedAdapterType(companyId, declaration);
let created = await agentSvc.create(companyId, {
...declarationPatch(declaration, { adapterType }),
status: requiresApproval ? "pending_approval" : declaration.status ?? "idle",
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration),
spentMonthlyCents: 0,
lastHeartbeatAt: null,
}) as Agent;
created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true });
let approvalId: string | null = null;
if (requiresApproval) {
const approval = await approvalSvc.create(companyId, {
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status: "pending",
payload: {
name: created.name,
role: created.role,
title: created.title,
icon: created.icon,
reportsTo: created.reportsTo,
capabilities: created.capabilities,
adapterType: created.adapterType,
adapterConfig: created.adapterConfig,
runtimeConfig: created.runtimeConfig,
budgetMonthlyCents: created.budgetMonthlyCents,
metadata: created.metadata,
agentId: created.id,
sourcePluginId: options.pluginId,
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
updatedAt: new Date(),
});
approvalId = approval.id;
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "approval.created",
entityType: "approval",
entityId: approval.id,
details: {
type: "hire_agent",
linkedAgentId: created.id,
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
});
}
await upsertBinding(companyId, declaration, created.id, { approvalId }, adapterType);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_agent.created",
entityType: "agent",
entityId: created.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
adapterType,
requiresApproval,
approvalId,
},
});
return resolution(companyId, declaration, created as Agent, "created", approvalId);
}
async function get(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const binding = await getBinding(companyId, agentKey);
const boundAgentId = typeof binding?.data?.agentId === "string" ? binding.data.agentId : null;
if (!boundAgentId) return resolution(companyId, declaration, null, "missing");
const agent = await agentSvc.getById(boundAgentId);
if (!agent || agent.companyId !== companyId || agent.status === "terminated") {
return resolution(companyId, declaration, null, "missing");
}
return resolution(companyId, declaration, agent as Agent, "resolved");
}
async function reconcile(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const current = await get(agentKey, companyId);
if (current.agent) {
await upsertBinding(companyId, declaration, current.agent.id);
return current;
}
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
if (relinkCandidate) {
await upsertBinding(companyId, declaration, relinkCandidate.id);
const agent = await agentSvc.getById(relinkCandidate.id);
return resolution(companyId, declaration, agent as Agent, "relinked");
}
return createManagedAgent(companyId, declaration);
}
async function reset(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const reconciled = await reconcile(agentKey, companyId);
if (!reconciled.agent) return reconciled;
const currentMetadata = reconciled.agent.metadata && typeof reconciled.agent.metadata === "object"
? reconciled.agent.metadata
: {};
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const updated = await agentSvc.update(reconciled.agent.id, {
...declarationPatch(declaration, { adapterType }),
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
}, {
recordRevision: {
source: `plugin:${options.pluginKey}:managed-agent-reset`,
},
});
if (!updated) throw notFound("Managed agent not found");
const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true });
await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_agent.reset",
entityType: "agent",
entityId: updatedAgent.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
});
return resolution(companyId, declaration, updatedAgent, "reset");
}
return {
get,
reconcile,
reset,
};
}

View file

@ -0,0 +1,523 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
pluginManagedResources,
plugins,
projects,
routines,
routineTriggers,
} from "@paperclipai/db";
import type {
CreateRoutineTrigger,
PluginManagedResourceRef,
PluginManagedRoutineDeclaration,
PluginManagedRoutineResolution,
Routine,
RoutineManagedByPlugin,
RoutineStatus,
} from "@paperclipai/shared";
import { ROUTINE_STATUSES } from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { routineService } from "./routines.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
const MANAGED_ROUTINE_RESOURCE_KIND = "routine";
interface PluginManagedRoutineServiceOptions {
pluginId: string;
pluginKey: string;
manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 | null;
pluginWorkerManager?: PluginWorkerManager;
}
interface RoutineOverrides {
assigneeAgentId?: string | null;
projectId?: string | null;
}
function buildRoutineDefaults(declaration: PluginManagedRoutineDeclaration) {
return {
routineKey: declaration.routineKey,
title: declaration.title,
description: declaration.description ?? null,
assigneeRef: declaration.assigneeRef ?? null,
projectRef: declaration.projectRef ?? null,
goalId: declaration.goalId ?? null,
status: declaration.status ?? null,
priority: declaration.priority ?? "medium",
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
variables: declaration.variables ?? [],
triggers: declaration.triggers ?? [],
issueTemplate: declaration.issueTemplate ?? null,
};
}
function normalizeRef(
pluginKey: string,
ref: PluginManagedResourceRef | null | undefined,
resourceKind: "agent" | "project",
) {
if (!ref) return null;
if (ref.resourceKind !== resourceKind) {
throw unprocessable(`Managed routine ${resourceKind} ref must target ${resourceKind}`);
}
if (ref.pluginKey && ref.pluginKey !== pluginKey) {
throw unprocessable("Managed routine refs must target the declaring plugin");
}
return { ...ref, pluginKey };
}
function managedByPlugin(row: {
id: string;
pluginId: string;
pluginKey: string;
manifestJson: { displayName?: string } | null;
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}): RoutineManagedByPlugin {
return {
id: row.id,
pluginId: row.pluginId,
pluginKey: row.pluginKey,
pluginDisplayName: row.manifestJson?.displayName ?? row.pluginKey,
resourceKind: "routine",
resourceKey: row.resourceKey,
defaultsJson: row.defaultsJson,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function triggerInput(trigger: NonNullable<PluginManagedRoutineDeclaration["triggers"]>[number]): CreateRoutineTrigger {
if (trigger.kind === "schedule") {
if (!trigger.cronExpression) {
throw unprocessable("Managed schedule routine triggers require cronExpression");
}
return {
kind: "schedule",
label: trigger.label ?? null,
enabled: trigger.enabled ?? true,
cronExpression: trigger.cronExpression,
timezone: trigger.timezone ?? "UTC",
};
}
if (trigger.kind === "webhook") {
return {
kind: "webhook",
label: trigger.label ?? null,
enabled: trigger.enabled ?? true,
signingMode: (trigger.signingMode ?? "bearer") as Extract<CreateRoutineTrigger, { kind: "webhook" }>["signingMode"],
replayWindowSec: trigger.replayWindowSec ?? 300,
};
}
return {
kind: "api",
label: trigger.label ?? null,
enabled: trigger.enabled ?? true,
};
}
export function pluginManagedRoutineService(
db: Db,
options: PluginManagedRoutineServiceOptions,
) {
const routinesSvc = routineService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
function declarationFor(routineKey: string) {
const declaration = options.manifest?.routines?.find((routine) => routine.routineKey === routineKey);
if (!declaration) {
throw notFound(`Managed routine declaration not found: ${routineKey}`);
}
return declaration;
}
async function getBinding(companyId: string, routineKey: string) {
return db
.select({
id: pluginManagedResources.id,
companyId: pluginManagedResources.companyId,
pluginId: pluginManagedResources.pluginId,
pluginKey: pluginManagedResources.pluginKey,
resourceKind: pluginManagedResources.resourceKind,
resourceKey: pluginManagedResources.resourceKey,
resourceId: pluginManagedResources.resourceId,
defaultsJson: pluginManagedResources.defaultsJson,
manifestJson: plugins.manifestJson,
createdAt: pluginManagedResources.createdAt,
updatedAt: pluginManagedResources.updatedAt,
})
.from(pluginManagedResources)
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
.where(
and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, MANAGED_ROUTINE_RESOURCE_KIND),
eq(pluginManagedResources.resourceKey, routineKey),
),
)
.then((rows) => rows[0] ?? null);
}
async function upsertBinding(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
routineId: string,
) {
const defaultsJson = buildRoutineDefaults(declaration);
const existing = await getBinding(companyId, declaration.routineKey);
if (existing) {
return db
.update(pluginManagedResources)
.set({
resourceId: routineId,
defaultsJson,
updatedAt: new Date(),
})
.where(eq(pluginManagedResources.id, existing.id))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginManagedResources)
.values({
companyId,
pluginId: options.pluginId,
pluginKey: options.pluginKey,
resourceKind: MANAGED_ROUTINE_RESOURCE_KIND,
resourceKey: declaration.routineKey,
resourceId: routineId,
defaultsJson,
})
.returning()
.then((rows) => rows[0]);
}
async function getRoutineWithManagedBy(companyId: string, declaration: PluginManagedRoutineDeclaration) {
const binding = await getBinding(companyId, declaration.routineKey);
if (!binding) return null;
const routine = await db
.select()
.from(routines)
.where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId)))
.then((rows) => rows[0] ?? null);
if (!routine) return null;
return {
...routine,
managedByPlugin: managedByPlugin(binding),
} as Routine;
}
async function resolveAgentId(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
overrides?: RoutineOverrides,
) {
if (overrides?.assigneeAgentId !== undefined) {
if (!overrides.assigneeAgentId) return { agentId: null, missingRef: null };
const row = await db
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, overrides.assigneeAgentId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Assignee agent not found");
return { agentId: row.id, missingRef: null };
}
const ref = normalizeRef(options.pluginKey, declaration.assigneeRef, "agent");
if (!ref) return { agentId: null, missingRef: null };
const binding = await db
.select({ resourceId: pluginManagedResources.resourceId })
.from(pluginManagedResources)
.where(
and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, "agent"),
eq(pluginManagedResources.resourceKey, ref.resourceKey),
),
)
.then((rows) => rows[0] ?? null);
if (!binding) return { agentId: null, missingRef: ref };
const row = await db
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.companyId, companyId), eq(agents.id, binding.resourceId)))
.then((rows) => rows[0] ?? null);
return row ? { agentId: row.id, missingRef: null } : { agentId: null, missingRef: ref };
}
async function resolveProjectId(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
overrides?: RoutineOverrides,
) {
if (overrides?.projectId !== undefined) {
if (!overrides.projectId) return { projectId: null, missingRef: null };
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, overrides.projectId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Project not found");
return { projectId: row.id, missingRef: null };
}
const ref = normalizeRef(options.pluginKey, declaration.projectRef, "project");
if (!ref) return { projectId: null, missingRef: null };
const binding = await db
.select({ resourceId: pluginManagedResources.resourceId })
.from(pluginManagedResources)
.where(
and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, ref.resourceKey),
),
)
.then((rows) => rows[0] ?? null);
if (!binding) return { projectId: null, missingRef: ref };
const row = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, binding.resourceId)))
.then((rows) => rows[0] ?? null);
return row ? { projectId: row.id, missingRef: null } : { projectId: null, missingRef: ref };
}
async function resolveRefs(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
overrides?: RoutineOverrides,
) {
const [agent, project] = await Promise.all([
resolveAgentId(companyId, declaration, overrides),
resolveProjectId(companyId, declaration, overrides),
]);
const missingRefs: PluginManagedResourceRef[] = [];
if (agent.missingRef) missingRefs.push(agent.missingRef);
if (project.missingRef) missingRefs.push(project.missingRef);
return {
assigneeAgentId: agent.agentId,
projectId: project.projectId,
missingRefs,
};
}
function resolution(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
routine: Routine | null,
status: PluginManagedRoutineResolution["status"],
missingRefs: PluginManagedResourceRef[] = [],
): PluginManagedRoutineResolution {
return {
pluginKey: options.pluginKey,
resourceKind: "routine",
resourceKey: declaration.routineKey,
companyId,
routineId: routine?.id ?? null,
routine,
status,
missingRefs,
};
}
async function ensureDefaultTriggers(
routineId: string,
declaration: PluginManagedRoutineDeclaration,
) {
const triggers = declaration.triggers ?? [];
if (triggers.length === 0) return;
const existingCount = await db
.select({ id: routineTriggers.id })
.from(routineTriggers)
.where(eq(routineTriggers.routineId, routineId))
.limit(1)
.then((rows) => rows.length);
if (existingCount > 0) return;
for (const trigger of triggers) {
await routinesSvc.createTrigger(routineId, triggerInput(trigger), { agentId: null, userId: null });
}
}
async function createManagedRoutine(
companyId: string,
declaration: PluginManagedRoutineDeclaration,
overrides?: RoutineOverrides,
) {
const refs = await resolveRefs(companyId, declaration, overrides);
if (refs.missingRefs.length > 0) {
return resolution(companyId, declaration, null, "missing_refs", refs.missingRefs);
}
const created = await routinesSvc.create(companyId, {
projectId: refs.projectId,
goalId: declaration.goalId ?? null,
title: declaration.title,
description: declaration.description ?? null,
assigneeAgentId: refs.assigneeAgentId,
priority: declaration.priority ?? "medium",
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
variables: declaration.variables ?? [],
}, { agentId: null, userId: null });
await upsertBinding(companyId, declaration, created.id);
await ensureDefaultTriggers(created.id, declaration);
const routine = await getRoutineWithManagedBy(companyId, declaration);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_routine.created",
entityType: "routine",
entityId: created.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.routineKey,
assigneeAgentId: refs.assigneeAgentId,
projectId: refs.projectId,
},
});
return resolution(companyId, declaration, routine, "created");
}
async function get(routineKey: string, companyId: string) {
const declaration = declarationFor(routineKey);
const routine = await getRoutineWithManagedBy(companyId, declaration);
return resolution(companyId, declaration, routine, routine ? "resolved" : "missing");
}
async function reconcile(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
const declaration = declarationFor(routineKey);
const current = await get(routineKey, companyId);
if (current.routine) {
await upsertBinding(companyId, declaration, current.routine.id);
await ensureDefaultTriggers(current.routine.id, declaration);
return current;
}
return createManagedRoutine(companyId, declaration, overrides);
}
async function reset(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
const declaration = declarationFor(routineKey);
const current = await get(routineKey, companyId);
if (!current.routine) {
return createManagedRoutine(companyId, declaration, overrides);
}
const refs = await resolveRefs(companyId, declaration, overrides);
if (refs.missingRefs.length > 0) {
return resolution(companyId, declaration, current.routine, "missing_refs", refs.missingRefs);
}
const updated = await routinesSvc.update(current.routine.id, {
projectId: refs.projectId,
goalId: declaration.goalId ?? null,
title: declaration.title,
description: declaration.description ?? null,
assigneeAgentId: refs.assigneeAgentId,
priority: declaration.priority ?? "medium",
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
variables: declaration.variables ?? [],
}, { agentId: null, userId: null });
if (!updated) throw notFound("Managed routine not found");
await upsertBinding(companyId, declaration, updated.id);
await ensureDefaultTriggers(updated.id, declaration);
const routine = await getRoutineWithManagedBy(companyId, declaration);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_routine.reset",
entityType: "routine",
entityId: updated.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.routineKey,
assigneeAgentId: refs.assigneeAgentId,
projectId: refs.projectId,
},
});
return resolution(companyId, declaration, routine, "reset");
}
async function update(
routineKey: string,
companyId: string,
patch: { status?: string },
) {
const declaration = declarationFor(routineKey);
const current = await get(routineKey, companyId);
if (!current.routine) throw notFound("Managed routine not found");
const updatePatch: { status?: RoutineStatus } = {};
if (patch.status !== undefined) {
if (!ROUTINE_STATUSES.includes(patch.status as RoutineStatus)) {
throw unprocessable("Invalid routine status");
}
updatePatch.status = patch.status as RoutineStatus;
}
const updated = await routinesSvc.update(current.routine.id, updatePatch, { agentId: null, userId: null });
if (!updated) throw notFound("Managed routine not found");
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_routine.updated",
entityType: "routine",
entityId: updated.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.routineKey,
status: updated.status,
},
});
const routine = await getRoutineWithManagedBy(companyId, declaration);
return routine ?? updated;
}
async function run(routineKey: string, companyId: string, overrides?: RoutineOverrides) {
const declaration = declarationFor(routineKey);
const current = await get(routineKey, companyId);
if (!current.routine) throw notFound("Managed routine not found");
const run = await routinesSvc.runRoutine(current.routine.id, {
source: "manual",
assigneeAgentId: overrides?.assigneeAgentId,
projectId: overrides?.projectId,
}, { agentId: null, userId: null });
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_routine.run_triggered",
entityType: "routine_run",
entityId: run.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.routineKey,
routineId: current.routine.id,
status: run.status,
},
});
return run;
}
return {
get,
reconcile,
reset,
update,
run,
};
}

View file

@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import {
plugins,
pluginConfig,
pluginCompanySettings,
pluginEntities,
pluginJobs,
pluginJobRuns,
@ -15,6 +16,7 @@ import type {
UpdatePluginStatus,
UpsertPluginConfig,
PatchPluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,
@ -387,6 +389,64 @@ export function pluginRegistryService(db: Db) {
return rows[0] ?? null;
},
// ----- Company settings ----------------------------------------------
/** Retrieve company-scoped plugin settings. */
getCompanySettings: (pluginId: string, companyId: string): Promise<PluginCompanySettings | null> =>
db
.select()
.from(pluginCompanySettings)
.where(and(
eq(pluginCompanySettings.pluginId, pluginId),
eq(pluginCompanySettings.companyId, companyId),
))
.then((rows) => rows[0] ?? null) as Promise<PluginCompanySettings | null>,
/** Create or replace company-scoped plugin settings. */
upsertCompanySettings: async (
pluginId: string,
companyId: string,
input: { enabled?: boolean; settingsJson: Record<string, unknown>; lastError?: string | null },
): Promise<PluginCompanySettings> => {
const plugin = await getById(pluginId);
if (!plugin) throw notFound("Plugin not found");
const existing = await db
.select()
.from(pluginCompanySettings)
.where(and(
eq(pluginCompanySettings.pluginId, pluginId),
eq(pluginCompanySettings.companyId, companyId),
))
.then((rows) => rows[0] ?? null);
if (existing) {
return db
.update(pluginCompanySettings)
.set({
enabled: input.enabled ?? existing.enabled,
settingsJson: input.settingsJson,
lastError: input.lastError ?? null,
updatedAt: new Date(),
})
.where(eq(pluginCompanySettings.id, existing.id))
.returning()
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
}
return db
.insert(pluginCompanySettings)
.values({
pluginId,
companyId,
enabled: input.enabled ?? true,
settingsJson: input.settingsJson,
lastError: input.lastError ?? null,
})
.returning()
.then((rows) => rows[0]) as Promise<PluginCompanySettings>;
},
// ----- Entities -------------------------------------------------------
/**

View file

@ -1,6 +1,14 @@
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import {
projects,
projectGoals,
goals,
pluginManagedResources,
plugins,
projectWorkspaces,
workspaceRuntimeServices,
} from "@paperclipai/db";
import {
PROJECT_COLORS,
deriveProjectUrlKey,
@ -10,9 +18,12 @@ import {
type ProjectCodebase,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
type ProjectManagedByPlugin,
type ProjectWorkspaceRuntimeConfig,
type ProjectWorkspace,
type WorkspaceRuntimeService,
type PluginManagedProjectDeclaration,
type PluginManagedProjectResolution,
} from "@paperclipai/shared";
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
@ -50,6 +61,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin: ProjectManagedByPlugin | null;
}
interface ProjectShortnameRow {
@ -245,6 +257,40 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
arr.push(row);
}
const managedRows = await db
.select({
id: pluginManagedResources.id,
pluginId: pluginManagedResources.pluginId,
pluginKey: pluginManagedResources.pluginKey,
manifestJson: plugins.manifestJson,
resourceKind: pluginManagedResources.resourceKind,
resourceKey: pluginManagedResources.resourceKey,
resourceId: pluginManagedResources.resourceId,
defaultsJson: pluginManagedResources.defaultsJson,
createdAt: pluginManagedResources.createdAt,
updatedAt: pluginManagedResources.updatedAt,
})
.from(pluginManagedResources)
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
.where(and(
eq(pluginManagedResources.resourceKind, "project"),
inArray(pluginManagedResources.resourceId, projectIds),
));
const managedByProjectId = new Map<string, ProjectManagedByPlugin>();
for (const row of managedRows) {
managedByProjectId.set(row.resourceId, {
id: row.id,
pluginId: row.pluginId,
pluginKey: row.pluginKey,
pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey,
resourceKind: "project",
resourceKey: row.resourceKey,
defaultsJson: row.defaultsJson,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
return rows.map((row) => {
const projectWorkspaceRows = map.get(row.id) ?? [];
const workspaces = projectWorkspaceRows.map((workspace) =>
@ -264,6 +310,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
}),
workspaces,
primaryWorkspace,
managedByPlugin: managedByProjectId.get(row.id) ?? null,
};
});
}
@ -337,6 +384,17 @@ function deriveWorkspaceName(input: {
return "Workspace";
}
function buildManagedProjectDefaults(declaration: PluginManagedProjectDeclaration) {
return {
projectKey: declaration.projectKey,
displayName: declaration.displayName,
description: declaration.description ?? null,
status: declaration.status ?? "in_progress",
color: declaration.color ?? null,
settings: declaration.settings ?? {},
};
}
export function resolveProjectNameForUniqueShortname(
requestedName: string,
existingProjects: ProjectShortnameRow[],
@ -398,6 +456,58 @@ async function ensureSinglePrimaryWorkspace(
}
export function projectService(db: Db) {
const createProject = async (
companyId: string,
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
): Promise<ProjectWithGoals> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
// Auto-assign a color from the palette if none provided
if (!projectData.color) {
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
projectData.color = nextColor;
}
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
const row = await db
.insert(projects)
.values({ ...projectData, goalId: legacyGoalId, companyId })
.returning()
.then((rows) => rows[0]);
if (ids && ids.length > 0) {
await syncGoalLinks(db, row.id, companyId, ids);
}
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched!;
};
const getProjectById = async (id: string): Promise<ProjectWithGoals | null> => {
const row = await db
.select()
.from(projects)
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [withGoals] = await attachGoals(db, [row]);
if (!withGoals) return null;
const [enriched] = await attachWorkspaces(db, [withGoals]);
return enriched ?? null;
};
return {
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
@ -418,58 +528,170 @@ export function projectService(db: Db) {
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
},
getById: async (id: string): Promise<ProjectWithGoals | null> => {
const row = await db
.select()
.from(projects)
.where(eq(projects.id, id))
getById: getProjectById,
resolveManagedProject: async (input: {
companyId: string;
pluginId: string;
pluginKey: string;
projectKey: string;
reset?: boolean;
createIfMissing?: boolean;
}): Promise<PluginManagedProjectResolution> => {
const plugin = await db
.select({ id: plugins.id, pluginKey: plugins.pluginKey, manifestJson: plugins.manifestJson })
.from(plugins)
.where(eq(plugins.id, input.pluginId))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [withGoals] = await attachGoals(db, [row]);
if (!withGoals) return null;
const [enriched] = await attachWorkspaces(db, [withGoals]);
return enriched ?? null;
},
create: async (
companyId: string,
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
): Promise<ProjectWithGoals> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
// Auto-assign a color from the palette if none provided
if (!projectData.color) {
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
projectData.color = nextColor;
if (!plugin || plugin.pluginKey !== input.pluginKey) {
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: null,
project: null,
status: "missing",
};
}
const existingProjects = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, companyId));
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
const row = await db
.insert(projects)
.values({ ...projectData, goalId: legacyGoalId, companyId })
.returning()
.then((rows) => rows[0]);
if (ids && ids.length > 0) {
await syncGoalLinks(db, row.id, companyId, ids);
const declaration = plugin.manifestJson.projects?.find((project) => project.projectKey === input.projectKey);
if (!declaration) {
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: null,
project: null,
status: "missing",
};
}
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched!;
const defaults = buildManagedProjectDefaults(declaration);
const existingBinding = await db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, input.companyId),
eq(pluginManagedResources.pluginId, input.pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, input.projectKey),
))
.then((rows) => rows[0] ?? null);
if (existingBinding) {
const existingProject = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)))
.then((rows) => rows[0] ?? null);
if (existingProject) {
if (input.reset) {
await db
.update(projects)
.set({
name: declaration.displayName,
description: declaration.description ?? null,
status: declaration.status ?? "in_progress",
color: declaration.color ?? null,
updatedAt: new Date(),
})
.where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId)));
}
if (input.createIfMissing !== false) {
await db
.update(pluginManagedResources)
.set({ defaultsJson: defaults, updatedAt: new Date() })
.where(eq(pluginManagedResources.id, existingBinding.id));
}
const project = await getProjectById(existingBinding.resourceId);
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: project?.id ?? existingBinding.resourceId,
project: project as import("@paperclipai/shared").Project | null,
status: input.reset ? "reset" : "resolved",
};
}
if (input.createIfMissing === false) {
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: null,
project: null,
status: "missing",
};
}
const project = await createProject(input.companyId, {
name: declaration.displayName,
description: declaration.description ?? null,
status: declaration.status ?? "in_progress",
color: declaration.color ?? undefined,
});
await db
.update(pluginManagedResources)
.set({ resourceId: project.id, defaultsJson: defaults, updatedAt: new Date() })
.where(eq(pluginManagedResources.id, existingBinding.id));
const hydrated = await getProjectById(project.id);
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: hydrated?.id ?? project.id,
project: hydrated as import("@paperclipai/shared").Project | null,
status: "relinked",
};
}
if (input.createIfMissing === false) {
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: null,
project: null,
status: "missing",
};
}
const project = await createProject(input.companyId, {
name: declaration.displayName,
description: declaration.description ?? null,
status: declaration.status ?? "in_progress",
color: declaration.color ?? undefined,
});
await db.insert(pluginManagedResources).values({
companyId: input.companyId,
pluginId: input.pluginId,
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
resourceId: project.id,
defaultsJson: defaults,
});
const hydrated = await getProjectById(project.id);
return {
pluginKey: input.pluginKey,
resourceKind: "project",
resourceKey: input.projectKey,
companyId: input.companyId,
projectId: hydrated?.id ?? project.id,
project: hydrated as import("@paperclipai/shared").Project | null,
status: "created",
};
},
create: createProject,
update: async (
id: string,
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },

View file

@ -10,6 +10,8 @@ import {
issueInboxArchives,
issueReadStates,
issues,
pluginManagedResources,
plugins,
projects,
routineRuns,
routines,
@ -21,6 +23,7 @@ import type {
Routine,
RoutineDetail,
RoutineListItem,
RoutineManagedByPlugin,
RoutineRunSummary,
RoutineTrigger,
RoutineTriggerSecretMaterial,
@ -34,6 +37,7 @@ import {
getBuiltinRoutineVariableValues,
extractRoutineVariableNames,
interpolateRoutineTemplate,
pluginOperationIssueOriginKind,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
} from "@paperclipai/shared";
@ -354,6 +358,16 @@ function createRoutineDispatchFingerprint(input: {
return crypto.createHash("sha256").update(canonical).digest("hex");
}
function readManagedRoutineIssueTemplate(defaultsJson: Record<string, unknown> | null | undefined) {
const value = defaultsJson?.issueTemplate;
if (!isPlainRecord(value)) return null;
return {
surfaceVisibility: typeof value.surfaceVisibility === "string" ? value.surfaceVisibility : null,
originId: typeof value.originId === "string" && value.originId.trim() ? value.originId.trim() : null,
billingCode: typeof value.billingCode === "string" && value.billingCode.trim() ? value.billingCode.trim() : null,
};
}
function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) {
return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE)
|| extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE);
@ -380,6 +394,63 @@ export function routineService(
.then((rows) => rows[0] ?? null);
}
async function getManagedRoutineBinding(routine: typeof routines.$inferSelect) {
return db
.select({
pluginKey: pluginManagedResources.pluginKey,
defaultsJson: pluginManagedResources.defaultsJson,
manifestJson: plugins.manifestJson,
})
.from(pluginManagedResources)
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
.where(
and(
eq(pluginManagedResources.companyId, routine.companyId),
eq(pluginManagedResources.resourceKind, "routine"),
eq(pluginManagedResources.resourceId, routine.id),
),
)
.then((rows) => rows[0] ?? null);
}
async function listManagedRoutineMetadata(routineIds: string[]) {
if (routineIds.length === 0) return new Map<string, RoutineManagedByPlugin>();
const rows = await db
.select({
id: pluginManagedResources.id,
pluginId: pluginManagedResources.pluginId,
pluginKey: pluginManagedResources.pluginKey,
manifestJson: plugins.manifestJson,
resourceKey: pluginManagedResources.resourceKey,
resourceId: pluginManagedResources.resourceId,
defaultsJson: pluginManagedResources.defaultsJson,
createdAt: pluginManagedResources.createdAt,
updatedAt: pluginManagedResources.updatedAt,
})
.from(pluginManagedResources)
.innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id))
.where(
and(
eq(pluginManagedResources.resourceKind, "routine"),
inArray(pluginManagedResources.resourceId, routineIds),
),
);
return new Map(rows.map((row) => [
row.resourceId,
{
id: row.id,
pluginId: row.pluginId,
pluginKey: row.pluginKey,
pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey,
resourceKind: "routine",
resourceKey: row.resourceKey,
defaultsJson: row.defaultsJson,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
} satisfies RoutineManagedByPlugin,
]));
}
async function getTriggerById(id: string) {
return db
.select()
@ -664,8 +735,11 @@ export function routineService(
routine: typeof routines.$inferSelect,
executor: Db = db,
dispatchFingerprint?: string | null,
origin?: { kind: string; id: string | null },
) {
const fingerprintCondition = routineExecutionFingerprintCondition(dispatchFingerprint);
const originKind = origin?.kind ?? "routine_execution";
const originId = origin?.id ?? routine.id;
const executionBoundIssue = await executor
.select()
.from(issues)
@ -679,8 +753,8 @@ export function routineService(
.where(
and(
eq(issues.companyId, routine.companyId),
eq(issues.originKind, "routine_execution"),
eq(issues.originId, routine.id),
eq(issues.originKind, originKind),
eq(issues.originId, originId),
inArray(issues.status, OPEN_ISSUE_STATUSES),
isNull(issues.hiddenAt),
...(fingerprintCondition ? [fingerprintCondition] : []),
@ -705,8 +779,8 @@ export function routineService(
.where(
and(
eq(issues.companyId, routine.companyId),
eq(issues.originKind, "routine_execution"),
eq(issues.originId, routine.id),
eq(issues.originKind, originKind),
eq(issues.originId, originId),
inArray(issues.status, OPEN_ISSUE_STATUSES),
isNull(issues.hiddenAt),
...(fingerprintCondition ? [fingerprintCondition] : []),
@ -844,6 +918,13 @@ export function routineService(
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables });
const managedRoutineBinding = await getManagedRoutineBinding(input.routine);
const managedIssueTemplate = readManagedRoutineIssueTemplate(managedRoutineBinding?.defaultsJson);
const issueOriginKind = managedIssueTemplate?.surfaceVisibility === "plugin_operation" && managedRoutineBinding
? pluginOperationIssueOriginKind(managedRoutineBinding.pluginKey)
: "routine_execution";
const issueOriginId = managedIssueTemplate?.originId ?? input.routine.id;
const issueBillingCode = managedIssueTemplate?.billingCode ?? null;
const dispatchFingerprint = createRoutineDispatchFingerprint({
payload: triggerPayload,
projectId,
@ -902,7 +983,10 @@ export function routineService(
let createdIssue: Awaited<ReturnType<typeof issueSvc.create>> | null = null;
try {
const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint);
const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint, {
kind: issueOriginKind,
id: issueOriginId,
});
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
@ -942,10 +1026,11 @@ export function routineService(
assigneeAgentId,
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
createdByUserId: manualRunnerUserId,
originKind: "routine_execution",
originId: input.routine.id,
originKind: issueOriginKind,
originId: issueOriginId,
originRunId: createdRun.id,
originFingerprint: dispatchFingerprint,
billingCode: issueBillingCode,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
@ -962,7 +1047,10 @@ export function routineService(
throw error;
}
const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint);
const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint, {
kind: issueOriginKind,
id: issueOriginId,
});
if (!existingIssue) throw error;
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
if (manualRunnerUserId) {
@ -1084,13 +1172,15 @@ export function routineService(
.where(and(...conditions))
.orderBy(desc(routines.updatedAt), asc(routines.title));
const routineIds = rows.map((row) => row.id);
const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine] = await Promise.all([
const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine, managedByRoutine] = await Promise.all([
listTriggersForRoutineIds(companyId, routineIds),
listLatestRunByRoutineIds(companyId, routineIds),
listLiveIssueByRoutineIds(companyId, routineIds),
listManagedRoutineMetadata(routineIds),
]);
return rows.map((row) => ({
...row,
managedByPlugin: managedByRoutine.get(row.id) ?? null,
triggers: (triggersByRoutine.get(row.id) ?? []).map((trigger) => ({
id: trigger.id,
kind: trigger.kind as RoutineListItem["triggers"][number]["kind"],
@ -1110,7 +1200,7 @@ export function routineService(
getDetail: async (id: string): Promise<RoutineDetail | null> => {
const row = await getRoutineById(id);
if (!row) return null;
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue] = await Promise.all([
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([
row.projectId
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
: null,
@ -1189,10 +1279,12 @@ export function routineService(
})),
),
findLiveExecutionIssue(row),
listManagedRoutineMetadata([row.id]),
]);
return {
...row,
managedByPlugin: managedByRoutine.get(row.id) ?? null,
project,
assignee,
parentIssue,