mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +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
|
|
@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [
|
|||
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||
export type PluginIssueOriginKind = `plugin:${string}`;
|
||||
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
|
||||
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
|
||||
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
|
||||
|
||||
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
|
||||
return `plugin:${pluginKey}:operation`;
|
||||
}
|
||||
|
||||
export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean {
|
||||
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
|
||||
}
|
||||
|
||||
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||
|
|
@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [
|
|||
"issue.comments.create",
|
||||
"issue.interactions.create",
|
||||
"issue.documents.write",
|
||||
"projects.managed",
|
||||
"routines.managed",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agents.managed",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
|
|
@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [
|
|||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"environment.drivers.register",
|
||||
"local.folders",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
|
|
@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
|||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"routeSidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"globalToolbarButton",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export {
|
|||
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||
ISSUE_ORIGIN_KINDS,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
pluginOperationIssueOriginKind,
|
||||
isPluginOperationIssueOriginKind,
|
||||
ISSUE_RELATION_TYPES,
|
||||
ISSUE_TREE_CONTROL_MODES,
|
||||
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
|
||||
|
|
@ -133,6 +136,7 @@ export {
|
|||
type BuiltInIssueOriginKind,
|
||||
type PluginIssueOriginKind,
|
||||
type IssueOriginKind,
|
||||
type IssueSurfaceVisibility,
|
||||
type IssueRelationType,
|
||||
type IssueTreeControlMode,
|
||||
type IssueTreeHoldReleasePolicyStrategy,
|
||||
|
|
@ -303,6 +307,7 @@ export type {
|
|||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
ProjectGoalRef,
|
||||
ProjectManagedByPlugin,
|
||||
ProjectWorkspace,
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
|
|
@ -493,6 +498,7 @@ export type {
|
|||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineTrigger,
|
||||
|
|
@ -507,6 +513,15 @@ export type {
|
|||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
|
|
@ -523,6 +538,7 @@ export type {
|
|||
PluginMigrationRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export type {
|
|||
AdapterEnvironmentTestResult,
|
||||
} from "./agent.js";
|
||||
export type { AssetImage } from "./asset.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
ExecutionWorkspace,
|
||||
ExecutionWorkspaceSummary,
|
||||
|
|
@ -221,6 +221,7 @@ export type {
|
|||
} from "./secrets.js";
|
||||
export type {
|
||||
Routine,
|
||||
RoutineManagedByPlugin,
|
||||
RoutineVariable,
|
||||
RoutineVariableDefaultValue,
|
||||
RoutineTrigger,
|
||||
|
|
@ -315,6 +316,15 @@ export type {
|
|||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginEnvironmentDriverDeclaration,
|
||||
PluginManagedAgentDeclaration,
|
||||
PluginManagedProjectDeclaration,
|
||||
PluginManagedRoutineDeclaration,
|
||||
PluginLocalFolderDeclaration,
|
||||
PluginManagedAgentResolution,
|
||||
PluginManagedProjectResolution,
|
||||
PluginManagedRoutineResolution,
|
||||
PluginManagedResourceKind,
|
||||
PluginManagedResourceRef,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
|
|
@ -331,6 +341,7 @@ export type {
|
|||
PluginMigrationRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginCompanySettings,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,19 @@ import type {
|
|||
PluginDatabaseMigrationStatus,
|
||||
PluginDatabaseNamespaceMode,
|
||||
PluginDatabaseNamespaceStatus,
|
||||
AgentAdapterType,
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
IssuePriority,
|
||||
ProjectStatus,
|
||||
RoutineCatchUpPolicy,
|
||||
RoutineConcurrencyPolicy,
|
||||
RoutineStatus,
|
||||
IssueSurfaceVisibility,
|
||||
} from "../constants.js";
|
||||
import type { Agent } from "./agent.js";
|
||||
import type { Project } from "./project.js";
|
||||
import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
|
||||
|
|
@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration {
|
|||
configSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a normal Paperclip agent that a plugin can provision and later
|
||||
* resolve by stable key within each company.
|
||||
*/
|
||||
export interface PluginManagedAgentDeclaration {
|
||||
/** Stable identifier for this managed agent, unique within the plugin. */
|
||||
agentKey: string;
|
||||
/** Suggested visible agent name. */
|
||||
displayName: string;
|
||||
/** Optional suggested role. Defaults to `general`. */
|
||||
role?: AgentRole | string;
|
||||
/** Optional suggested title shown in agent surfaces. */
|
||||
title?: string | null;
|
||||
/** Optional icon for agent list/detail surfaces. */
|
||||
icon?: string | null;
|
||||
/** Suggested capability summary for the agent. */
|
||||
capabilities?: string | null;
|
||||
/** Suggested adapter type. Defaults to `process`. */
|
||||
adapterType?: AgentAdapterType | string;
|
||||
/**
|
||||
* Optional ordered list of compatible adapter types. When present, the host
|
||||
* prefers the most-used compatible adapter already configured in the company,
|
||||
* falling back to `adapterType`.
|
||||
*/
|
||||
adapterPreference?: Array<AgentAdapterType | string>;
|
||||
/** Suggested adapter configuration. */
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
/** Suggested Paperclip runtime configuration. */
|
||||
runtimeConfig?: Record<string, unknown>;
|
||||
/** Suggested permissions object. Normalized by the host on create/reset. */
|
||||
permissions?: Record<string, unknown>;
|
||||
/** Suggested starting status when no board approval is required. */
|
||||
status?: Extract<AgentStatus, "idle" | "paused">;
|
||||
/** Suggested monthly budget in cents. */
|
||||
budgetMonthlyCents?: number;
|
||||
/** Optional managed instructions content or pointer metadata for plugin UI. */
|
||||
instructions?: {
|
||||
entryFile?: string;
|
||||
content?: string;
|
||||
assetPath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a company-scoped local folder a trusted plugin wants the operator
|
||||
* to configure. The host treats this as a generic filesystem root: plugin
|
||||
* code may request required relative folders/files, then use SDK helpers for
|
||||
* path-safe reads and atomic writes under that root.
|
||||
*/
|
||||
export interface PluginLocalFolderDeclaration {
|
||||
/** Stable identifier for this folder, unique within the plugin. */
|
||||
folderKey: string;
|
||||
/** Human-readable name shown in plugin settings. */
|
||||
displayName: string;
|
||||
/** Optional operator-facing description. */
|
||||
description?: string;
|
||||
/** Access level requested by the plugin. Defaults to `readWrite`. */
|
||||
access?: "read" | "readWrite";
|
||||
/** Relative directories expected to exist under the configured root. */
|
||||
requiredDirectories?: string[];
|
||||
/** Relative files expected to exist under the configured root. */
|
||||
requiredFiles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a normal Paperclip project that a plugin can provision and later
|
||||
* resolve by stable key within each company.
|
||||
*/
|
||||
export interface PluginManagedProjectDeclaration {
|
||||
/** Stable identifier for this managed project, unique within the plugin. */
|
||||
projectKey: string;
|
||||
/** Suggested visible project name. */
|
||||
displayName: string;
|
||||
/** Suggested project description. */
|
||||
description?: string | null;
|
||||
/** Suggested starting status. Defaults to `in_progress`. */
|
||||
status?: ProjectStatus;
|
||||
/** Suggested project color. Defaults to the normal project palette. */
|
||||
color?: string | null;
|
||||
/** Optional plugin-specific defaults retained for reset/reconcile UI. */
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PluginManagedResourceKind = "agent" | "project" | "routine";
|
||||
|
||||
export interface PluginManagedResourceRef {
|
||||
pluginKey?: string;
|
||||
resourceKind: PluginManagedResourceKind;
|
||||
resourceKey: string;
|
||||
}
|
||||
|
||||
export interface PluginManagedRoutineDeclaration {
|
||||
/** Stable identifier for this managed routine, unique within the plugin. */
|
||||
routineKey: string;
|
||||
/** Suggested routine title template. */
|
||||
title: string;
|
||||
/** Suggested routine description template. */
|
||||
description?: string | null;
|
||||
/** Stable managed agent reference for the default assignee. */
|
||||
assigneeRef?: PluginManagedResourceRef | null;
|
||||
/** Stable managed project reference for routine-created issues. */
|
||||
projectRef?: PluginManagedResourceRef | null;
|
||||
/** Optional goal id to set on the routine in this company. */
|
||||
goalId?: string | null;
|
||||
/** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */
|
||||
status?: RoutineStatus;
|
||||
/** Suggested issue priority. Defaults to `medium`. */
|
||||
priority?: IssuePriority;
|
||||
/** Suggested concurrency behavior. Defaults to core routine default. */
|
||||
concurrencyPolicy?: RoutineConcurrencyPolicy;
|
||||
/** Suggested missed-trigger behavior. Defaults to core routine default. */
|
||||
catchUpPolicy?: RoutineCatchUpPolicy;
|
||||
/** Suggested routine variables. */
|
||||
variables?: RoutineVariable[];
|
||||
/** Suggested triggers created when the routine is first reconciled. */
|
||||
triggers?: Array<Pick<RoutineTrigger, "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "signingMode" | "replayWindowSec">>;
|
||||
/** Defaults for issues created by this routine. */
|
||||
issueTemplate?: {
|
||||
surfaceVisibility?: IssueSurfaceVisibility;
|
||||
originId?: string | null;
|
||||
billingCode?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginManagedAgentResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "agent";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
agentId: string | null;
|
||||
agent: Agent | null;
|
||||
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
||||
approvalId?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginManagedProjectResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "project";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
project: Project | null;
|
||||
status: "missing" | "resolved" | "created" | "relinked" | "reset";
|
||||
}
|
||||
|
||||
export interface PluginManagedRoutineResolution {
|
||||
pluginKey: string;
|
||||
resourceKind: "routine";
|
||||
resourceKey: string;
|
||||
companyId: string;
|
||||
routineId: string | null;
|
||||
routine: Routine | null;
|
||||
status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset";
|
||||
missingRefs?: PluginManagedResourceRef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a UI extension slot the plugin fills with a React component.
|
||||
*
|
||||
|
|
@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration {
|
|||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/**
|
||||
* Optional company-scoped route segment for page slots.
|
||||
* Optional company-scoped route segment for page and routeSidebar slots.
|
||||
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||
*/
|
||||
routePath?: string;
|
||||
|
|
@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 {
|
|||
apiRoutes?: PluginApiRouteDeclaration[];
|
||||
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
|
||||
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
|
||||
/** Suggested company-scoped agents this plugin can provision and resolve by stable key. */
|
||||
agents?: PluginManagedAgentDeclaration[];
|
||||
/** Suggested company-scoped projects this plugin can provision and resolve by stable key. */
|
||||
projects?: PluginManagedProjectDeclaration[];
|
||||
/** Suggested company-scoped routines this plugin can provision and resolve by stable key. */
|
||||
routines?: PluginManagedRoutineDeclaration[];
|
||||
/** Trusted local folders this plugin can configure and access by stable key. */
|
||||
localFolders?: PluginLocalFolderDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
|
|
@ -455,6 +631,22 @@ export interface PluginConfig {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scoped plugin settings row. This is intentionally generic; plugin
|
||||
* features such as local folders live inside `settingsJson` under namespaced
|
||||
* keys instead of requiring feature-specific database columns.
|
||||
*/
|
||||
export interface PluginCompanySettings {
|
||||
id: string;
|
||||
companyId: string;
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
settingsJson: Record<string, unknown>;
|
||||
lastError: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter for `ctx.entities.list`.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ export interface ProjectCodebase {
|
|||
origin: ProjectCodebaseOrigin;
|
||||
}
|
||||
|
||||
export interface ProjectManagedByPlugin {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
pluginDisplayName: string;
|
||||
resourceKind: "project";
|
||||
resourceKey: string;
|
||||
defaultsJson: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
companyId: string;
|
||||
|
|
@ -73,6 +85,7 @@ export interface Project {
|
|||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
managedByPlugin?: ProjectManagedByPlugin | null;
|
||||
archivedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,19 @@ export interface Routine {
|
|||
lastEnqueuedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
managedByPlugin?: RoutineManagedByPlugin | null;
|
||||
}
|
||||
|
||||
export interface RoutineManagedByPlugin {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
pluginDisplayName: string;
|
||||
resourceKind: "routine";
|
||||
resourceKey: string;
|
||||
defaultsJson: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RoutineTrigger {
|
||||
|
|
|
|||
72
packages/shared/src/validators/plugin.test.ts
Normal file
72
packages/shared/src/validators/plugin.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PLUGIN_CAPABILITIES } from "../constants.js";
|
||||
import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js";
|
||||
|
||||
describe("plugin capability constants", () => {
|
||||
it("exposes each capability once", () => {
|
||||
expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin managed routine validators", () => {
|
||||
it("accepts core issue surface visibility values in routine templates", () => {
|
||||
const parsed = pluginManagedRoutineDeclarationSchema.parse({
|
||||
routineKey: "wiki.refresh",
|
||||
title: "Refresh Wiki",
|
||||
issueTemplate: { surfaceVisibility: "default" },
|
||||
});
|
||||
|
||||
expect(parsed.issueTemplate?.surfaceVisibility).toBe("default");
|
||||
});
|
||||
|
||||
it("rejects non-core issue surface visibility values in routine templates", () => {
|
||||
const parsed = pluginManagedRoutineDeclarationSchema.safeParse({
|
||||
routineKey: "wiki.refresh",
|
||||
title: "Refresh Wiki",
|
||||
issueTemplate: { surfaceVisibility: "normal" },
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin UI slot validators", () => {
|
||||
it("accepts route-scoped sidebar slots with a routePath", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.parse({
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
routePath: "wiki",
|
||||
});
|
||||
|
||||
expect(parsed.routePath).toBe("wiki");
|
||||
});
|
||||
|
||||
it("requires route-scoped sidebar slots to declare a routePath", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.safeParse({
|
||||
type: "routeSidebar",
|
||||
id: "wiki-route-sidebar",
|
||||
displayName: "Wiki Sidebar",
|
||||
exportName: "WikiSidebar",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath");
|
||||
});
|
||||
|
||||
it("keeps reserved company route protection for route-scoped sidebars", () => {
|
||||
const parsed = pluginUiSlotDeclarationSchema.safeParse({
|
||||
type: "routeSidebar",
|
||||
id: "settings-route-sidebar",
|
||||
displayName: "Settings Sidebar",
|
||||
exportName: "SettingsSidebar",
|
||||
routePath: "settings",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,15 @@ import {
|
|||
PLUGIN_API_ROUTE_AUTH_MODES,
|
||||
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
||||
PLUGIN_API_ROUTE_METHODS,
|
||||
ISSUE_PRIORITIES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
ROUTINE_STATUSES,
|
||||
ROUTINE_TRIGGER_KINDS,
|
||||
ROUTINE_TRIGGER_SIGNING_MODES,
|
||||
ISSUE_SURFACE_VISIBILITIES,
|
||||
} from "../constants.js";
|
||||
import { routineVariableSchema } from "./routine.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
|
|
@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
|
|||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
export const pluginManagedAgentDeclarationSchema = z.object({
|
||||
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
role: z.string().min(1).max(100).optional(),
|
||||
title: z.string().max(200).nullable().optional(),
|
||||
icon: z.string().max(100).nullable().optional(),
|
||||
capabilities: z.string().max(2000).nullable().optional(),
|
||||
adapterType: z.string().min(1).max(100).optional(),
|
||||
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
runtimeConfig: z.record(z.unknown()).optional(),
|
||||
permissions: z.record(z.unknown()).optional(),
|
||||
status: z.enum(["idle", "paused"]).optional(),
|
||||
budgetMonthlyCents: z.number().int().min(0).optional(),
|
||||
instructions: z.object({
|
||||
entryFile: z.string().min(1).max(200).optional(),
|
||||
content: z.string().max(200_000).optional(),
|
||||
assetPath: z.string().min(1).max(500).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
|
||||
|
||||
export const pluginManagedProjectDeclarationSchema = z.object({
|
||||
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
|
||||
color: z.string().max(32).nullable().optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
|
||||
|
||||
const pluginManagedResourceRefSchema = z.object({
|
||||
pluginKey: z.string().min(1).max(100).optional(),
|
||||
resourceKind: z.enum(["agent", "project", "routine"]),
|
||||
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
export const pluginManagedRoutineDeclarationSchema = z.object({
|
||||
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().max(10_000).nullable().optional(),
|
||||
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
|
||||
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
|
||||
goalId: z.string().uuid().nullable().optional(),
|
||||
status: z.enum(ROUTINE_STATUSES).optional(),
|
||||
priority: z.enum(ISSUE_PRIORITIES).optional(),
|
||||
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
|
||||
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
|
||||
variables: z.array(routineVariableSchema).optional(),
|
||||
triggers: z.array(z.object({
|
||||
kind: z.enum(ROUTINE_TRIGGER_KINDS),
|
||||
label: z.string().trim().max(120).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cronExpression: z.string().trim().min(1).optional().nullable(),
|
||||
timezone: z.string().trim().min(1).optional().nullable(),
|
||||
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
|
||||
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
|
||||
})).max(20).optional(),
|
||||
issueTemplate: z.object({
|
||||
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
|
||||
originId: z.string().trim().max(255).nullable().optional(),
|
||||
billingCode: z.string().trim().max(200).nullable().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
|
||||
|
||||
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
|
||||
(value) =>
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes("\\") &&
|
||||
!value.split("/").some((segment) => segment === "" || segment === "."),
|
||||
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
|
||||
);
|
||||
|
||||
export const pluginLocalFolderDeclarationSchema = z.object({
|
||||
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
|
||||
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
|
||||
}),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
access: z.enum(["read", "readWrite"]).optional(),
|
||||
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
|
||||
});
|
||||
|
||||
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||
|
|
@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
|||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page") {
|
||||
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page slots",
|
||||
message: "routePath is only supported for page and routeSidebar slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.type === "routeSidebar" && !value.routePath) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routeSidebar slots require routePath",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
|
|
@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
|
|||
database: pluginDatabaseDeclarationSchema.optional(),
|
||||
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
|
||||
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
|
||||
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
|
||||
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
|
||||
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
|
|
@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
if (manifest.agents && manifest.agents.length > 0) {
|
||||
if (!manifest.capabilities.includes("agents.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'agents.managed' is required when managed agents are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects && manifest.projects.length > 0) {
|
||||
if (!manifest.capabilities.includes("projects.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'projects.managed' is required when managed projects are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines && manifest.routines.length > 0) {
|
||||
if (!manifest.capabilities.includes("routines.managed")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'routines.managed' is required when managed routines are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders && manifest.localFolders.length > 0) {
|
||||
if (!manifest.capabilities.includes("local.folders")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'local.folders' is required when local folders are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
|
|
@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({
|
|||
}
|
||||
}
|
||||
|
||||
if (manifest.localFolders) {
|
||||
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
|
||||
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["localFolders"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.agents) {
|
||||
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
|
||||
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["agents"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.projects) {
|
||||
const projectKeys = manifest.projects.map((project) => project.projectKey);
|
||||
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["projects"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.routines) {
|
||||
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
|
||||
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["routines"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue