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
|
|
@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
||||
});
|
||||
|
||||
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const normalIssueId = randomUUID();
|
||||
const pluginVisibleIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Plugin Runner",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Plugin operations",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
{
|
||||
id: pluginVisibleIssueId,
|
||||
companyId,
|
||||
title: "Plugin-visible issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:feature",
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Plugin operation issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
|
||||
expect(defaultIssueIds).toContain(normalIssueId);
|
||||
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
|
||||
expect(defaultIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
const inboxIssueIds = (await svc.list(companyId, {
|
||||
assigneeAgentId: agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
includeRoutineExecutions: true,
|
||||
})).map((issue) => issue.id);
|
||||
expect(inboxIssueIds).toContain(normalIssueId);
|
||||
expect(inboxIssueIds).not.toContain(operationIssueId);
|
||||
|
||||
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
|
||||
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
|
||||
|
||||
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
|
||||
expect(projectIssueIds).toContain(operationIssueId);
|
||||
|
||||
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
|
||||
expect(advancedIssueIds).toContain(operationIssueId);
|
||||
});
|
||||
|
||||
it("excludes plugin operation issues from unread inbox counts", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
const otherUserId = "other-user";
|
||||
const normalIssueId = randomUUID();
|
||||
const operationIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: normalIssueId,
|
||||
companyId,
|
||||
title: "Normal touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
},
|
||||
{
|
||||
id: operationIssueId,
|
||||
companyId,
|
||||
title: "Plugin operation touched issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: userId,
|
||||
originKind: "plugin:paperclip.missions:operation",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values([
|
||||
{
|
||||
companyId,
|
||||
issueId: normalIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread normal update.",
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
issueId: operationIssueId,
|
||||
authorUserId: otherUserId,
|
||||
body: "Unread operation update.",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "user-1";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
companies,
|
||||
createDb,
|
||||
|
|
@ -25,9 +25,11 @@ import {
|
|||
validatePluginRuntimeExecute,
|
||||
validatePluginRuntimeQuery,
|
||||
} from "../services/plugin-database.js";
|
||||
import { pluginLoader } from "../services/plugin-loader.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
const multiMigrationPluginKey = "paperclip.dbfixture";
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
|
|
@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
|
||||
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
|
||||
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
||||
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
||||
}
|
||||
|
|
@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
return packageRoot;
|
||||
}
|
||||
|
||||
async function createInstallablePluginPackage(
|
||||
pluginManifest: PaperclipPluginManifestV1,
|
||||
migrationSql: string,
|
||||
) {
|
||||
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: pluginManifest.id,
|
||||
version: pluginManifest.version,
|
||||
type: "module",
|
||||
paperclipPlugin: { manifest: "./manifest.js" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, "manifest.js"),
|
||||
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
|
|
@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
};
|
||||
}
|
||||
|
||||
it("applies multi-file plugin migrations through the production validator", async () => {
|
||||
const pluginManifest = manifest(multiMigrationPluginKey);
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createPluginPackage(
|
||||
pluginManifest,
|
||||
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
|
||||
`CREATE TABLE ${namespace}.derived_rows (
|
||||
id uuid PRIMARY KEY,
|
||||
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
|
||||
);`,
|
||||
"utf8",
|
||||
);
|
||||
const pluginId = await installPluginRecord(pluginManifest);
|
||||
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
|
||||
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
|
||||
expect(migrations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
|
|
@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
|||
expect(migration?.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("rolls back plugin install when migration validation fails", async () => {
|
||||
const pluginManifest = manifest("paperclip.escape");
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
pluginManifest,
|
||||
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
|
||||
);
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
});
|
||||
|
||||
await expect(loader.installPlugin({ localPath: packageRoot }))
|
||||
.rejects.toThrow(/public\.plugin_escape|public/i);
|
||||
|
||||
const installedPlugins = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.pluginKey, pluginManifest.id));
|
||||
const namespaces = await db
|
||||
.select()
|
||||
.from(pluginDatabaseNamespaces)
|
||||
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
|
||||
const migrations = await db
|
||||
.select()
|
||||
.from(pluginMigrations)
|
||||
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
|
||||
const schemaRows = Array.from(
|
||||
await db.execute(
|
||||
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
|
||||
) as Iterable<{ schema_name: string }>,
|
||||
);
|
||||
|
||||
expect(installedPlugins).toHaveLength(0);
|
||||
expect(namespaces).toHaveLength(0);
|
||||
expect(migrations).toHaveLength(0);
|
||||
expect(schemaRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refreshes persisted manifests from disk before activation", async () => {
|
||||
const staleManifest = manifest("paperclip.refresh");
|
||||
const refreshedManifest: PaperclipPluginManifestV1 = {
|
||||
...staleManifest,
|
||||
database: {
|
||||
...staleManifest.database!,
|
||||
coreReadTables: ["companies"],
|
||||
},
|
||||
};
|
||||
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
|
||||
const packageRoot = await createInstallablePluginPackage(
|
||||
refreshedManifest,
|
||||
`
|
||||
CREATE TABLE ${namespace}.company_refs (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id)
|
||||
);
|
||||
`,
|
||||
);
|
||||
const pluginId = await installPluginRecord(staleManifest);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
packagePath: packageRoot,
|
||||
status: "ready",
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const workerManager = {
|
||||
startWorker: vi.fn().mockResolvedValue(undefined),
|
||||
stopAll: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const loader = pluginLoader(db, {
|
||||
enableLocalFilesystem: false,
|
||||
enableNpmDiscovery: false,
|
||||
}, {
|
||||
workerManager,
|
||||
eventBus: {
|
||||
forPlugin: vi.fn(() => ({})),
|
||||
subscriptionCount: vi.fn(() => 0),
|
||||
},
|
||||
jobScheduler: {
|
||||
registerPlugin: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
jobStore: {
|
||||
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
toolDispatcher: {
|
||||
registerPluginTools: vi.fn(),
|
||||
},
|
||||
lifecycleManager: {
|
||||
markError: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
buildHostHandlers: vi.fn(() => ({})),
|
||||
instanceInfo: {
|
||||
instanceId: "test-instance",
|
||||
hostVersion: "1.0.0",
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "public",
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await loader.loadSingle(pluginId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(workerManager.startWorker).toHaveBeenCalledWith(
|
||||
pluginId,
|
||||
expect.objectContaining({
|
||||
databaseNamespace: namespace,
|
||||
env: {
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
|
||||
},
|
||||
manifest: expect.objectContaining({
|
||||
database: expect.objectContaining({ coreReadTables: ["companies"] }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [plugin] = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
|
||||
});
|
||||
|
||||
it("rejects checksum changes for already applied migrations", async () => {
|
||||
const pluginManifest = manifest();
|
||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||
|
|
|
|||
263
server/src/__tests__/plugin-local-folders.test.ts
Normal file
263
server/src/__tests__/plugin-local-folders.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
assertConfiguredLocalFolder,
|
||||
assertWritableConfiguredLocalFolder,
|
||||
inspectPluginLocalFolder,
|
||||
listPluginLocalFolderEntries,
|
||||
preparePluginLocalFolder,
|
||||
readPluginLocalFolderText,
|
||||
resolvePluginLocalFolderPath,
|
||||
writePluginLocalFolderTextAtomic,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
|
||||
describe("plugin local folders", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function makeRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a healthy generic folder when required paths exist", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "sources"));
|
||||
await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(true);
|
||||
expect(status.problems).toEqual([]);
|
||||
expect(status.requiredDirectories).toEqual(["sources"]);
|
||||
expect(status.requiredFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("reports missing required folders and files without using product-specific branches", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toEqual(
|
||||
expect.arrayContaining(["missing_directory", "missing_file"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports all required paths as missing when the configured root does not exist", async () => {
|
||||
const root = await makeRoot();
|
||||
const missingRoot = path.join(root, "missing-root");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: missingRoot,
|
||||
requiredDirectories: ["sources"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.readable).toBe(false);
|
||||
expect(status.missingDirectories).toEqual(["sources"]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
expect(status.problems.map((item) => item.code)).toContain("missing");
|
||||
});
|
||||
|
||||
it("uses manifest declaration access and required paths over stored or caller overrides", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "manifest-dir"));
|
||||
await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8");
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
declaration: {
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "read",
|
||||
requiredDirectories: ["manifest-dir"],
|
||||
requiredFiles: ["manifest.md"],
|
||||
},
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["stored-dir"],
|
||||
requiredFiles: ["stored.md"],
|
||||
},
|
||||
overrideConfig: {
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["override-dir"],
|
||||
requiredFiles: ["override.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.access).toBe("read");
|
||||
expect(status.writable).toBe(false);
|
||||
expect(status.requiredDirectories).toEqual(["manifest-dir"]);
|
||||
expect(status.requiredFiles).toEqual(["manifest.md"]);
|
||||
expect(status.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("prepares required directories for a read-write folder without creating required files", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await preparePluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["sources", "wiki/concepts"],
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(status.missingDirectories).toEqual([]);
|
||||
expect(status.missingFiles).toEqual(["schema.md"]);
|
||||
});
|
||||
|
||||
it("allows write access to repair folders that are only missing required paths", async () => {
|
||||
const root = await makeRoot();
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy");
|
||||
expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow();
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "schema.md", "schema");
|
||||
const repaired = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredFiles: ["schema.md"],
|
||||
},
|
||||
});
|
||||
expect(repaired.healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects traversal outside the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
|
||||
await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects required symlinks that escape the configured folder", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8");
|
||||
await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt"));
|
||||
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey: "content-root",
|
||||
storedConfig: {
|
||||
path: root,
|
||||
requiredFiles: ["linked.txt"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.healthy).toBe(false);
|
||||
expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true);
|
||||
});
|
||||
|
||||
it("writes files atomically under the root and can read them back", async () => {
|
||||
const root = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "nested"));
|
||||
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello");
|
||||
await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated");
|
||||
|
||||
await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated");
|
||||
const leftovers = await fs.readdir(path.join(root, "nested"));
|
||||
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists nested local folder entries without following symlink escapes", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true });
|
||||
await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8");
|
||||
await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8");
|
||||
await fs.symlink(outside, path.join(root, "wiki/outside"));
|
||||
|
||||
const listing = await listPluginLocalFolderEntries(root, {
|
||||
relativePath: "wiki",
|
||||
recursive: true,
|
||||
maxEntries: 20,
|
||||
});
|
||||
|
||||
expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md");
|
||||
expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md");
|
||||
expect(listing.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates temp-file containment before writing atomic contents", async () => {
|
||||
const root = await makeRoot();
|
||||
const outside = await makeRoot();
|
||||
const nested = path.join(root, "nested");
|
||||
await fs.mkdir(nested);
|
||||
const originalOpen = fs.open.bind(fs);
|
||||
const openSpy = vi.spyOn(fs, "open");
|
||||
openSpy.mockImplementationOnce(async (file, flags, mode) => {
|
||||
await fs.rm(nested, { recursive: true, force: true });
|
||||
await fs.symlink(outside, nested);
|
||||
return originalOpen(file, flags, mode);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await fs.readdir(outside)).toEqual([]);
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
365
server/src/__tests__/plugin-managed-agents.test.ts
Normal file
365
server/src/__tests__/plugin-managed-agents.test.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
pluginEntities,
|
||||
pluginCompanySettings,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-agents-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Agents Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [
|
||||
{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
title: "Maintains plugin-owned knowledge",
|
||||
capabilities: "Maintains a plugin-owned wiki.",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } },
|
||||
permissions: { canCreateAgents: false },
|
||||
budgetMonthlyCents: 1234,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed agents", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginEntities);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(pluginCompanySettings);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
const pluginManifest = options.manifest ?? manifest();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
requireBoardApprovalForNewAgents: options.requireApproval ?? false,
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-agents-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("creates and resolves managed agents by stable resource key", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const created = await services.agents.managedReconcile({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agentId).toBeTruthy();
|
||||
expect(created.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
|
||||
const resolved = await services.agents.managedGet({
|
||||
companyId,
|
||||
agentKey: "wiki-maintainer",
|
||||
});
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.agentId).toBe(created.agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.entityType).toBe("managed_agent");
|
||||
expect(binding?.scopeKind).toBe("company");
|
||||
expect(binding?.scopeId).toBe(companyId);
|
||||
expect(binding?.data).toMatchObject({
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
agentId: created.agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves user edits during reconcile and resets only on explicit reset", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(created.agentId).toBeTruthy();
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, created.agentId!));
|
||||
|
||||
const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.agent).toMatchObject({
|
||||
name: "Knowledge Lead",
|
||||
adapterConfig: { command: "custom" },
|
||||
});
|
||||
|
||||
const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(reset.status).toBe("reset");
|
||||
expect(reset.agent).toMatchObject({
|
||||
name: "Wiki Maintainer",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates managed agents with the most-used compatible company adapter", async () => {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterPreference: ["claude_local", "codex_local"],
|
||||
adapterConfig: {},
|
||||
};
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Codex Two",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Claude One",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.adapterType).toBe("codex_local");
|
||||
});
|
||||
|
||||
it("materializes declared managed agent instructions with local folder paths", async () => {
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
|
||||
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
|
||||
process.env.PAPERCLIP_HOME = tempHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "test";
|
||||
try {
|
||||
const pluginManifest = manifest();
|
||||
pluginManifest.localFolders = [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
];
|
||||
pluginManifest.agents![0] = {
|
||||
...pluginManifest.agents![0]!,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
content: [
|
||||
"# LLM Wiki Maintainer",
|
||||
"",
|
||||
"You are the LLM Wiki Maintainer.",
|
||||
"Wiki root: `{{localFolders.wiki-root.path}}`",
|
||||
"Wiki schema: `{{localFolders.wiki-root.agentsPath}}`",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
};
|
||||
const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
|
||||
await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8");
|
||||
await db.insert(pluginCompanySettings).values({
|
||||
companyId,
|
||||
pluginId,
|
||||
enabled: true,
|
||||
settingsJson: {
|
||||
localFolders: {
|
||||
"wiki-root": {
|
||||
path: wikiRoot,
|
||||
access: "readWrite",
|
||||
requiredDirectories: [],
|
||||
requiredFiles: ["AGENTS.md"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath;
|
||||
expect(typeof instructionsFilePath).toBe("string");
|
||||
const content = await fs.readFile(instructionsFilePath as string, "utf8");
|
||||
expect(content).toContain("You are the LLM Wiki Maintainer.");
|
||||
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
|
||||
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousInstance;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
await fs.rm(wikiRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
|
||||
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Renamed Wiki Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "custom" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: {
|
||||
paperclipManagedResource: {
|
||||
pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
resourceKind: "agent",
|
||||
resourceKey: "wiki-maintainer",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
expect(relinked.status).toBe("relinked");
|
||||
expect(relinked.agentId).toBe(agentId);
|
||||
|
||||
const [binding] = await db.select().from(pluginEntities);
|
||||
expect(binding?.data).toMatchObject({ agentId });
|
||||
});
|
||||
|
||||
it("respects board approval policy for new managed agents", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
|
||||
|
||||
const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.agent?.status).toBe("pending_approval");
|
||||
expect(created.approvalId).toBeTruthy();
|
||||
|
||||
const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!));
|
||||
expect(approval).toMatchObject({
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
});
|
||||
expect(approval?.payload).toMatchObject({
|
||||
agentId: created.agentId,
|
||||
sourcePluginKey: "paperclip.managed-agents-test",
|
||||
managedResourceKey: "wiki-maintainer",
|
||||
});
|
||||
});
|
||||
});
|
||||
249
server/src/__tests__/plugin-managed-routines.test.ts
Normal file
249
server/src/__tests__/plugin-managed-routines.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
routineRuns,
|
||||
routineTriggers,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
import { routineService } from "../services/routines.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: async () => {},
|
||||
subscribe: () => {},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function manifest(): PaperclipPluginManifestV1 {
|
||||
return {
|
||||
id: "paperclip.managed-routines-test",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Managed Routines Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["agents.managed", "projects.managed", "routines.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
agents: [{
|
||||
agentKey: "wiki-maintainer",
|
||||
displayName: "Wiki Maintainer",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "pnpm wiki:maintain" },
|
||||
}],
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Plugin Operations",
|
||||
description: "Plugin operation inspection",
|
||||
status: "in_progress",
|
||||
}],
|
||||
routines: [{
|
||||
routineKey: "nightly-lint",
|
||||
title: "Nightly lint",
|
||||
description: "Lint plugin state",
|
||||
assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" },
|
||||
projectRef: { resourceKind: "project", resourceKey: "operations" },
|
||||
status: "active",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
triggers: [{
|
||||
kind: "schedule",
|
||||
label: "Nightly",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
}],
|
||||
issueTemplate: {
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("plugin-managed routines", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(issues);
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(agents);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompanyAndPlugin(pluginManifest = manifest()) {
|
||||
const companyId = randomUUID();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
});
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: pluginManifest.id,
|
||||
packageName: "@paperclipai/plugin-managed-routines-test",
|
||||
version: pluginManifest.version,
|
||||
apiVersion: pluginManifest.apiVersion,
|
||||
categories: pluginManifest.categories,
|
||||
manifestJson: pluginManifest,
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, {
|
||||
manifest: pluginManifest,
|
||||
});
|
||||
return { companyId, pluginId, pluginManifest, services };
|
||||
}
|
||||
|
||||
it("resolves routine agent and project refs by stable managed keys", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.routine).toMatchObject({
|
||||
title: "Nightly lint",
|
||||
assigneeAgentId: agent.agentId,
|
||||
projectId: project.projectId,
|
||||
managedByPlugin: expect.objectContaining({
|
||||
pluginKey: "paperclip.managed-routines-test",
|
||||
resourceKind: "routine",
|
||||
resourceKey: "nightly-lint",
|
||||
}),
|
||||
});
|
||||
|
||||
const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!));
|
||||
expect(trigger).toMatchObject({
|
||||
kind: "schedule",
|
||||
cronExpression: "0 3 * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
|
||||
const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(missing.status).toBe("missing_refs");
|
||||
expect(missing.missingRefs).toEqual([
|
||||
expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }),
|
||||
expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }),
|
||||
]);
|
||||
|
||||
const [agent] = await db.insert(agents).values({
|
||||
companyId,
|
||||
name: "Operator-selected maintainer",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
}).returning();
|
||||
const [project] = await db.insert(projects).values({
|
||||
companyId,
|
||||
name: "Operator-selected project",
|
||||
status: "in_progress",
|
||||
}).returning();
|
||||
|
||||
const repaired = await services.routines.managedReconcile({
|
||||
companyId,
|
||||
routineKey: "nightly-lint",
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(repaired.status).toBe("created");
|
||||
expect(repaired.routine).toMatchObject({
|
||||
assigneeAgentId: agent.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ title: "Operator renamed lint", updatedAt: new Date() })
|
||||
.where(eq(routines.id, repaired.routineId!));
|
||||
|
||||
const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
expect(reconciled.status).toBe("resolved");
|
||||
expect(reconciled.routine?.title).toBe("Operator renamed lint");
|
||||
});
|
||||
|
||||
it("creates routine operation issues with plugin visibility and managed project scoping", async () => {
|
||||
const { companyId, services } = await seedCompanyAndPlugin();
|
||||
const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
|
||||
const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" });
|
||||
const wakeup = vi.fn(async () => ({ id: randomUUID() }));
|
||||
const routinesSvc = routineService(db, { heartbeat: { wakeup } });
|
||||
|
||||
const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!));
|
||||
expect(issue).toMatchObject({
|
||||
originKind: "plugin:paperclip.managed-routines-test:operation",
|
||||
originId: "operation:nightly-lint",
|
||||
billingCode: "plugin-test:nightly-lint",
|
||||
projectId: project.projectId,
|
||||
assigneeAgentId: agent.agentId,
|
||||
});
|
||||
expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({
|
||||
reason: "issue_assigned",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
|
@ -11,6 +14,9 @@ import {
|
|||
heartbeatRuns,
|
||||
issueRelations,
|
||||
issues,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
|
|||
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
||||
|
|
@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
||||
tempRoots.length = 0;
|
||||
await db.delete(activityLog);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issues);
|
||||
await db.delete(pluginManagedResources);
|
||||
await db.delete(projects);
|
||||
await db.delete(plugins);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
|
@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
return { companyId, agentId };
|
||||
}
|
||||
|
||||
async function makeLocalRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const blockerIssueId = randomUUID();
|
||||
|
|
@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
|||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
||||
});
|
||||
|
||||
it("creates plugin operation issues with the generic operation origin", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
|
||||
|
||||
const issue = await services.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "mission-alpha:operation-1",
|
||||
});
|
||||
|
||||
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
|
||||
expect(issue.originId).toBe("mission-alpha:operation-1");
|
||||
});
|
||||
|
||||
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||
packageName: "@paperclipai/plugin-llm-wiki",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const root = await makeLocalRoot();
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclipai.plugin-llm-wiki",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclipai.plugin-llm-wiki",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "LLM Wiki",
|
||||
description: "Local-file LLM Wiki plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "wiki-root",
|
||||
displayName: "Wiki root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const configured = await services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
path: root,
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
|
||||
requiredFiles: ["WIKI.md", "AGENTS.md"],
|
||||
});
|
||||
expect(configured.healthy).toBe(false);
|
||||
expect(configured.missingDirectories).toEqual([]);
|
||||
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
|
||||
|
||||
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
|
||||
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
|
||||
.rejects.toThrow("Local folder is not healthy");
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "WIKI.md",
|
||||
contents: "# Wiki\n",
|
||||
});
|
||||
await services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "wiki-root",
|
||||
relativePath: "AGENTS.md",
|
||||
contents: "# Agents\n",
|
||||
});
|
||||
|
||||
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
|
||||
expect(finalStatus.healthy).toBe(true);
|
||||
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
|
||||
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
|
||||
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
|
||||
});
|
||||
|
||||
it("rejects worker local-folder access for undeclared manifest keys", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.local-folders",
|
||||
packageName: "@paperclip/plugin-local-folders",
|
||||
version: "0.1.0",
|
||||
manifestJson: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
status: "ready",
|
||||
});
|
||||
const services = buildHostServices(
|
||||
db,
|
||||
pluginId,
|
||||
"paperclip.local-folders",
|
||||
createEventBusStub(),
|
||||
undefined,
|
||||
{
|
||||
manifest: {
|
||||
id: "paperclip.local-folders",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Local Folders",
|
||||
description: "Local folder fixture",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["local.folders"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(services.localFolders.configure({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
path: "/tmp",
|
||||
access: "read",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
|
||||
.rejects.toThrow("Local folder key is not declared");
|
||||
await expect(services.localFolders.writeTextAtomic({
|
||||
companyId,
|
||||
folderKey: "ssh",
|
||||
relativePath: "id_rsa",
|
||||
contents: "secret",
|
||||
})).rejects.toThrow("Local folder key is not declared");
|
||||
});
|
||||
|
||||
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
|
||||
const { companyId } = await seedCompanyAndAgent();
|
||||
const pluginId = randomUUID();
|
||||
await db.insert(plugins).values({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
packageName: "@paperclip/plugin-missions",
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["automation"],
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Mission Operations",
|
||||
description: "Plugin operation inspection area",
|
||||
status: "in_progress",
|
||||
color: "#14b8a6",
|
||||
settings: { surface: "operations" },
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
|
||||
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
|
||||
expect(missing.status).toBe("missing");
|
||||
expect(missing.projectId).toBeNull();
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
.from(pluginManagedResources)
|
||||
.where(and(
|
||||
eq(pluginManagedResources.companyId, companyId),
|
||||
eq(pluginManagedResources.pluginId, pluginId),
|
||||
eq(pluginManagedResources.resourceKind, "project"),
|
||||
eq(pluginManagedResources.resourceKey, "operations"),
|
||||
)),
|
||||
).resolves.toHaveLength(0);
|
||||
|
||||
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(created.status).toBe("created");
|
||||
expect(created.projectId).toEqual(expect.any(String));
|
||||
expect(created.project?.managedByPlugin).toMatchObject({
|
||||
pluginId,
|
||||
pluginKey: "paperclip.missions",
|
||||
pluginDisplayName: "Missions",
|
||||
resourceKind: "project",
|
||||
resourceKey: "operations",
|
||||
});
|
||||
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
|
||||
.where(eq(projects.id, created.projectId!));
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({
|
||||
manifestJson: {
|
||||
id: "paperclip.missions",
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "Missions",
|
||||
description: "Mission orchestration",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: ["projects.managed"],
|
||||
entrypoints: { worker: "./dist/worker.js" },
|
||||
projects: [{
|
||||
projectKey: "operations",
|
||||
displayName: "Upgraded Default Name",
|
||||
description: "Upgraded default description",
|
||||
status: "planned",
|
||||
color: "#f97316",
|
||||
settings: { surface: "operations", upgraded: true },
|
||||
}],
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, pluginId));
|
||||
|
||||
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
|
||||
|
||||
expect(resolved.status).toBe("resolved");
|
||||
expect(resolved.projectId).toBe(created.projectId);
|
||||
expect(resolved.project?.name).toBe("Renamed by operator");
|
||||
expect(resolved.project?.description).toBe("User-owned text");
|
||||
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
|
||||
displayName: "Upgraded Default Name",
|
||||
settings: { upgraded: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||
const issueId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
|
|||
getById: vi.fn(),
|
||||
getByKey: vi.fn(),
|
||||
upsertConfig: vi.fn(),
|
||||
getCompanySettings: vi.fn(),
|
||||
upsertCompanySettings: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLifecycle = vi.hoisted(() => ({
|
||||
|
|
@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => {
|
|||
}, 20_000);
|
||||
});
|
||||
|
||||
describe.sequential("plugin local folder routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRegistry.getCompanySettings.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function readyLocalFolderPlugin() {
|
||||
mockRegistry.getById.mockResolvedValue({
|
||||
id: pluginId,
|
||||
pluginKey: "paperclip.example",
|
||||
version: "1.0.0",
|
||||
status: "ready",
|
||||
manifestJson: {
|
||||
id: "paperclip.example",
|
||||
capabilities: ["local.folders"],
|
||||
localFolders: [
|
||||
{
|
||||
folderKey: "content-root",
|
||||
displayName: "Content root",
|
||||
access: "readWrite",
|
||||
requiredDirectories: ["docs"],
|
||||
requiredFiles: ["README.md"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects validation for undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects saving undeclared local folder keys", async () => {
|
||||
readyLocalFolderPlugin();
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
|
||||
.send({ path: "/tmp" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Local folder key is not declared");
|
||||
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("plugin tool and bridge authz", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
|
|||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333334";
|
||||
const runId = "44444444-4444-4444-8444-444444444444";
|
||||
const issueId = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
|
|
@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
routeKey: "issue.advance",
|
||||
method: "POST",
|
||||
path: "/issues/:issueId/advance",
|
||||
auth: "agent",
|
||||
capability: "api.routes.register",
|
||||
checkoutPolicy: "required-for-agent-in-progress",
|
||||
companyResolution: { from: "issue", param: "issueId" },
|
||||
},
|
||||
]);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
const { app, workerManager } = await createApp({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: peerAgentId,
|
||||
companyId,
|
||||
runId,
|
||||
source: "agent_key",
|
||||
},
|
||||
plugin: {
|
||||
id: pluginId,
|
||||
pluginKey: apiRoutes.id,
|
||||
status: "ready",
|
||||
manifestJson: apiRoutes,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
|
||||
.send({ step: "next" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
|
||||
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
|
||||
routeKey: "issue.advance",
|
||||
params: { issueId },
|
||||
body: { step: "next" },
|
||||
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
|
||||
companyId,
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
|
||||
const apiRoutes = manifest([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
|
|||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
|
||||
});
|
||||
|
||||
it("supports generic plugin operation issue visibility in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const harness = createTestHarness({
|
||||
manifest: manifest(["issues.create"]),
|
||||
});
|
||||
|
||||
const created = await harness.ctx.issues.create({
|
||||
companyId,
|
||||
title: "Background operation",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originId: "operation-1",
|
||||
});
|
||||
|
||||
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
|
||||
expect(created.originId).toBe("operation-1");
|
||||
});
|
||||
|
||||
it("enforces checkout and wakeup capabilities in the test harness", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -253,6 +253,8 @@ export async function createApp(
|
|||
instanceInfo: {
|
||||
instanceId: opts.instanceId ?? "default",
|
||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
},
|
||||
buildHostHandlers: (pluginId, manifest) => {
|
||||
const notifyWorker = (method: string, params: unknown) => {
|
||||
|
|
@ -261,6 +263,7 @@ export async function createApp(
|
|||
};
|
||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
||||
pluginWorkerManager: workerManager,
|
||||
manifest,
|
||||
});
|
||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||
return createHostClientHandlers({
|
||||
|
|
|
|||
|
|
@ -1020,11 +1020,14 @@ export function issueRoutes(
|
|||
descendantOf: req.query.descendantOf as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
originKind: req.query.originKind as string | undefined,
|
||||
originKindPrefix: req.query.originKindPrefix as string | undefined,
|
||||
originId: req.query.originId as string | undefined,
|
||||
includeRoutineExecutions:
|
||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||
excludeRoutineExecutions:
|
||||
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
||||
includePluginOperations:
|
||||
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
||||
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
||||
q: req.query.q as string | undefined,
|
||||
limit,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ import {
|
|||
getActorInfo,
|
||||
} from "./authz.js";
|
||||
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
|
||||
import {
|
||||
findLocalFolderDeclaration,
|
||||
getStoredLocalFolders,
|
||||
inspectPluginLocalFolder,
|
||||
requireLocalFolderDeclaration,
|
||||
setStoredLocalFolder,
|
||||
} from "../services/plugin-local-folders.js";
|
||||
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||
|
||||
/** UI slot declaration extracted from plugin manifest */
|
||||
|
|
@ -2379,6 +2386,152 @@ export function pluginRoutes(
|
|||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Company-scoped trusted local folders
|
||||
// ===========================================================================
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const folderKeys = declarations.map((declaration) => declaration.folderKey);
|
||||
|
||||
const statuses = await Promise.all(folderKeys.map((folderKey) =>
|
||||
inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration: findLocalFolderDeclaration(declarations, folderKey),
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
})));
|
||||
|
||||
res.json({
|
||||
pluginId: plugin.id,
|
||||
companyId,
|
||||
declarations,
|
||||
folders: statuses,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
|
||||
const declarations = plugin.manifestJson.localFolders ?? [];
|
||||
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: storedFolders[folderKey] ?? null,
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
const { pluginId, companyId, folderKey } = req.params;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const plugin = await resolvePlugin(registry, pluginId);
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
path?: unknown;
|
||||
access?: "read" | "readWrite";
|
||||
requiredDirectories?: string[];
|
||||
requiredFiles?: string[];
|
||||
} | undefined;
|
||||
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
|
||||
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await registry.getCompanySettings(plugin.id, companyId);
|
||||
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
|
||||
const status = await inspectPluginLocalFolder({
|
||||
folderKey,
|
||||
declaration,
|
||||
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
|
||||
overrideConfig: {
|
||||
path: body.path,
|
||||
},
|
||||
});
|
||||
|
||||
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
|
||||
path: body.path,
|
||||
access: status.access,
|
||||
requiredDirectories: status.requiredDirectories,
|
||||
requiredFiles: status.requiredFiles,
|
||||
});
|
||||
await registry.upsertCompanySettings(plugin.id, companyId, {
|
||||
enabled: existing?.enabled ?? true,
|
||||
settingsJson: nextSettings,
|
||||
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
|
||||
});
|
||||
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
|
||||
pluginId: plugin.id,
|
||||
pluginKey: plugin.pluginKey,
|
||||
companyId,
|
||||
folderKey,
|
||||
healthy: status.healthy,
|
||||
});
|
||||
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Plugin health dashboard — aggregated diagnostics for the settings page
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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