mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
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:
parent
d6bee62f02
commit
3c73ed26b5
89 changed files with 27516 additions and 914 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
564
server/src/services/plugin-local-folders.ts
Normal file
564
server/src/services/plugin-local-folders.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
508
server/src/services/plugin-managed-agents.ts
Normal file
508
server/src/services/plugin-managed-agents.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
523
server/src/services/plugin-managed-routines.ts
Normal file
523
server/src/services/plugin-managed-routines.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[] },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue