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
|
|
@ -13,7 +13,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i
|
||||||
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
- Plugin database migrations are restricted to a host-derived plugin namespace.
|
||||||
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
- Plugin-owned JSON API routes must be declared in the manifest and are mounted
|
||||||
only under `/api/plugins/:pluginId/api/*`.
|
only under `/api/plugins/:pluginId/api/*`.
|
||||||
- There is no host-provided shared React component kit for plugins yet.
|
- The host provides a small shared React component kit through
|
||||||
|
`@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before
|
||||||
|
building custom versions.
|
||||||
- `ctx.assets` is not supported in the current runtime.
|
- `ctx.assets` is not supported in the current runtime.
|
||||||
|
|
||||||
## Scaffold a plugin
|
## Scaffold a plugin
|
||||||
|
|
@ -168,6 +170,187 @@ Mount surfaces currently wired in the host include:
|
||||||
- `commentAnnotation`
|
- `commentAnnotation`
|
||||||
- `commentContextMenuItem`
|
- `commentContextMenuItem`
|
||||||
|
|
||||||
|
## Shared host components
|
||||||
|
|
||||||
|
Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a
|
||||||
|
Paperclip-native control. The host owns the implementation, so plugins inherit
|
||||||
|
the board's current styling, ordering, recent selections, and dark-mode behavior
|
||||||
|
without importing `ui/src` internals.
|
||||||
|
|
||||||
|
Currently exposed components include:
|
||||||
|
|
||||||
|
- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown.
|
||||||
|
- `FileTree` for serializable file and directory trees.
|
||||||
|
- `IssuesList` for a native company-scoped issue table.
|
||||||
|
- `AssigneePicker` for the same agent/user selector used in the new issue pane.
|
||||||
|
Use the controlled `value` format `agent:<id>`, `user:<id>`, or `""`.
|
||||||
|
- `ProjectPicker` for the same project selector used in the new issue pane.
|
||||||
|
Use the controlled project id value, or `""` for no project.
|
||||||
|
- `ManagedRoutinesList` for plugin-owned routine settings pages.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function PluginAssignmentControls({ companyId }: { companyId: string }) {
|
||||||
|
const [assignee, setAssignee] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AssigneePicker
|
||||||
|
companyId={companyId}
|
||||||
|
value={assignee}
|
||||||
|
onChange={(value) => setAssignee(value)}
|
||||||
|
/>
|
||||||
|
<ProjectPicker
|
||||||
|
companyId={companyId}
|
||||||
|
value={projectId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File and path UI
|
||||||
|
|
||||||
|
Plugin UI often needs to render a file tree, accept a folder path, or browse a
|
||||||
|
project workspace. There are three different surfaces for that, and they map to
|
||||||
|
different trust and data-flow boundaries. Pick the surface that matches the
|
||||||
|
data the plugin actually has.
|
||||||
|
|
||||||
|
### When to use the shared `FileTree`
|
||||||
|
|
||||||
|
Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs
|
||||||
|
to render a serializable file/directory list and react to selection or
|
||||||
|
expand/collapse. The host owns the implementation, so plugin UI inherits the
|
||||||
|
board's icons, indent, focus ring, and dark-mode styling without importing host
|
||||||
|
internals.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
FileTree,
|
||||||
|
type FileTreeNode,
|
||||||
|
} from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
const nodes: FileTreeNode[] = [
|
||||||
|
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||||
|
{
|
||||||
|
name: "wiki",
|
||||||
|
path: "wiki",
|
||||||
|
kind: "dir",
|
||||||
|
children: [
|
||||||
|
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WikiTree() {
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["wiki"]));
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={selected}
|
||||||
|
expandedPaths={expanded}
|
||||||
|
onSelectFile={(path) => setSelected(path)}
|
||||||
|
onToggleDir={(path) =>
|
||||||
|
setExpanded((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.has(path) ? next.delete(path) : next.add(path);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good fits:
|
||||||
|
|
||||||
|
- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a
|
||||||
|
`FileTreeNode[]` from worker query results and renders it through `FileTree`.
|
||||||
|
- The example `plugin-file-browser-example` lazily fetches a directory's
|
||||||
|
children through a `loadFileList` action when `onToggleDir` fires, then
|
||||||
|
merges the children into the local tree state — letting the shared component
|
||||||
|
handle rendering and selection.
|
||||||
|
|
||||||
|
Boundary rules:
|
||||||
|
|
||||||
|
- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`,
|
||||||
|
`fileBadges`, `fileTones`). Do not pass arbitrary render functions across the
|
||||||
|
plugin/host boundary in v1; the supported escape hatches are
|
||||||
|
`fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by
|
||||||
|
path).
|
||||||
|
- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK
|
||||||
|
declaration is the only supported import path for plugin UI.
|
||||||
|
- The shared `FileTree` is for rendering and selection. Plugin-specific editors,
|
||||||
|
ingest flows, query forms, and lint runs stay inside the plugin and do not
|
||||||
|
belong as `FileTree` props.
|
||||||
|
|
||||||
|
### When to declare `localFolders`
|
||||||
|
|
||||||
|
When the plugin needs operator-configured filesystem roots — typically for
|
||||||
|
trusted local plugins like wiki tooling — declare `localFolders[]` on the
|
||||||
|
manifest and add the `local.folders` capability. The host renders a settings
|
||||||
|
surface for the operator to set the absolute path, validates the path
|
||||||
|
server-side (containment, symlinks, required files/directories), and exposes
|
||||||
|
`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the
|
||||||
|
worker.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const manifest = {
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [
|
||||||
|
{
|
||||||
|
folderKey: "content-root",
|
||||||
|
displayName: "Content root",
|
||||||
|
access: "readWrite",
|
||||||
|
requiredDirectories: ["sources", "pages"],
|
||||||
|
requiredFiles: ["schema.md"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
|
||||||
|
- The data lives outside any project workspace.
|
||||||
|
- Reads and writes need company-scoped configuration.
|
||||||
|
- The operator picks the path once in plugin settings and the worker resolves
|
||||||
|
files relative to that root.
|
||||||
|
|
||||||
|
Do not use `localFolders` to grant the UI direct browser-side access to the
|
||||||
|
filesystem — there is no such capability. The browser still goes through the
|
||||||
|
worker via `getData` / `performAction`, and the worker only exposes paths it
|
||||||
|
chose to expose.
|
||||||
|
|
||||||
|
### When to keep worker-mediated project workspace browsing
|
||||||
|
|
||||||
|
When the data lives inside an existing project workspace, keep the browsing
|
||||||
|
flow worker-mediated:
|
||||||
|
|
||||||
|
- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace
|
||||||
|
path, then reads its filesystem with normal Node APIs.
|
||||||
|
- The plugin UI calls a `getData` handler for the root listing and an action
|
||||||
|
for lazy children, then renders them through `FileTree`.
|
||||||
|
- The worker is the only side that touches the disk. The browser receives a
|
||||||
|
serializable tree and never sees raw absolute paths it can replay.
|
||||||
|
|
||||||
|
The example `plugin-file-browser-example` is the reference for this pattern:
|
||||||
|
the worker registers `fileList` (data) and `loadFileList` (action) over the
|
||||||
|
same handler, and the UI uses the action for on-toggle directory loading so the
|
||||||
|
shared `FileTree` stays the rendering surface.
|
||||||
|
|
||||||
|
### Mixing surfaces
|
||||||
|
|
||||||
|
A single plugin can use more than one of these. The LLM Wiki uses
|
||||||
|
`localFolders` for its content root, then renders the resulting page list
|
||||||
|
through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces`
|
||||||
|
to pick a workspace and renders its on-disk tree through `FileTree` with lazy
|
||||||
|
loading. Pick the boundary per data source, not per plugin.
|
||||||
|
|
||||||
## Company routes
|
## Company routes
|
||||||
|
|
||||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ Current limitations to keep in mind:
|
||||||
- Published npm packages are the intended install artifact for deployed plugins.
|
- Published npm packages are the intended install artifact for deployed plugins.
|
||||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises.
|
||||||
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
- Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`.
|
||||||
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
|
||||||
API routes.
|
API routes.
|
||||||
|
|
@ -976,13 +976,23 @@ export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||||
|
|
||||||
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides:
|
||||||
|
|
||||||
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`
|
- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()`
|
||||||
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
- **Design tokens**: colors, spacing, typography, shadows matching the host theme
|
||||||
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
|
||||||
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
- **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps`
|
||||||
|
|
||||||
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge.
|
||||||
|
|
||||||
|
`useHostNavigation()` is the supported way for plugin UI to navigate to
|
||||||
|
Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to,
|
||||||
|
options?)`, and `linkProps(to, options?)`. Plugin links should prefer
|
||||||
|
`linkProps()` so anchors keep real `href` values for copy-link, modifier-click,
|
||||||
|
middle-click, and open-in-new-tab behavior while plain left-clicks route through
|
||||||
|
the host SPA router. The host resolves company-scoped paths against the active
|
||||||
|
company prefix without double-prefixing already-prefixed paths. Plugin UI should
|
||||||
|
not use raw same-origin `href`s or `window.location.assign()` for internal
|
||||||
|
Paperclip navigation because those can force a full document reload.
|
||||||
|
|
||||||
### 19.0.2 Bundle Isolation
|
### 19.0.2 Bundle Isolation
|
||||||
|
|
||||||
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens.
|
||||||
|
|
@ -1062,6 +1072,11 @@ The host SDK ships shared components that plugins can import to quickly build UI
|
||||||
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
| `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs |
|
||||||
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
|
||||||
| `Spinner` | Loading indicator | Data fetch states |
|
| `Spinner` | Loading indicator | Data fetch states |
|
||||||
|
| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews |
|
||||||
|
| `IssuesList` | Host issue list | Plugin pages that need a native issue view |
|
||||||
|
| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work |
|
||||||
|
| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work |
|
||||||
|
| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines |
|
||||||
|
|
||||||
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render.
|
||||||
|
|
||||||
|
|
|
||||||
29
packages/db/src/migrations/0076_useful_elektra.sql
Normal file
29
packages/db/src/migrations/0076_useful_elektra.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "plugin_managed_resources" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"plugin_id" uuid NOT NULL,
|
||||||
|
"plugin_key" text NOT NULL,
|
||||||
|
"resource_kind" text NOT NULL,
|
||||||
|
"resource_key" text NOT NULL,
|
||||||
|
"resource_id" uuid NOT NULL,
|
||||||
|
"defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_company_idx" ON "plugin_managed_resources" USING btree ("company_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_plugin_idx" ON "plugin_managed_resources" USING btree ("plugin_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "plugin_managed_resources_resource_idx" ON "plugin_managed_resources" USING btree ("resource_kind","resource_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "plugin_managed_resources_company_plugin_resource_uq" ON "plugin_managed_resources" USING btree ("company_id","plugin_id","resource_kind","resource_key");
|
||||||
16134
packages/db/src/migrations/meta/0076_snapshot.json
Normal file
16134
packages/db/src/migrations/meta/0076_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -533,6 +533,13 @@
|
||||||
"when": 1777572332006,
|
"when": 1777572332006,
|
||||||
"tag": "0075_cultured_sebastian_shaw",
|
"tag": "0075_cultured_sebastian_shaw",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777675301279,
|
||||||
|
"tag": "0076_useful_elektra",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ export { companySkills } from "./company_skills.js";
|
||||||
export { plugins } from "./plugins.js";
|
export { plugins } from "./plugins.js";
|
||||||
export { pluginConfig } from "./plugin_config.js";
|
export { pluginConfig } from "./plugin_config.js";
|
||||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||||
|
export { pluginManagedResources } from "./plugin_managed_resources.js";
|
||||||
export { pluginState } from "./plugin_state.js";
|
export { pluginState } from "./plugin_state.js";
|
||||||
export { pluginEntities } from "./plugin_entities.js";
|
export { pluginEntities } from "./plugin_entities.js";
|
||||||
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";
|
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";
|
||||||
|
|
|
||||||
34
packages/db/src/schema/plugin_managed_resources.ts
Normal file
34
packages/db/src/schema/plugin_managed_resources.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { plugins } from "./plugins.js";
|
||||||
|
|
||||||
|
export const pluginManagedResources = pgTable(
|
||||||
|
"plugin_managed_resources",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: "cascade" }),
|
||||||
|
pluginId: uuid("plugin_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||||
|
pluginKey: text("plugin_key").notNull(),
|
||||||
|
resourceKind: text("resource_kind").notNull(),
|
||||||
|
resourceKey: text("resource_key").notNull(),
|
||||||
|
resourceId: uuid("resource_id").notNull(),
|
||||||
|
defaultsJson: jsonb("defaults_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyIdx: index("plugin_managed_resources_company_idx").on(table.companyId),
|
||||||
|
pluginIdx: index("plugin_managed_resources_plugin_idx").on(table.pluginId),
|
||||||
|
resourceIdx: index("plugin_managed_resources_resource_idx").on(table.resourceKind, table.resourceId),
|
||||||
|
companyPluginResourceUq: uniqueIndex("plugin_managed_resources_company_plugin_resource_uq").on(
|
||||||
|
table.companyId,
|
||||||
|
table.pluginId,
|
||||||
|
table.resourceKind,
|
||||||
|
table.resourceKey,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -27,7 +27,7 @@ Generates:
|
||||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||||
|
|
||||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
The scaffold starts with plain React elements so the generated plugin stays minimal. For Paperclip-native controls, import shared host components such as `MarkdownEditor`, `FileTree`, `AssigneePicker`, and `ProjectPicker` from `@paperclipai/plugin-sdk/ui`.
|
||||||
|
|
||||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type {
|
import type {
|
||||||
|
FileTreeNode,
|
||||||
PluginProjectSidebarItemProps,
|
PluginProjectSidebarItemProps,
|
||||||
PluginDetailTabProps,
|
PluginDetailTabProps,
|
||||||
PluginCommentAnnotationProps,
|
PluginCommentAnnotationProps,
|
||||||
PluginCommentContextMenuItemProps,
|
PluginCommentContextMenuItemProps,
|
||||||
} from "@paperclipai/plugin-sdk/ui";
|
} from "@paperclipai/plugin-sdk/ui";
|
||||||
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||||
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { basicSetup } from "codemirror";
|
import { basicSetup } from "codemirror";
|
||||||
import { javascript } from "@codemirror/lang-javascript";
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
|
@ -129,15 +130,31 @@ const editorLightHighlightStyle = HighlightStyle.define([
|
||||||
|
|
||||||
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||||
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||||
type FileTreeNodeProps = {
|
|
||||||
entry: FileEntry;
|
function entryToFileTreeNode(entry: FileEntry): FileTreeNode {
|
||||||
companyId: string | null;
|
return {
|
||||||
projectId: string;
|
name: entry.name,
|
||||||
workspaceId: string;
|
path: entry.path,
|
||||||
selectedPath: string | null;
|
kind: entry.isDirectory ? "dir" : "file",
|
||||||
onSelect: (path: string) => void;
|
children: [],
|
||||||
depth?: number;
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function entriesToFileTreeNodes(entries: FileEntry[]): FileTreeNode[] {
|
||||||
|
return entries.map(entryToFileTreeNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChildrenAtPath(nodes: FileTreeNode[], path: string, children: FileTreeNode[]): FileTreeNode[] {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.path === path) {
|
||||||
|
return { ...node, children };
|
||||||
|
}
|
||||||
|
if (node.kind === "dir" && node.children.length > 0 && (path === node.path || path.startsWith(`${node.path}/`))) {
|
||||||
|
return { ...node, children: setChildrenAtPath(node.children, path, children) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const PathLikePattern = /[\\/]/;
|
const PathLikePattern = /[\\/]/;
|
||||||
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||||
|
|
@ -235,109 +252,6 @@ function useAvailableHeight(
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileTreeNode({
|
|
||||||
entry,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
workspaceId,
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
depth = 0,
|
|
||||||
}: FileTreeNodeProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const isSelected = selectedPath === entry.path;
|
|
||||||
|
|
||||||
if (entry.isDirectory) {
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
|
|
||||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
|
||||||
onClick={() => setIsExpanded((value) => !value)}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
>
|
|
||||||
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
|
|
||||||
<span className="truncate font-medium">{entry.name}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded ? (
|
|
||||||
<ExpandedDirectoryChildren
|
|
||||||
directoryPath={entry.path}
|
|
||||||
companyId={companyId}
|
|
||||||
projectId={projectId}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
selectedPath={selectedPath}
|
|
||||||
onSelect={onSelect}
|
|
||||||
depth={depth}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
|
|
||||||
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
style={{ paddingLeft: `${depth * 14 + 23}px` }}
|
|
||||||
onClick={() => onSelect(entry.path)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{entry.name}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedDirectoryChildren({
|
|
||||||
directoryPath,
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
workspaceId,
|
|
||||||
selectedPath,
|
|
||||||
onSelect,
|
|
||||||
depth,
|
|
||||||
}: {
|
|
||||||
directoryPath: string;
|
|
||||||
companyId: string | null;
|
|
||||||
projectId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
selectedPath: string | null;
|
|
||||||
onSelect: (path: string) => void;
|
|
||||||
depth: number;
|
|
||||||
}) {
|
|
||||||
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
|
|
||||||
companyId,
|
|
||||||
projectId,
|
|
||||||
workspaceId,
|
|
||||||
directoryPath,
|
|
||||||
});
|
|
||||||
const children = childData?.entries ?? [];
|
|
||||||
|
|
||||||
if (children.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{children.map((child) => (
|
|
||||||
<FileTreeNode
|
|
||||||
key={child.path}
|
|
||||||
entry={child}
|
|
||||||
companyId={companyId}
|
|
||||||
projectId={projectId}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
selectedPath={selectedPath}
|
|
||||||
onSelect={onSelect}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||||
*/
|
*/
|
||||||
|
|
@ -430,11 +344,60 @@ export function FilesTab({ context }: PluginDetailTabProps) {
|
||||||
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||||
[companyId, projectId, selectedWorkspace],
|
[companyId, projectId, selectedWorkspace],
|
||||||
);
|
);
|
||||||
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
|
const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>(
|
||||||
"fileList",
|
"fileList",
|
||||||
fileListParams,
|
fileListParams,
|
||||||
);
|
);
|
||||||
const entries = fileListData?.entries ?? [];
|
|
||||||
|
// Lazy-load directory children through an imperative action so the shared
|
||||||
|
// FileTree can reuse `expandedPaths` for state without spawning a hook per
|
||||||
|
// expanded directory.
|
||||||
|
const loadFileList = usePluginAction("loadFileList");
|
||||||
|
const [nodes, setNodes] = useState<FileTreeNode[]>([]);
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||||
|
const [loadedDirs, setLoadedDirs] = useState<Set<string>>(() => new Set());
|
||||||
|
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNodes(fileListData?.entries ? entriesToFileTreeNodes(fileListData.entries) : []);
|
||||||
|
setExpandedPaths(new Set());
|
||||||
|
setLoadedDirs(new Set());
|
||||||
|
setLoadingDirs(new Set());
|
||||||
|
}, [fileListData, selectedWorkspace?.id]);
|
||||||
|
|
||||||
|
const handleToggleDir = useCallback(
|
||||||
|
(dirPath: string) => {
|
||||||
|
setExpandedPaths((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(dirPath)) next.delete(dirPath);
|
||||||
|
else next.add(dirPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (!selectedWorkspace) return;
|
||||||
|
if (loadedDirs.has(dirPath) || loadingDirs.has(dirPath)) return;
|
||||||
|
setLoadingDirs((current) => new Set(current).add(dirPath));
|
||||||
|
void loadFileList({
|
||||||
|
projectId,
|
||||||
|
companyId,
|
||||||
|
workspaceId: selectedWorkspace.id,
|
||||||
|
directoryPath: dirPath,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const entries = (response as { entries?: FileEntry[] })?.entries ?? [];
|
||||||
|
const children = entriesToFileTreeNodes(entries);
|
||||||
|
setNodes((current) => setChildrenAtPath(current, dirPath, children));
|
||||||
|
setLoadedDirs((current) => new Set(current).add(dirPath));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingDirs((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(dirPath);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[companyId, loadFileList, loadedDirs, loadingDirs, projectId, selectedWorkspace],
|
||||||
|
);
|
||||||
|
|
||||||
// Track the `?file=` query parameter across navigations (popstate).
|
// Track the `?file=` query parameter across navigations (popstate).
|
||||||
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||||
|
|
@ -610,28 +573,23 @@ export function FilesTab({ context }: PluginDetailTabProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||||
{selectedWorkspace ? (
|
{selectedWorkspace ? (
|
||||||
fileListLoading ? (
|
<FileTree
|
||||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
|
nodes={nodes}
|
||||||
) : entries.length > 0 ? (
|
selectedFile={selectedPath}
|
||||||
<ul className="space-y-0.5">
|
expandedPaths={expandedPaths}
|
||||||
{entries.map((entry) => (
|
onToggleDir={handleToggleDir}
|
||||||
<FileTreeNode
|
onSelectFile={(path: string) => {
|
||||||
key={entry.path}
|
setSelectedPath(path);
|
||||||
entry={entry}
|
setMobileView("editor");
|
||||||
companyId={companyId}
|
}}
|
||||||
projectId={projectId}
|
loading={fileListLoading}
|
||||||
workspaceId={selectedWorkspace.id}
|
error={fileListError ? { message: fileListError.message } : null}
|
||||||
selectedPath={selectedPath}
|
empty={{
|
||||||
onSelect={(path) => {
|
title: "No files",
|
||||||
setSelectedPath(path);
|
description: "No files found in this workspace.",
|
||||||
setMobileView("editor");
|
}}
|
||||||
}}
|
ariaLabel="Workspace files"
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -106,43 +106,46 @@ const plugin = definePlugin({
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.data.register(
|
async function readFileList(params: Record<string, unknown>) {
|
||||||
"fileList",
|
const projectId = params.projectId as string;
|
||||||
async (params: Record<string, unknown>) => {
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||||
const projectId = params.projectId as string;
|
const workspaceId = params.workspaceId as string;
|
||||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||||
const workspaceId = params.workspaceId as string;
|
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
if (!workspace) return { entries: [] };
|
||||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||||
if (!workspace) return { entries: [] };
|
if (!workspacePath) return { entries: [] };
|
||||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||||
if (!workspacePath) return { entries: [] };
|
if (!dirPath) {
|
||||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
return { entries: [] };
|
||||||
if (!dirPath) {
|
}
|
||||||
return { entries: [] };
|
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
}
|
return { entries: [] };
|
||||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
}
|
||||||
return { entries: [] };
|
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||||
}
|
const entries = names.map((name) => {
|
||||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
const full = path.join(dirPath, name);
|
||||||
const entries = names.map((name) => {
|
const stat = fs.lstatSync(full);
|
||||||
const full = path.join(dirPath, name);
|
const relativePath = path.relative(workspacePath, full);
|
||||||
const stat = fs.lstatSync(full);
|
return {
|
||||||
const relativePath = path.relative(workspacePath, full);
|
name,
|
||||||
return {
|
path: relativePath,
|
||||||
name,
|
isDirectory: stat.isDirectory(),
|
||||||
path: relativePath,
|
};
|
||||||
isDirectory: stat.isDirectory(),
|
}).sort((a, b) => {
|
||||||
};
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
}).sort((a, b) => {
|
return a.name.localeCompare(b.name);
|
||||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
});
|
||||||
return a.name.localeCompare(b.name);
|
return { entries };
|
||||||
});
|
}
|
||||||
return { entries };
|
|
||||||
},
|
ctx.data.register("fileList", readFileList);
|
||||||
);
|
|
||||||
|
// Mirror `fileList` as an action so the UI can lazily fetch directory
|
||||||
|
// children on tree expand without spawning a usePluginData hook per dir.
|
||||||
|
ctx.actions.register("loadFileList", readFileList);
|
||||||
|
|
||||||
ctx.data.register(
|
ctx.data.register(
|
||||||
"fileContent",
|
"fileContent",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
AssigneePicker,
|
||||||
|
ProjectPicker,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
useHostNavigation,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
|
|
@ -248,14 +251,6 @@ const mutedTextStyle: CSSProperties = {
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hostPath(companyPrefix: string | null | undefined, suffix: string): string {
|
|
||||||
return companyPrefix ? `/${companyPrefix}${suffix}` : suffix;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pluginPagePath(companyPrefix: string | null | undefined): string {
|
|
||||||
return hostPath(companyPrefix, `/${PAGE_ROUTE}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown): string {
|
function getErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
@ -521,6 +516,7 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp
|
||||||
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
|
function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) {
|
||||||
const overview = usePluginOverview(context.companyId);
|
const overview = usePluginOverview(context.companyId);
|
||||||
const toast = usePluginToast();
|
const toast = usePluginToast();
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const emitDemoEvent = usePluginAction("emit-demo-event");
|
const emitDemoEvent = usePluginAction("emit-demo-event");
|
||||||
const startProgressStream = usePluginAction("start-progress-stream");
|
const startProgressStream = usePluginAction("start-progress-stream");
|
||||||
const writeMetric = usePluginAction("write-metric");
|
const writeMetric = usePluginAction("write-metric");
|
||||||
|
|
@ -591,7 +587,7 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
|
||||||
tone: "info",
|
tone: "info",
|
||||||
action: {
|
action: {
|
||||||
label: "Go",
|
label: "Go",
|
||||||
href: hostPath(context.companyPrefix, "/dashboard"),
|
href: hostNavigation.resolveHref("/dashboard"),
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -1079,6 +1075,7 @@ function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["con
|
||||||
}
|
}
|
||||||
|
|
||||||
function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) {
|
function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1098,8 +1095,8 @@ function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] })
|
||||||
<div style={mutedTextStyle}>
|
<div style={mutedTextStyle}>
|
||||||
The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
|
The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
|
||||||
</div>
|
</div>
|
||||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>
|
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>
|
||||||
{pluginPagePath(context.companyPrefix)}
|
{hostNavigation.resolveHref(`/${PAGE_ROUTE}`)}
|
||||||
</a>
|
</a>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="Paperclip Animation">
|
<Section title="Paperclip Animation">
|
||||||
|
|
@ -1193,6 +1190,7 @@ function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context
|
||||||
}
|
}
|
||||||
|
|
||||||
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
|
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
|
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
|
||||||
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
|
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -1228,7 +1226,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||||
<div style={subtleCardStyle}>
|
<div style={subtleCardStyle}>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<strong>Company Route</strong>
|
<strong>Company Route</strong>
|
||||||
<Pill label={pluginPagePath(context.companyPrefix)} />
|
<Pill label={hostNavigation.resolveHref(`/${PAGE_ROUTE}`)} />
|
||||||
</div>
|
</div>
|
||||||
<div style={mutedTextStyle}>
|
<div style={mutedTextStyle}>
|
||||||
This page is mounted as a real company route instead of living only under `/plugins/:pluginId`.
|
This page is mounted as a real company route instead of living only under `/plugins/:pluginId`.
|
||||||
|
|
@ -1260,7 +1258,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||||
</div>
|
</div>
|
||||||
<div>{run.id}</div>
|
<div>{run.id}</div>
|
||||||
{run.agentId ? (
|
{run.agentId ? (
|
||||||
<a href={hostPath(context.companyPrefix, `/agents/${run.agentId}/runs/${run.id}`)}>
|
<a {...hostNavigation.linkProps(`/agents/${run.agentId}/runs/${run.id}`)}>
|
||||||
Open run
|
Open run
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -1294,6 +1292,44 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KitchenSinkSharedPickerDemo({ context }: { context: PluginPageProps["context"] }) {
|
||||||
|
const [assigneeValue, setAssigneeValue] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState(context.projectId ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProjectId(context.projectId ?? "");
|
||||||
|
}, [context.projectId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title="Shared Host Pickers">
|
||||||
|
<div style={mutedTextStyle}>
|
||||||
|
These controls are imported from `@paperclipai/plugin-sdk/ui` and reuse the host's assignee and project pickers from the new issue pane.
|
||||||
|
</div>
|
||||||
|
{!context.companyId ? (
|
||||||
|
<div style={mutedTextStyle}>Select a company to load picker options.</div>
|
||||||
|
) : (
|
||||||
|
<div style={subtleCardStyle}>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
|
||||||
|
<AssigneePicker
|
||||||
|
companyId={context.companyId}
|
||||||
|
value={assigneeValue}
|
||||||
|
onChange={(value) => setAssigneeValue(value)}
|
||||||
|
/>
|
||||||
|
<ProjectPicker
|
||||||
|
companyId={context.companyId}
|
||||||
|
value={projectId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...mutedTextStyle, marginTop: "8px" }}>
|
||||||
|
Selected assignee: {assigneeValue || "none"}, selected project: {projectId || "none"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) {
|
function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "grid", gap: "14px" }}>
|
<div style={{ display: "grid", gap: "14px" }}>
|
||||||
|
|
@ -1301,12 +1337,14 @@ function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context
|
||||||
<KitchenSinkStorageDemo context={context} />
|
<KitchenSinkStorageDemo context={context} />
|
||||||
<KitchenSinkIssueCrudDemo context={context} />
|
<KitchenSinkIssueCrudDemo context={context} />
|
||||||
<KitchenSinkCompanyCrudDemo context={context} />
|
<KitchenSinkCompanyCrudDemo context={context} />
|
||||||
|
<KitchenSinkSharedPickerDemo context={context} />
|
||||||
<KitchenSinkHostIntegrationDemo context={context} />
|
<KitchenSinkHostIntegrationDemo context={context} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) {
|
function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const companyId = context.companyId;
|
const companyId = context.companyId;
|
||||||
const overview = usePluginOverview(companyId);
|
const overview = usePluginOverview(companyId);
|
||||||
const [companiesLimit, setCompaniesLimit] = useState(20);
|
const [companiesLimit, setCompaniesLimit] = useState(20);
|
||||||
|
|
@ -1531,10 +1569,10 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||||
|
|
||||||
<Section title="UI Surfaces">
|
<Section title="UI Surfaces">
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open plugin page</a>
|
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open plugin page</a>
|
||||||
{projectRef ? (
|
{projectRef ? (
|
||||||
<a
|
<a
|
||||||
href={hostPath(context.companyPrefix, `/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
{...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||||
style={{ fontSize: "12px" }}
|
style={{ fontSize: "12px" }}
|
||||||
>
|
>
|
||||||
Open project tab
|
Open project tab
|
||||||
|
|
@ -1542,7 +1580,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
|
||||||
) : null}
|
) : null}
|
||||||
{selectedIssueId ? (
|
{selectedIssueId ? (
|
||||||
<a
|
<a
|
||||||
href={hostPath(context.companyPrefix, `/issues/${selectedIssueId}`)}
|
{...hostNavigation.linkProps(`/issues/${selectedIssueId}`)}
|
||||||
style={{ fontSize: "12px" }}
|
style={{ fontSize: "12px" }}
|
||||||
>
|
>
|
||||||
Open selected issue
|
Open selected issue
|
||||||
|
|
@ -2199,6 +2237,7 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const overview = usePluginOverview(context.companyId);
|
const overview = usePluginOverview(context.companyId);
|
||||||
const writeMetric = usePluginAction("write-metric");
|
const writeMetric = usePluginAction("write-metric");
|
||||||
|
|
||||||
|
|
@ -2217,7 +2256,7 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||||
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
|
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={rowStyle}>
|
<div style={rowStyle}>
|
||||||
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>Open page</a>
|
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>Open page</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
|
|
@ -2234,13 +2273,14 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const config = usePluginConfigData();
|
const config = usePluginConfigData();
|
||||||
if (config.data && config.data.showSidebarEntry === false) return null;
|
if (config.data && config.data.showSidebarEntry === false) return null;
|
||||||
const href = pluginPagePath(context.companyPrefix);
|
const href = hostNavigation.resolveHref(`/${PAGE_ROUTE}`);
|
||||||
const isActive = typeof window !== "undefined" && window.location.pathname === href;
|
const isActive = typeof window !== "undefined" && window.location.pathname === href;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
{...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={[
|
className={[
|
||||||
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
"flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-colors",
|
||||||
|
|
@ -2267,6 +2307,7 @@ export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
|
||||||
|
|
||||||
export function KitchenSinkSidebarPanel() {
|
export function KitchenSinkSidebarPanel() {
|
||||||
const context = useHostContext();
|
const context = useHostContext();
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const config = usePluginConfigData();
|
const config = usePluginConfigData();
|
||||||
const overview = usePluginOverview(context.companyId);
|
const overview = usePluginOverview(context.companyId);
|
||||||
if (config.data && config.data.showSidebarPanel === false) return null;
|
if (config.data && config.data.showSidebarPanel === false) return null;
|
||||||
|
|
@ -2274,17 +2315,18 @@ export function KitchenSinkSidebarPanel() {
|
||||||
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
|
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
|
||||||
<strong>Kitchen Sink Panel</strong>
|
<strong>Kitchen Sink Panel</strong>
|
||||||
<div>Recent plugin records: {overview.data?.recentRecords.length ?? 0}</div>
|
<div>Recent plugin records: {overview.data?.recentRecords.length ?? 0}</div>
|
||||||
<a href={pluginPagePath(context.companyPrefix)}>Open plugin page</a>
|
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}>Open plugin page</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
|
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const config = usePluginConfigData();
|
const config = usePluginConfigData();
|
||||||
if (config.data && config.data.showProjectSidebarItem === false) return null;
|
if (config.data && config.data.showProjectSidebarItem === false) return null;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={hostPath(context.companyPrefix, `/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
{...hostNavigation.linkProps(`/projects/${context.entityId}?tab=plugin:${PLUGIN_ID}:${SLOT_IDS.projectTab}`)}
|
||||||
style={{ fontSize: "12px", textDecoration: "none" }}
|
style={{ fontSize: "12px", textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
Kitchen Sink
|
Kitchen Sink
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
|
||||||
| Import | Purpose |
|
| Import | Purpose |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
||||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
|
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types |
|
||||||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||||||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||||||
|
|
@ -47,7 +47,7 @@ The SDK is stable enough for local development and first-party examples, but the
|
||||||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||||||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||||||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||||||
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
|
- The host ships a small shared React component kit through `@paperclipai/plugin-sdk/ui`. Use it for native Paperclip controls; custom React and CSS are still supported.
|
||||||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||||||
|
|
||||||
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
||||||
|
|
@ -100,12 +100,14 @@ runWorker(plugin, import.meta.url);
|
||||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||||
|
|
||||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||||
|
|
||||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||||
|
|
||||||
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
||||||
|
|
||||||
|
**Trusted local folders:** Declare `manifest.localFolders[]` and the `local.folders` capability when a plugin needs an operator-configured company-scoped folder. Use `ctx.localFolders.configure()`, `status()`, `readText()`, and `writeTextAtomic()` instead of resolving arbitrary filesystem paths yourself. The host validates absolute roots, read/write access, required relative folders/files, traversal attempts, symlink escapes, and writes through temp-file-plus-rename atomic replacement.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
|
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
|
||||||
|
|
@ -201,12 +203,13 @@ Slots are mount points for plugin React components. Launchers are host-rendered
|
||||||
|
|
||||||
### Slot types / launcher placement zones
|
### Slot types / launcher placement zones
|
||||||
|
|
||||||
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
|
Slot types describe where a component mounts. Most values also exist as launcher placement zones.
|
||||||
|
|
||||||
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
||||||
|----------------------------|-------|---------------------------------------|
|
|----------------------------|-------|---------------------------------------|
|
||||||
| `page` | Global | — |
|
| `page` | Global | — |
|
||||||
| `sidebar` | Global | — |
|
| `sidebar` | Global | — |
|
||||||
|
| `routeSidebar` | Global | — |
|
||||||
| `sidebarPanel` | Global | — |
|
| `sidebarPanel` | Global | — |
|
||||||
| `settingsPage` | Global | — |
|
| `settingsPage` | Global | — |
|
||||||
| `dashboardWidget` | Global | — |
|
| `dashboardWidget` | Global | — |
|
||||||
|
|
@ -233,6 +236,10 @@ A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plu
|
||||||
|
|
||||||
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
|
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||||
|
|
||||||
|
#### `routeSidebar`
|
||||||
|
|
||||||
|
Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||||
|
|
||||||
#### `sidebarPanel`
|
#### `sidebarPanel`
|
||||||
|
|
||||||
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
|
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
|
||||||
|
|
@ -338,6 +345,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
||||||
| | `http.outbound` |
|
| | `http.outbound` |
|
||||||
| | `secrets.read-ref` |
|
| | `secrets.read-ref` |
|
||||||
| | `environment.drivers.register` |
|
| | `environment.drivers.register` |
|
||||||
|
| | `local.folders` |
|
||||||
| **Agent** | `agent.tools.register` |
|
| **Agent** | `agent.tools.register` |
|
||||||
| | `agents.invoke` |
|
| | `agents.invoke` |
|
||||||
| | `agent.sessions.create` |
|
| | `agent.sessions.create` |
|
||||||
|
|
@ -372,6 +380,38 @@ only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from
|
||||||
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
|
`ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the
|
||||||
plugin namespace.
|
plugin namespace.
|
||||||
|
|
||||||
|
### Trusted Local Folders
|
||||||
|
|
||||||
|
Trusted local plugins can request operator-configured folders per company:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const manifest = {
|
||||||
|
// ...
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [
|
||||||
|
{
|
||||||
|
folderKey: "content-root",
|
||||||
|
displayName: "Content root",
|
||||||
|
access: "readWrite",
|
||||||
|
requiredDirectories: ["sources", "pages"],
|
||||||
|
requiredFiles: ["schema.md"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The host stores the selected path in company-scoped plugin settings and exposes
|
||||||
|
readiness through:
|
||||||
|
|
||||||
|
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders`
|
||||||
|
- `GET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status`
|
||||||
|
- `POST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate`
|
||||||
|
- `PUT /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey`
|
||||||
|
|
||||||
|
Worker code should access files through `ctx.localFolders.readText()` and
|
||||||
|
`ctx.localFolders.writeTextAtomic()`. Relative paths must stay inside the
|
||||||
|
configured root; symlinks that escape the root are rejected.
|
||||||
|
|
||||||
### Scoped API Routes
|
### Scoped API Routes
|
||||||
|
|
||||||
Manifest-declared `apiRoutes` expose JSON routes under
|
Manifest-declared `apiRoutes` expose JSON routes under
|
||||||
|
|
@ -599,6 +639,23 @@ export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `useHostNavigation()`
|
||||||
|
|
||||||
|
Routes Paperclip-internal plugin links through the host router without a full document reload. Use `linkProps()` for anchors so the browser still gets a real `href` for copy-link, modifier-click, middle-click, and open-in-new-tab behavior.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useHostNavigation } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function WikiSidebarLink() {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
|
return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`linkProps("/wiki")` resolves against the active company prefix, so in company `PAP` it renders `href="/PAP/wiki"`. Already-prefixed paths such as `/PAP/wiki` are not prefixed again. For button-style commands, call `hostNavigation.navigate("/issues/PAP-123")`.
|
||||||
|
|
||||||
|
Avoid raw same-origin `href`s or `window.location.assign()` for Paperclip-internal navigation from plugin UI. Those bypass the host router and can reload the whole app. External links should keep normal anchors with `target="_blank"` and `rel="noopener noreferrer"` as appropriate.
|
||||||
|
|
||||||
#### `usePluginStream<T>(channel, options?)`
|
#### `usePluginStream<T>(channel, options?)`
|
||||||
|
|
||||||
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
||||||
|
|
@ -629,7 +686,118 @@ The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?co
|
||||||
|
|
||||||
### UI authoring note
|
### UI authoring note
|
||||||
|
|
||||||
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
|
The host provides selected shared UI components through `@paperclipai/plugin-sdk/ui`.
|
||||||
|
Plugins can also use normal React components, their own CSS, or small design
|
||||||
|
primitives inside the plugin package.
|
||||||
|
|
||||||
|
Use the shared components when the plugin needs to look and behave like a native
|
||||||
|
Paperclip surface:
|
||||||
|
|
||||||
|
| Component | Use when |
|
||||||
|
|---|---|
|
||||||
|
| `MarkdownBlock` | Rendering markdown from plugin or host data |
|
||||||
|
| `MarkdownEditor` | Editing markdown with the host editor treatment |
|
||||||
|
| `FileTree` | Showing serializable workspace/wiki/import paths |
|
||||||
|
| `IssuesList` | Embedding a company-scoped native issue list |
|
||||||
|
| `AssigneePicker` | Selecting an agent or board user with the same picker as the new issue pane |
|
||||||
|
| `ProjectPicker` | Selecting a project with the same picker as the new issue pane |
|
||||||
|
| `ManagedRoutinesList` | Showing plugin-managed routines in settings UI |
|
||||||
|
|
||||||
|
#### Shared Markdown Components
|
||||||
|
|
||||||
|
Plugin UI can render markdown and edit markdown using the same host components
|
||||||
|
used by Paperclip issue comments and documents:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { MarkdownBlock, MarkdownEditor } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function WikiPageEditor() {
|
||||||
|
const [body, setBody] = useState("# Wiki page");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MarkdownBlock content={body} />
|
||||||
|
<MarkdownEditor value={body} onChange={setBody} bordered />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MarkdownBlock` can opt into Obsidian-style wikilinks when a plugin owns the
|
||||||
|
target URL shape:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<MarkdownBlock
|
||||||
|
content={"See [[wiki/entities/paperclip|Paperclip]]."}
|
||||||
|
enableWikiLinks
|
||||||
|
wikiLinkRoot="/wiki/page"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared FileTree
|
||||||
|
|
||||||
|
Plugin UI can render the host file tree without importing host internals:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
const nodes: FileTreeNode[] = [
|
||||||
|
{ name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] },
|
||||||
|
{
|
||||||
|
name: "wiki",
|
||||||
|
path: "wiki",
|
||||||
|
kind: "dir",
|
||||||
|
children: [
|
||||||
|
{ name: "index.md", path: "wiki/index.md", kind: "file", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WikiFiles() {
|
||||||
|
return (
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
expandedPaths={["wiki"]}
|
||||||
|
selectedFile="wiki/index.md"
|
||||||
|
onToggleDir={(path) => console.log("toggle", path)}
|
||||||
|
onSelectFile={(path) => console.log("select", path)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared Assignee and Project Pickers
|
||||||
|
|
||||||
|
Use `AssigneePicker` and `ProjectPicker` when a plugin needs to create, filter,
|
||||||
|
or configure work against Paperclip entities. Both are controlled components and
|
||||||
|
load their options from the host for the provided company.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
|
export function AssignmentControls({ companyId }: { companyId: string }) {
|
||||||
|
const [assignee, setAssignee] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AssigneePicker
|
||||||
|
companyId={companyId}
|
||||||
|
value={assignee}
|
||||||
|
onChange={(value, selection) => {
|
||||||
|
setAssignee(value);
|
||||||
|
console.log(selection.assigneeAgentId, selection.assigneeUserId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProjectPicker
|
||||||
|
companyId={companyId}
|
||||||
|
value={projectId}
|
||||||
|
onChange={setProjectId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Slot component props
|
### Slot component props
|
||||||
|
|
||||||
|
|
@ -639,6 +807,7 @@ Each slot type receives a typed props object with `context: PluginHostContext`.
|
||||||
|-----------|----------------|------------------|
|
|-----------|----------------|------------------|
|
||||||
| `page` | `PluginPageProps` | — |
|
| `page` | `PluginPageProps` | — |
|
||||||
| `sidebar` | `PluginSidebarProps` | — |
|
| `sidebar` | `PluginSidebarProps` | — |
|
||||||
|
| `routeSidebar` | `PluginRouteSidebarProps` | — |
|
||||||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||||||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||||||
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
||||||
|
|
@ -741,14 +910,17 @@ Plugins can add a link under each project in the sidebar via the `projectSidebar
|
||||||
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
|
import {
|
||||||
|
useHostNavigation,
|
||||||
|
type PluginProjectSidebarItemProps,
|
||||||
|
} from "@paperclipai/plugin-sdk/ui";
|
||||||
|
|
||||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||||
|
const hostNavigation = useHostNavigation();
|
||||||
const projectId = context.entityId;
|
const projectId = context.entityId;
|
||||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
|
||||||
const projectRef = projectId; // or resolve from host; entityId is project id
|
const projectRef = projectId; // or resolve from host; entityId is project id
|
||||||
return (
|
return (
|
||||||
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
|
<a {...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:your-plugin:files`)}>
|
||||||
Files
|
Files
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -89,11 +89,12 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
|
||||||
const esbuildManifest: EsbuildLikeOptions = {
|
const esbuildManifest: EsbuildLikeOptions = {
|
||||||
entryPoints: [manifestEntry],
|
entryPoints: [manifestEntry],
|
||||||
outdir,
|
outdir,
|
||||||
bundle: false,
|
bundle: true,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
platform: "node",
|
platform: "node",
|
||||||
target: "node20",
|
target: "node20",
|
||||||
sourcemap,
|
sourcemap,
|
||||||
|
external: ["@paperclipai/plugin-sdk"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const esbuildUi = uiEntry
|
const esbuildUi = uiEntry
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,16 @@ export interface HostServices {
|
||||||
get(): Promise<Record<string, unknown>>;
|
get(): Promise<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Provides trusted company-scoped local folder helpers. */
|
||||||
|
localFolders: {
|
||||||
|
declarations(params: WorkerToHostMethods["localFolders.declarations"][0]): Promise<WorkerToHostMethods["localFolders.declarations"][1]>;
|
||||||
|
configure(params: WorkerToHostMethods["localFolders.configure"][0]): Promise<WorkerToHostMethods["localFolders.configure"][1]>;
|
||||||
|
status(params: WorkerToHostMethods["localFolders.status"][0]): Promise<WorkerToHostMethods["localFolders.status"][1]>;
|
||||||
|
list(params: WorkerToHostMethods["localFolders.list"][0]): Promise<WorkerToHostMethods["localFolders.list"][1]>;
|
||||||
|
readText(params: WorkerToHostMethods["localFolders.readText"][0]): Promise<WorkerToHostMethods["localFolders.readText"][1]>;
|
||||||
|
writeTextAtomic(params: WorkerToHostMethods["localFolders.writeTextAtomic"][0]): Promise<WorkerToHostMethods["localFolders.writeTextAtomic"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
/** Provides `state.get`, `state.set`, `state.delete`. */
|
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||||
state: {
|
state: {
|
||||||
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||||
|
|
@ -165,6 +175,18 @@ export interface HostServices {
|
||||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||||
|
getManaged(params: WorkerToHostMethods["projects.managed.get"][0]): Promise<WorkerToHostMethods["projects.managed.get"][1]>;
|
||||||
|
reconcileManaged(params: WorkerToHostMethods["projects.managed.reconcile"][0]): Promise<WorkerToHostMethods["projects.managed.reconcile"][1]>;
|
||||||
|
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provides `routines.managed.*`. */
|
||||||
|
routines: {
|
||||||
|
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
|
||||||
|
managedReconcile(params: WorkerToHostMethods["routines.managed.reconcile"][0]): Promise<WorkerToHostMethods["routines.managed.reconcile"][1]>;
|
||||||
|
managedReset(params: WorkerToHostMethods["routines.managed.reset"][0]): Promise<WorkerToHostMethods["routines.managed.reset"][1]>;
|
||||||
|
managedUpdate(params: WorkerToHostMethods["routines.managed.update"][0]): Promise<WorkerToHostMethods["routines.managed.update"][1]>;
|
||||||
|
managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise<WorkerToHostMethods["routines.managed.run"][1]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */
|
/** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */
|
||||||
|
|
@ -202,6 +224,9 @@ export interface HostServices {
|
||||||
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||||
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||||
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||||
|
managedGet(params: WorkerToHostMethods["agents.managed.get"][0]): Promise<WorkerToHostMethods["agents.managed.get"][1]>;
|
||||||
|
managedReconcile(params: WorkerToHostMethods["agents.managed.reconcile"][0]): Promise<WorkerToHostMethods["agents.managed.reconcile"][1]>;
|
||||||
|
managedReset(params: WorkerToHostMethods["agents.managed.reset"][0]): Promise<WorkerToHostMethods["agents.managed.reset"][1]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||||
|
|
@ -281,6 +306,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||||
// Config — always allowed
|
// Config — always allowed
|
||||||
"config.get": null,
|
"config.get": null,
|
||||||
|
|
||||||
|
// Trusted local folders
|
||||||
|
"localFolders.declarations": null,
|
||||||
|
"localFolders.configure": "local.folders",
|
||||||
|
"localFolders.status": "local.folders",
|
||||||
|
"localFolders.list": "local.folders",
|
||||||
|
"localFolders.readText": "local.folders",
|
||||||
|
"localFolders.writeTextAtomic": "local.folders",
|
||||||
|
|
||||||
// State
|
// State
|
||||||
"state.get": "plugin.state.read",
|
"state.get": "plugin.state.read",
|
||||||
"state.set": "plugin.state.write",
|
"state.set": "plugin.state.write",
|
||||||
|
|
@ -326,6 +359,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||||
"projects.listWorkspaces": "project.workspaces.read",
|
"projects.listWorkspaces": "project.workspaces.read",
|
||||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
"projects.getWorkspaceForIssue": "project.workspaces.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",
|
||||||
|
"routines.managed.update": "routines.managed",
|
||||||
|
"routines.managed.run": "routines.managed",
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
"issues.list": "issues.read",
|
"issues.list": "issues.read",
|
||||||
|
|
@ -357,6 +398,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
||||||
"agents.pause": "agents.pause",
|
"agents.pause": "agents.pause",
|
||||||
"agents.resume": "agents.resume",
|
"agents.resume": "agents.resume",
|
||||||
"agents.invoke": "agents.invoke",
|
"agents.invoke": "agents.invoke",
|
||||||
|
"agents.managed.get": "agents.managed",
|
||||||
|
"agents.managed.reconcile": "agents.managed",
|
||||||
|
"agents.managed.reset": "agents.managed",
|
||||||
|
|
||||||
// Agent Sessions
|
// Agent Sessions
|
||||||
"agents.sessions.create": "agent.sessions.create",
|
"agents.sessions.create": "agent.sessions.create",
|
||||||
|
|
@ -439,6 +483,25 @@ export function createHostClientHandlers(
|
||||||
return services.config.get();
|
return services.config.get();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
"localFolders.declarations": gated("localFolders.declarations", async (params) => {
|
||||||
|
return services.localFolders.declarations(params);
|
||||||
|
}),
|
||||||
|
"localFolders.configure": gated("localFolders.configure", async (params) => {
|
||||||
|
return services.localFolders.configure(params);
|
||||||
|
}),
|
||||||
|
"localFolders.status": gated("localFolders.status", async (params) => {
|
||||||
|
return services.localFolders.status(params);
|
||||||
|
}),
|
||||||
|
"localFolders.list": gated("localFolders.list", async (params) => {
|
||||||
|
return services.localFolders.list(params);
|
||||||
|
}),
|
||||||
|
"localFolders.readText": gated("localFolders.readText", async (params) => {
|
||||||
|
return services.localFolders.readText(params);
|
||||||
|
}),
|
||||||
|
"localFolders.writeTextAtomic": gated("localFolders.writeTextAtomic", async (params) => {
|
||||||
|
return services.localFolders.writeTextAtomic(params);
|
||||||
|
}),
|
||||||
|
|
||||||
// State
|
// State
|
||||||
"state.get": gated("state.get", async (params) => {
|
"state.get": gated("state.get", async (params) => {
|
||||||
return services.state.get(params);
|
return services.state.get(params);
|
||||||
|
|
@ -530,6 +593,32 @@ export function createHostClientHandlers(
|
||||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||||
return services.projects.getWorkspaceForIssue(params);
|
return services.projects.getWorkspaceForIssue(params);
|
||||||
}),
|
}),
|
||||||
|
"projects.managed.get": gated("projects.managed.get", async (params) => {
|
||||||
|
return services.projects.getManaged(params);
|
||||||
|
}),
|
||||||
|
"projects.managed.reconcile": gated("projects.managed.reconcile", async (params) => {
|
||||||
|
return services.projects.reconcileManaged(params);
|
||||||
|
}),
|
||||||
|
"projects.managed.reset": gated("projects.managed.reset", async (params) => {
|
||||||
|
return services.projects.resetManaged(params);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Routines
|
||||||
|
"routines.managed.get": gated("routines.managed.get", async (params) => {
|
||||||
|
return services.routines.managedGet(params);
|
||||||
|
}),
|
||||||
|
"routines.managed.reconcile": gated("routines.managed.reconcile", async (params) => {
|
||||||
|
return services.routines.managedReconcile(params);
|
||||||
|
}),
|
||||||
|
"routines.managed.reset": gated("routines.managed.reset", async (params) => {
|
||||||
|
return services.routines.managedReset(params);
|
||||||
|
}),
|
||||||
|
"routines.managed.update": gated("routines.managed.update", async (params) => {
|
||||||
|
return services.routines.managedUpdate(params);
|
||||||
|
}),
|
||||||
|
"routines.managed.run": gated("routines.managed.run", async (params) => {
|
||||||
|
return services.routines.managedRun(params);
|
||||||
|
}),
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
"issues.list": gated("issues.list", async (params) => {
|
"issues.list": gated("issues.list", async (params) => {
|
||||||
|
|
@ -611,6 +700,15 @@ export function createHostClientHandlers(
|
||||||
"agents.invoke": gated("agents.invoke", async (params) => {
|
"agents.invoke": gated("agents.invoke", async (params) => {
|
||||||
return services.agents.invoke(params);
|
return services.agents.invoke(params);
|
||||||
}),
|
}),
|
||||||
|
"agents.managed.get": gated("agents.managed.get", async (params) => {
|
||||||
|
return services.agents.managedGet(params);
|
||||||
|
}),
|
||||||
|
"agents.managed.reconcile": gated("agents.managed.reconcile", async (params) => {
|
||||||
|
return services.agents.managedReconcile(params);
|
||||||
|
}),
|
||||||
|
"agents.managed.reset": gated("agents.managed.reset", async (params) => {
|
||||||
|
return services.agents.managedReset(params);
|
||||||
|
}),
|
||||||
|
|
||||||
// Agent Sessions
|
// Agent Sessions
|
||||||
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,13 @@ export type {
|
||||||
export type {
|
export type {
|
||||||
PluginContext,
|
PluginContext,
|
||||||
PluginConfigClient,
|
PluginConfigClient,
|
||||||
|
PluginLocalFolderProblem,
|
||||||
|
PluginLocalFolderStatus,
|
||||||
|
PluginLocalFolderConfigureInput,
|
||||||
|
PluginLocalFolderListOptions,
|
||||||
|
PluginLocalFolderEntry,
|
||||||
|
PluginLocalFolderListing,
|
||||||
|
PluginLocalFoldersClient,
|
||||||
PluginEventsClient,
|
PluginEventsClient,
|
||||||
PluginJobsClient,
|
PluginJobsClient,
|
||||||
PluginLaunchersClient,
|
PluginLaunchersClient,
|
||||||
|
|
@ -255,6 +262,14 @@ export type {
|
||||||
PluginWebhookDeclaration,
|
PluginWebhookDeclaration,
|
||||||
PluginToolDeclaration,
|
PluginToolDeclaration,
|
||||||
PluginEnvironmentDriverDeclaration,
|
PluginEnvironmentDriverDeclaration,
|
||||||
|
PluginManagedAgentDeclaration,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectDeclaration,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineDeclaration,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
PluginManagedResourceKind,
|
||||||
|
PluginManagedResourceRef,
|
||||||
PluginUiSlotDeclaration,
|
PluginUiSlotDeclaration,
|
||||||
PluginUiDeclaration,
|
PluginUiDeclaration,
|
||||||
PluginLauncherActionDeclaration,
|
PluginLauncherActionDeclaration,
|
||||||
|
|
@ -264,6 +279,8 @@ export type {
|
||||||
PluginDatabaseDeclaration,
|
PluginDatabaseDeclaration,
|
||||||
PluginApiRouteCompanyResolution,
|
PluginApiRouteCompanyResolution,
|
||||||
PluginApiRouteDeclaration,
|
PluginApiRouteDeclaration,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
|
PluginCompanySettings,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
PluginDatabaseNamespaceRecord,
|
PluginDatabaseNamespaceRecord,
|
||||||
PluginMigrationRecord,
|
PluginMigrationRecord,
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,14 @@ import type {
|
||||||
IssueDocumentSummary,
|
IssueDocumentSummary,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
Routine,
|
||||||
|
RoutineRun,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
|
@ -46,6 +52,8 @@ import type {
|
||||||
PluginWorkspace,
|
PluginWorkspace,
|
||||||
ToolRunContext,
|
ToolRunContext,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
|
PluginLocalFolderListing,
|
||||||
|
PluginLocalFolderStatus,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type {
|
import type {
|
||||||
PluginHealthDiagnostics,
|
PluginHealthDiagnostics,
|
||||||
|
|
@ -566,6 +574,44 @@ export interface WorkerToHostMethods {
|
||||||
// Config
|
// Config
|
||||||
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
|
"config.get": [params: Record<string, never>, result: Record<string, unknown>];
|
||||||
|
|
||||||
|
// Trusted local folders
|
||||||
|
"localFolders.declarations": [
|
||||||
|
params: Record<string, never>,
|
||||||
|
result: PluginLocalFolderDeclaration[],
|
||||||
|
];
|
||||||
|
"localFolders.configure": [
|
||||||
|
params: {
|
||||||
|
companyId: string;
|
||||||
|
folderKey: string;
|
||||||
|
path: string;
|
||||||
|
access?: "read" | "readWrite";
|
||||||
|
requiredDirectories?: string[];
|
||||||
|
requiredFiles?: string[];
|
||||||
|
},
|
||||||
|
result: PluginLocalFolderStatus,
|
||||||
|
];
|
||||||
|
"localFolders.status": [
|
||||||
|
params: { companyId: string; folderKey: string },
|
||||||
|
result: PluginLocalFolderStatus,
|
||||||
|
];
|
||||||
|
"localFolders.list": [
|
||||||
|
params: { companyId: string; folderKey: string; relativePath?: string | null; recursive?: boolean; maxEntries?: number },
|
||||||
|
result: PluginLocalFolderListing,
|
||||||
|
];
|
||||||
|
"localFolders.readText": [
|
||||||
|
params: { companyId: string; folderKey: string; relativePath: string },
|
||||||
|
result: string,
|
||||||
|
];
|
||||||
|
"localFolders.writeTextAtomic": [
|
||||||
|
params: {
|
||||||
|
companyId: string;
|
||||||
|
folderKey: string;
|
||||||
|
relativePath: string;
|
||||||
|
contents: string;
|
||||||
|
},
|
||||||
|
result: PluginLocalFolderStatus,
|
||||||
|
];
|
||||||
|
|
||||||
// State
|
// State
|
||||||
"state.get": [
|
"state.get": [
|
||||||
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
|
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
|
||||||
|
|
@ -724,6 +770,57 @@ export interface WorkerToHostMethods {
|
||||||
params: { issueId: string; companyId: string },
|
params: { issueId: string; companyId: string },
|
||||||
result: PluginWorkspace | null,
|
result: PluginWorkspace | null,
|
||||||
];
|
];
|
||||||
|
"projects.managed.get": [
|
||||||
|
params: { projectKey: string; companyId: string },
|
||||||
|
result: PluginManagedProjectResolution,
|
||||||
|
];
|
||||||
|
"projects.managed.reconcile": [
|
||||||
|
params: { projectKey: string; companyId: string },
|
||||||
|
result: PluginManagedProjectResolution,
|
||||||
|
];
|
||||||
|
"projects.managed.reset": [
|
||||||
|
params: { projectKey: string; companyId: string },
|
||||||
|
result: PluginManagedProjectResolution,
|
||||||
|
];
|
||||||
|
"routines.managed.get": [
|
||||||
|
params: { routineKey: string; companyId: string },
|
||||||
|
result: PluginManagedRoutineResolution,
|
||||||
|
];
|
||||||
|
"routines.managed.reconcile": [
|
||||||
|
params: {
|
||||||
|
routineKey: string;
|
||||||
|
companyId: string;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
},
|
||||||
|
result: PluginManagedRoutineResolution,
|
||||||
|
];
|
||||||
|
"routines.managed.reset": [
|
||||||
|
params: {
|
||||||
|
routineKey: string;
|
||||||
|
companyId: string;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
},
|
||||||
|
result: PluginManagedRoutineResolution,
|
||||||
|
];
|
||||||
|
"routines.managed.update": [
|
||||||
|
params: {
|
||||||
|
routineKey: string;
|
||||||
|
companyId: string;
|
||||||
|
status?: string;
|
||||||
|
},
|
||||||
|
result: Routine,
|
||||||
|
];
|
||||||
|
"routines.managed.run": [
|
||||||
|
params: {
|
||||||
|
routineKey: string;
|
||||||
|
companyId: string;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
},
|
||||||
|
result: RoutineRun,
|
||||||
|
];
|
||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
"issues.list": [
|
"issues.list": [
|
||||||
|
|
@ -732,8 +829,10 @@ export interface WorkerToHostMethods {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
|
originKindPrefix?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
includePluginOperations?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
},
|
},
|
||||||
|
|
@ -758,6 +857,7 @@ export interface WorkerToHostMethods {
|
||||||
assigneeUserId?: string | null;
|
assigneeUserId?: string | null;
|
||||||
requestDepth?: number;
|
requestDepth?: number;
|
||||||
billingCode?: string | null;
|
billingCode?: string | null;
|
||||||
|
surfaceVisibility?: string | null;
|
||||||
originKind?: string | null;
|
originKind?: string | null;
|
||||||
originId?: string | null;
|
originId?: string | null;
|
||||||
originRunId?: string | null;
|
originRunId?: string | null;
|
||||||
|
|
@ -940,6 +1040,18 @@ export interface WorkerToHostMethods {
|
||||||
params: { agentId: string; companyId: string; prompt: string; reason?: string },
|
params: { agentId: string; companyId: string; prompt: string; reason?: string },
|
||||||
result: { runId: string },
|
result: { runId: string },
|
||||||
];
|
];
|
||||||
|
"agents.managed.get": [
|
||||||
|
params: { agentKey: string; companyId: string },
|
||||||
|
result: PluginManagedAgentResolution,
|
||||||
|
];
|
||||||
|
"agents.managed.reconcile": [
|
||||||
|
params: { agentKey: string; companyId: string },
|
||||||
|
result: PluginManagedAgentResolution,
|
||||||
|
];
|
||||||
|
"agents.managed.reset": [
|
||||||
|
params: { agentKey: string; companyId: string },
|
||||||
|
result: PluginManagedAgentResolution,
|
||||||
|
];
|
||||||
|
|
||||||
// Agent Sessions
|
// Agent Sessions
|
||||||
"agents.sessions.create": [
|
"agents.sessions.create": [
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||||
import type {
|
import type {
|
||||||
PaperclipPluginManifestV1,
|
PaperclipPluginManifestV1,
|
||||||
PluginCapability,
|
PluginCapability,
|
||||||
PluginEventType,
|
PluginEventType,
|
||||||
PluginIssueOriginKind,
|
PluginIssueOriginKind,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
Company,
|
Company,
|
||||||
Project,
|
Project,
|
||||||
|
Routine,
|
||||||
|
RoutineRun,
|
||||||
Issue,
|
Issue,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
IssueThreadInteraction,
|
IssueThreadInteraction,
|
||||||
|
|
@ -419,6 +424,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
const entityExternalIndex = new Map<string, string>();
|
const entityExternalIndex = new Map<string, string>();
|
||||||
const companies = new Map<string, Company>();
|
const companies = new Map<string, Company>();
|
||||||
const projects = new Map<string, Project>();
|
const projects = new Map<string, Project>();
|
||||||
|
const routines = new Map<string, Routine>();
|
||||||
|
const routineRuns = new Map<string, RoutineRun>();
|
||||||
const issues = new Map<string, Issue>();
|
const issues = new Map<string, Issue>();
|
||||||
const blockedByIssueIds = new Map<string, string[]>();
|
const blockedByIssueIds = new Map<string, string[]>();
|
||||||
const issueComments = new Map<string, IssueComment[]>();
|
const issueComments = new Map<string, IssueComment[]>();
|
||||||
|
|
@ -465,6 +472,53 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
|
const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`;
|
||||||
|
|
||||||
|
function managedAgentDeclaration(agentKey: string) {
|
||||||
|
const declaration = manifest.agents?.find((agent) => agent.agentKey === agentKey);
|
||||||
|
if (!declaration) throw new Error(`Managed agent declaration not found: ${agentKey}`);
|
||||||
|
return declaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isManagedAgent(agent: Agent, agentKey: string) {
|
||||||
|
const marker = agent.metadata?.paperclipManagedResource;
|
||||||
|
return Boolean(
|
||||||
|
marker
|
||||||
|
&& typeof marker === "object"
|
||||||
|
&& !Array.isArray(marker)
|
||||||
|
&& (marker as Record<string, unknown>).pluginKey === manifest.id
|
||||||
|
&& (marker as Record<string, unknown>).resourceKind === "agent"
|
||||||
|
&& (marker as Record<string, unknown>).resourceKey === agentKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedAgentMetadata(agentKey: string, existing?: Record<string, unknown> | null) {
|
||||||
|
return {
|
||||||
|
...(existing ?? {}),
|
||||||
|
paperclipManagedResource: {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "agent",
|
||||||
|
resourceKey: agentKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managedResolution(
|
||||||
|
agentKey: string,
|
||||||
|
companyId: string,
|
||||||
|
agent: Agent | null,
|
||||||
|
status: PluginManagedAgentResolution["status"],
|
||||||
|
): PluginManagedAgentResolution {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "agent",
|
||||||
|
resourceKey: agentKey,
|
||||||
|
companyId,
|
||||||
|
agentId: agent?.id ?? null,
|
||||||
|
agent,
|
||||||
|
status,
|
||||||
|
approvalId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
|
function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind {
|
||||||
if (originKind == null || originKind === "") return defaultPluginOriginKind;
|
if (originKind == null || originKind === "") return defaultPluginOriginKind;
|
||||||
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
|
if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string");
|
||||||
|
|
@ -481,6 +535,81 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
return { ...currentConfig };
|
return { ...currentConfig };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
localFolders: {
|
||||||
|
declarations() {
|
||||||
|
return manifest.localFolders ?? [];
|
||||||
|
},
|
||||||
|
async configure(input) {
|
||||||
|
requireCapability(manifest, capabilitySet, "local.folders");
|
||||||
|
return {
|
||||||
|
folderKey: input.folderKey,
|
||||||
|
configured: true,
|
||||||
|
path: input.path,
|
||||||
|
realPath: input.path,
|
||||||
|
access: input.access ?? "readWrite",
|
||||||
|
readable: true,
|
||||||
|
writable: input.access === "read" ? false : true,
|
||||||
|
requiredDirectories: input.requiredDirectories ?? [],
|
||||||
|
requiredFiles: input.requiredFiles ?? [],
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: [],
|
||||||
|
healthy: true,
|
||||||
|
problems: [],
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async status(_companyId, folderKey) {
|
||||||
|
requireCapability(manifest, capabilitySet, "local.folders");
|
||||||
|
return {
|
||||||
|
folderKey,
|
||||||
|
configured: false,
|
||||||
|
path: null,
|
||||||
|
realPath: null,
|
||||||
|
access: "readWrite",
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
requiredDirectories: [],
|
||||||
|
requiredFiles: [],
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: [],
|
||||||
|
healthy: false,
|
||||||
|
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async list(_companyId, folderKey, options) {
|
||||||
|
requireCapability(manifest, capabilitySet, "local.folders");
|
||||||
|
return {
|
||||||
|
folderKey,
|
||||||
|
relativePath: options?.relativePath ?? null,
|
||||||
|
entries: [],
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async readText() {
|
||||||
|
requireCapability(manifest, capabilitySet, "local.folders");
|
||||||
|
throw new Error("Test harness local folder readText is not implemented");
|
||||||
|
},
|
||||||
|
async writeTextAtomic(_companyId, folderKey) {
|
||||||
|
requireCapability(manifest, capabilitySet, "local.folders");
|
||||||
|
return {
|
||||||
|
folderKey,
|
||||||
|
configured: false,
|
||||||
|
path: null,
|
||||||
|
realPath: null,
|
||||||
|
access: "readWrite",
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
requiredDirectories: [],
|
||||||
|
requiredFiles: [],
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: [],
|
||||||
|
healthy: false,
|
||||||
|
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||||
|
|
@ -647,6 +776,314 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||||
},
|
},
|
||||||
|
managed: {
|
||||||
|
async get(projectKey, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "projects.managed");
|
||||||
|
const declaration = manifest.projects?.find((project) => project.projectKey === projectKey);
|
||||||
|
if (!declaration) {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "project",
|
||||||
|
resourceKey: projectKey,
|
||||||
|
companyId,
|
||||||
|
projectId: null,
|
||||||
|
project: null,
|
||||||
|
status: "missing",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const externalId = `${manifest.id}:project:${projectKey}`;
|
||||||
|
const existingEntity = [...entities.values()].find((entity) =>
|
||||||
|
entity.entityType === "managed_resource"
|
||||||
|
&& entity.scopeKind === "company"
|
||||||
|
&& entity.scopeId === companyId
|
||||||
|
&& entity.externalId === externalId
|
||||||
|
);
|
||||||
|
const existingProject = existingEntity ? projects.get(String(existingEntity.data?.projectId ?? "")) : null;
|
||||||
|
if (existingProject && isInCompany(existingProject, companyId)) {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "project",
|
||||||
|
resourceKey: projectKey,
|
||||||
|
companyId,
|
||||||
|
projectId: existingProject.id,
|
||||||
|
project: existingProject,
|
||||||
|
status: "resolved",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const project = {
|
||||||
|
id: `project-${projects.size + 1}`,
|
||||||
|
companyId,
|
||||||
|
urlKey: declaration.projectKey,
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: declaration.displayName,
|
||||||
|
description: declaration.description ?? null,
|
||||||
|
status: declaration.status ?? "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: declaration.color ?? null,
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: `/tmp/${declaration.projectKey}`,
|
||||||
|
effectiveLocalFolder: `/tmp/${declaration.projectKey}`,
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
managedByPlugin: {
|
||||||
|
id: `managed-${projects.size + 1}`,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
pluginDisplayName: manifest.displayName,
|
||||||
|
resourceKind: "project",
|
||||||
|
resourceKey: projectKey,
|
||||||
|
defaultsJson: { displayName: declaration.displayName, settings: declaration.settings ?? {} },
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} as Project;
|
||||||
|
projects.set(project.id, project);
|
||||||
|
const externalKey = `managed_resource|company|${companyId}|${externalId}`;
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
const record: PluginEntityRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
entityType: "managed_resource",
|
||||||
|
scopeKind: "company",
|
||||||
|
scopeId: companyId,
|
||||||
|
externalId,
|
||||||
|
title: declaration.displayName,
|
||||||
|
status: null,
|
||||||
|
data: { resourceKind: "project", resourceKey: projectKey, projectId: project.id },
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
};
|
||||||
|
entities.set(record.id, record);
|
||||||
|
entityExternalIndex.set(externalKey, record.id);
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "project",
|
||||||
|
resourceKey: projectKey,
|
||||||
|
companyId,
|
||||||
|
projectId: project.id,
|
||||||
|
project,
|
||||||
|
status: "created",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async reconcile(projectKey, companyId) {
|
||||||
|
return this.get(projectKey, companyId);
|
||||||
|
},
|
||||||
|
async reset(projectKey, companyId) {
|
||||||
|
const resolved = await this.get(projectKey, companyId);
|
||||||
|
return { ...resolved, status: resolved.project ? "reset" : resolved.status };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routines: {
|
||||||
|
managed: {
|
||||||
|
async get(routineKey, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "routines.managed");
|
||||||
|
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
||||||
|
if (!declaration) {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
companyId,
|
||||||
|
routineId: null,
|
||||||
|
routine: null,
|
||||||
|
status: "missing",
|
||||||
|
missingRefs: [],
|
||||||
|
} satisfies PluginManagedRoutineResolution;
|
||||||
|
}
|
||||||
|
const externalId = `${manifest.id}:routine:${routineKey}`;
|
||||||
|
const existingEntity = [...entities.values()].find((entity) =>
|
||||||
|
entity.entityType === "managed_resource"
|
||||||
|
&& entity.scopeKind === "company"
|
||||||
|
&& entity.scopeId === companyId
|
||||||
|
&& entity.externalId === externalId
|
||||||
|
);
|
||||||
|
const existingRoutine = existingEntity ? routines.get(String(existingEntity.data?.routineId ?? "")) : null;
|
||||||
|
if (existingRoutine && isInCompany(existingRoutine, companyId)) {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
companyId,
|
||||||
|
routineId: existingRoutine.id,
|
||||||
|
routine: existingRoutine,
|
||||||
|
status: "resolved",
|
||||||
|
missingRefs: [],
|
||||||
|
} satisfies PluginManagedRoutineResolution;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
companyId,
|
||||||
|
routineId: null,
|
||||||
|
routine: null,
|
||||||
|
status: "missing",
|
||||||
|
missingRefs: [],
|
||||||
|
} satisfies PluginManagedRoutineResolution;
|
||||||
|
},
|
||||||
|
async reconcile(routineKey, companyId, overrides) {
|
||||||
|
const existing = await this.get(routineKey, companyId);
|
||||||
|
if (existing.routine) return existing;
|
||||||
|
const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey);
|
||||||
|
if (!declaration) return existing;
|
||||||
|
const now = new Date();
|
||||||
|
const agentRef = declaration.assigneeRef;
|
||||||
|
const projectRef = declaration.projectRef;
|
||||||
|
const assigneeAgentId = overrides?.assigneeAgentId
|
||||||
|
?? (agentRef?.resourceKind === "agent"
|
||||||
|
? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id
|
||||||
|
: null)
|
||||||
|
?? null;
|
||||||
|
const projectId = overrides?.projectId
|
||||||
|
?? (projectRef?.resourceKind === "project"
|
||||||
|
? [...projects.values()].find((project) => (
|
||||||
|
isInCompany(project, companyId)
|
||||||
|
&& project.managedByPlugin?.pluginKey === manifest.id
|
||||||
|
&& project.managedByPlugin?.resourceKey === projectRef.resourceKey
|
||||||
|
))?.id
|
||||||
|
: null)
|
||||||
|
?? null;
|
||||||
|
const missingRefs: NonNullable<PluginManagedRoutineResolution["missingRefs"]> = [];
|
||||||
|
if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id });
|
||||||
|
if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id });
|
||||||
|
if (missingRefs.length > 0) {
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
companyId,
|
||||||
|
routineId: null,
|
||||||
|
routine: null,
|
||||||
|
status: "missing_refs",
|
||||||
|
missingRefs,
|
||||||
|
} satisfies PluginManagedRoutineResolution;
|
||||||
|
}
|
||||||
|
const routine = {
|
||||||
|
id: `routine-${routines.size + 1}`,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
goalId: declaration.goalId ?? null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: declaration.title,
|
||||||
|
description: declaration.description ?? null,
|
||||||
|
assigneeAgentId,
|
||||||
|
priority: declaration.priority ?? "medium",
|
||||||
|
status: declaration.status ?? (assigneeAgentId ? "active" : "paused"),
|
||||||
|
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
|
||||||
|
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
|
||||||
|
variables: declaration.variables ?? [],
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
managedByPlugin: {
|
||||||
|
id: `managed-routine-${routines.size + 1}`,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
pluginDisplayName: manifest.displayName,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null },
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
} as Routine;
|
||||||
|
routines.set(routine.id, routine);
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
const record: PluginEntityRecord = {
|
||||||
|
id: randomUUID(),
|
||||||
|
entityType: "managed_resource",
|
||||||
|
scopeKind: "company",
|
||||||
|
scopeId: companyId,
|
||||||
|
externalId: `${manifest.id}:routine:${routineKey}`,
|
||||||
|
title: declaration.title,
|
||||||
|
status: null,
|
||||||
|
data: { resourceKind: "routine", resourceKey: routineKey, routineId: routine.id },
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
};
|
||||||
|
entities.set(record.id, record);
|
||||||
|
return {
|
||||||
|
pluginKey: manifest.id,
|
||||||
|
resourceKind: "routine",
|
||||||
|
resourceKey: routineKey,
|
||||||
|
companyId,
|
||||||
|
routineId: routine.id,
|
||||||
|
routine,
|
||||||
|
status: "created",
|
||||||
|
missingRefs: [],
|
||||||
|
} satisfies PluginManagedRoutineResolution;
|
||||||
|
},
|
||||||
|
async reset(routineKey, companyId, overrides) {
|
||||||
|
const resolved = await this.reconcile(routineKey, companyId, overrides);
|
||||||
|
return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution;
|
||||||
|
},
|
||||||
|
async update(routineKey, companyId, patch) {
|
||||||
|
const resolved = await this.get(routineKey, companyId);
|
||||||
|
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
||||||
|
const next = {
|
||||||
|
...resolved.routine,
|
||||||
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
routines.set(next.id, next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
async run(routineKey, companyId) {
|
||||||
|
const resolved = await this.get(routineKey, companyId);
|
||||||
|
if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`);
|
||||||
|
const now = new Date();
|
||||||
|
const run = {
|
||||||
|
id: `routine-run-${routineRuns.size + 1}`,
|
||||||
|
companyId,
|
||||||
|
routineId: resolved.routine.id,
|
||||||
|
triggerId: null,
|
||||||
|
source: "manual",
|
||||||
|
status: "queued",
|
||||||
|
triggeredAt: now,
|
||||||
|
idempotencyKey: null,
|
||||||
|
triggerPayload: null,
|
||||||
|
dispatchFingerprint: null,
|
||||||
|
linkedIssueId: null,
|
||||||
|
coalescedIntoRunId: null,
|
||||||
|
failureReason: null,
|
||||||
|
completedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
} satisfies RoutineRun;
|
||||||
|
routineRuns.set(run.id, run);
|
||||||
|
routines.set(resolved.routine.id, {
|
||||||
|
...resolved.routine,
|
||||||
|
lastTriggeredAt: now,
|
||||||
|
lastEnqueuedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return run;
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
companies: {
|
companies: {
|
||||||
async list(input) {
|
async list(input) {
|
||||||
|
|
@ -673,6 +1110,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
|
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind);
|
||||||
out = out.filter((issue) => issue.originKind === input.originKind);
|
out = out.filter((issue) => issue.originKind === input.originKind);
|
||||||
}
|
}
|
||||||
|
if (input?.originKindPrefix) {
|
||||||
|
const prefix = input.originKindPrefix;
|
||||||
|
out = out.filter((issue) =>
|
||||||
|
typeof issue.originKind === "string" && issue.originKind.startsWith(prefix),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
|
if (input?.originId) out = out.filter((issue) => issue.originId === input.originId);
|
||||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||||
if (input?.offset) out = out.slice(input.offset);
|
if (input?.offset) out = out.slice(input.offset);
|
||||||
|
|
@ -687,6 +1130,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
async create(input) {
|
async create(input) {
|
||||||
requireCapability(manifest, capabilitySet, "issues.create");
|
requireCapability(manifest, capabilitySet, "issues.create");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const originKind = normalizePluginOriginKind(
|
||||||
|
input.surfaceVisibility === "plugin_operation" && !input.originKind
|
||||||
|
? pluginOperationIssueOriginKind(manifest.id)
|
||||||
|
: input.originKind,
|
||||||
|
);
|
||||||
const record: Issue = {
|
const record: Issue = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
|
|
@ -708,7 +1156,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
createdByUserId: null,
|
createdByUserId: null,
|
||||||
issueNumber: null,
|
issueNumber: null,
|
||||||
identifier: null,
|
identifier: null,
|
||||||
originKind: normalizePluginOriginKind(input.originKind),
|
originKind,
|
||||||
originId: input.originId ?? null,
|
originId: input.originId ?? null,
|
||||||
originRunId: input.originRunId ?? null,
|
originRunId: input.originRunId ?? null,
|
||||||
requestDepth: input.requestDepth ?? 0,
|
requestDepth: input.requestDepth ?? 0,
|
||||||
|
|
@ -1064,6 +1512,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||||
}
|
}
|
||||||
return { runId: randomUUID() };
|
return { runId: randomUUID() };
|
||||||
},
|
},
|
||||||
|
managed: {
|
||||||
|
async get(agentKey, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
managedAgentDeclaration(agentKey);
|
||||||
|
const agent = [...agents.values()].find((candidate) =>
|
||||||
|
candidate.companyId === cid &&
|
||||||
|
candidate.status !== "terminated" &&
|
||||||
|
isManagedAgent(candidate, agentKey),
|
||||||
|
) ?? null;
|
||||||
|
return managedResolution(agentKey, cid, agent, agent ? "resolved" : "missing");
|
||||||
|
},
|
||||||
|
async reconcile(agentKey, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const declaration = managedAgentDeclaration(agentKey);
|
||||||
|
const existingAgent = [...agents.values()].find((candidate) =>
|
||||||
|
candidate.companyId === cid &&
|
||||||
|
candidate.status !== "terminated" &&
|
||||||
|
isManagedAgent(candidate, agentKey),
|
||||||
|
) ?? null;
|
||||||
|
const existing = managedResolution(agentKey, cid, existingAgent, existingAgent ? "resolved" : "missing");
|
||||||
|
if (existing.agent) return existing;
|
||||||
|
const now = new Date();
|
||||||
|
const created: Agent = {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: cid,
|
||||||
|
name: declaration.displayName,
|
||||||
|
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
||||||
|
role: (declaration.role ?? "general") as Agent["role"],
|
||||||
|
title: declaration.title ?? null,
|
||||||
|
icon: declaration.icon ?? null,
|
||||||
|
status: declaration.status ?? "idle",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: declaration.capabilities ?? null,
|
||||||
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||||
|
adapterConfig: declaration.adapterConfig ?? {},
|
||||||
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||||
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: managedAgentMetadata(agentKey),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
agents.set(created.id, created);
|
||||||
|
return managedResolution(agentKey, cid, created, "created");
|
||||||
|
},
|
||||||
|
async reset(agentKey, companyId) {
|
||||||
|
requireCapability(manifest, capabilitySet, "agents.managed");
|
||||||
|
const cid = requireCompanyId(companyId);
|
||||||
|
const declaration = managedAgentDeclaration(agentKey);
|
||||||
|
let agent = [...agents.values()].find((candidate) =>
|
||||||
|
candidate.companyId === cid &&
|
||||||
|
candidate.status !== "terminated" &&
|
||||||
|
isManagedAgent(candidate, agentKey),
|
||||||
|
) ?? null;
|
||||||
|
if (!agent) {
|
||||||
|
const now = new Date();
|
||||||
|
agent = {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: cid,
|
||||||
|
name: declaration.displayName,
|
||||||
|
urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
|
||||||
|
role: (declaration.role ?? "general") as Agent["role"],
|
||||||
|
title: declaration.title ?? null,
|
||||||
|
icon: declaration.icon ?? null,
|
||||||
|
status: declaration.status ?? "idle",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: declaration.capabilities ?? null,
|
||||||
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||||
|
adapterConfig: declaration.adapterConfig ?? {},
|
||||||
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||||
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: managedAgentMetadata(agentKey),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
agents.set(agent.id, agent);
|
||||||
|
}
|
||||||
|
const resolved = managedResolution(agentKey, cid, agent, "resolved");
|
||||||
|
if (!resolved.agent) return resolved;
|
||||||
|
const updated: Agent = {
|
||||||
|
...resolved.agent,
|
||||||
|
name: declaration.displayName,
|
||||||
|
role: (declaration.role ?? "general") as Agent["role"],
|
||||||
|
title: declaration.title ?? null,
|
||||||
|
icon: declaration.icon ?? null,
|
||||||
|
capabilities: declaration.capabilities ?? null,
|
||||||
|
adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"],
|
||||||
|
adapterConfig: declaration.adapterConfig ?? {},
|
||||||
|
runtimeConfig: declaration.runtimeConfig ?? {},
|
||||||
|
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
|
||||||
|
permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) },
|
||||||
|
metadata: managedAgentMetadata(agentKey, resolved.agent.metadata),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
agents.set(updated.id, updated);
|
||||||
|
return managedResolution(agentKey, cid, updated, "reset");
|
||||||
|
},
|
||||||
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
async create(agentId, companyId, opts) {
|
async create(agentId, companyId, opts) {
|
||||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ import type {
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
PluginIssueOriginKind,
|
PluginIssueOriginKind,
|
||||||
|
IssueSurfaceVisibility,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
Routine,
|
||||||
|
RoutineRun,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -42,6 +48,18 @@ export type {
|
||||||
PluginWebhookDeclaration,
|
PluginWebhookDeclaration,
|
||||||
PluginToolDeclaration,
|
PluginToolDeclaration,
|
||||||
PluginEnvironmentDriverDeclaration,
|
PluginEnvironmentDriverDeclaration,
|
||||||
|
PluginManagedAgentDeclaration,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectDeclaration,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineDeclaration,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
Routine,
|
||||||
|
RoutineRun,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
|
PluginCompanySettings,
|
||||||
|
PluginManagedResourceKind,
|
||||||
|
PluginManagedResourceRef,
|
||||||
PluginUiSlotDeclaration,
|
PluginUiSlotDeclaration,
|
||||||
PluginUiDeclaration,
|
PluginUiDeclaration,
|
||||||
PluginLauncherActionDeclaration,
|
PluginLauncherActionDeclaration,
|
||||||
|
|
@ -92,6 +110,7 @@ export type {
|
||||||
RequestConfirmationInteraction,
|
RequestConfirmationInteraction,
|
||||||
CreateIssueThreadInteraction,
|
CreateIssueThreadInteraction,
|
||||||
PluginIssueOriginKind,
|
PluginIssueOriginKind,
|
||||||
|
IssueSurfaceVisibility,
|
||||||
Agent,
|
Agent,
|
||||||
Goal,
|
Goal,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -349,6 +368,90 @@ export interface PluginConfigClient {
|
||||||
get(): Promise<Record<string, unknown>>;
|
get(): Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderProblem {
|
||||||
|
code:
|
||||||
|
| "not_configured"
|
||||||
|
| "not_absolute"
|
||||||
|
| "missing"
|
||||||
|
| "not_directory"
|
||||||
|
| "not_readable"
|
||||||
|
| "not_writable"
|
||||||
|
| "missing_directory"
|
||||||
|
| "missing_file"
|
||||||
|
| "path_traversal"
|
||||||
|
| "symlink_escape"
|
||||||
|
| "atomic_write_failed";
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderStatus {
|
||||||
|
folderKey: string;
|
||||||
|
configured: boolean;
|
||||||
|
path: string | null;
|
||||||
|
realPath: string | null;
|
||||||
|
access: "read" | "readWrite";
|
||||||
|
readable: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
requiredDirectories: string[];
|
||||||
|
requiredFiles: string[];
|
||||||
|
missingDirectories: string[];
|
||||||
|
missingFiles: string[];
|
||||||
|
healthy: boolean;
|
||||||
|
problems: PluginLocalFolderProblem[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderConfigureInput {
|
||||||
|
companyId: string;
|
||||||
|
folderKey: string;
|
||||||
|
path: string;
|
||||||
|
access?: "read" | "readWrite";
|
||||||
|
requiredDirectories?: string[];
|
||||||
|
requiredFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderListOptions {
|
||||||
|
relativePath?: string | null;
|
||||||
|
recursive?: boolean;
|
||||||
|
maxEntries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
kind: "file" | "directory";
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderListing {
|
||||||
|
folderKey: string;
|
||||||
|
relativePath: string | null;
|
||||||
|
entries: PluginLocalFolderEntry[];
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFoldersClient {
|
||||||
|
/** Manifest-declared local folders for this plugin. */
|
||||||
|
declarations(): import("@paperclipai/shared").PluginLocalFolderDeclaration[];
|
||||||
|
/** Persist a company-scoped local folder path after validating it. */
|
||||||
|
configure(input: PluginLocalFolderConfigureInput): Promise<PluginLocalFolderStatus>;
|
||||||
|
/** Check the stored folder readiness for a company and folder key. */
|
||||||
|
status(companyId: string, folderKey: string): Promise<PluginLocalFolderStatus>;
|
||||||
|
/** List entries below a configured folder after containment checks. */
|
||||||
|
list(companyId: string, folderKey: string, options?: PluginLocalFolderListOptions): Promise<PluginLocalFolderListing>;
|
||||||
|
/** Read a UTF-8 text file below a configured folder after containment checks. */
|
||||||
|
readText(companyId: string, folderKey: string, relativePath: string): Promise<string>;
|
||||||
|
/** Write a UTF-8 text file below a configured folder using atomic rename. */
|
||||||
|
writeTextAtomic(
|
||||||
|
companyId: string,
|
||||||
|
folderKey: string,
|
||||||
|
relativePath: string,
|
||||||
|
contents: string,
|
||||||
|
): Promise<PluginLocalFolderStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `ctx.events` — subscribe to and emit Paperclip domain events.
|
* `ctx.events` — subscribe to and emit Paperclip domain events.
|
||||||
*
|
*
|
||||||
|
|
@ -697,6 +800,44 @@ export interface PluginProjectsClient {
|
||||||
* @see PLUGIN_SPEC.md §20 — Local Tooling
|
* @see PLUGIN_SPEC.md §20 — Local Tooling
|
||||||
*/
|
*/
|
||||||
getWorkspaceForIssue(issueId: string, companyId: string): Promise<PluginWorkspace | null>;
|
getWorkspaceForIssue(issueId: string, companyId: string): Promise<PluginWorkspace | null>;
|
||||||
|
|
||||||
|
/** Resolve and reconcile manifest-declared plugin-managed projects by stable key. Requires `projects.managed`. */
|
||||||
|
managed: {
|
||||||
|
get(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||||
|
reconcile(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||||
|
reset(projectKey: string, companyId: string): Promise<PluginManagedProjectResolution>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
|
||||||
|
*
|
||||||
|
* Requires `routines.managed` capability.
|
||||||
|
*/
|
||||||
|
export interface PluginRoutinesClient {
|
||||||
|
managed: {
|
||||||
|
get(routineKey: string, companyId: string): Promise<PluginManagedRoutineResolution>;
|
||||||
|
reconcile(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
): Promise<PluginManagedRoutineResolution>;
|
||||||
|
reset(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
): Promise<PluginManagedRoutineResolution>;
|
||||||
|
update(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
patch: { status?: string },
|
||||||
|
): Promise<Routine>;
|
||||||
|
run(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
): Promise<RoutineRun>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1099,8 +1240,10 @@ export interface PluginIssuesClient {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
originKind?: PluginIssueOriginKind;
|
originKind?: PluginIssueOriginKind;
|
||||||
|
originKindPrefix?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
status?: Issue["status"];
|
status?: Issue["status"];
|
||||||
|
includePluginOperations?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<Issue[]>;
|
}): Promise<Issue[]>;
|
||||||
|
|
@ -1119,6 +1262,7 @@ export interface PluginIssuesClient {
|
||||||
assigneeUserId?: string | null;
|
assigneeUserId?: string | null;
|
||||||
requestDepth?: number;
|
requestDepth?: number;
|
||||||
billingCode?: string | null;
|
billingCode?: string | null;
|
||||||
|
surfaceVisibility?: IssueSurfaceVisibility;
|
||||||
originKind?: PluginIssueOriginKind;
|
originKind?: PluginIssueOriginKind;
|
||||||
originId?: string | null;
|
originId?: string | null;
|
||||||
originRunId?: string | null;
|
originRunId?: string | null;
|
||||||
|
|
@ -1241,6 +1385,12 @@ export interface PluginAgentsClient {
|
||||||
resume(agentId: string, companyId: string): Promise<Agent>;
|
resume(agentId: string, companyId: string): Promise<Agent>;
|
||||||
/** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */
|
/** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */
|
||||||
invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>;
|
invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>;
|
||||||
|
/** Resolve and reconcile manifest-declared plugin-managed agents by stable key. Requires `agents.managed`. */
|
||||||
|
managed: {
|
||||||
|
get(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||||
|
reconcile(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||||
|
reset(agentKey: string, companyId: string): Promise<PluginManagedAgentResolution>;
|
||||||
|
};
|
||||||
/** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */
|
/** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */
|
||||||
sessions: PluginAgentSessionsClient;
|
sessions: PluginAgentSessionsClient;
|
||||||
}
|
}
|
||||||
|
|
@ -1436,6 +1586,9 @@ export interface PluginContext {
|
||||||
/** Read resolved operator configuration. */
|
/** Read resolved operator configuration. */
|
||||||
config: PluginConfigClient;
|
config: PluginConfigClient;
|
||||||
|
|
||||||
|
/** Configure and safely access trusted company-scoped local folders. */
|
||||||
|
localFolders: PluginLocalFoldersClient;
|
||||||
|
|
||||||
/** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
|
/** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
|
||||||
events: PluginEventsClient;
|
events: PluginEventsClient;
|
||||||
|
|
||||||
|
|
@ -1466,6 +1619,9 @@ export interface PluginContext {
|
||||||
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
|
||||||
projects: PluginProjectsClient;
|
projects: PluginProjectsClient;
|
||||||
|
|
||||||
|
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
|
||||||
|
routines: PluginRoutinesClient;
|
||||||
|
|
||||||
/** Read company metadata. Requires `companies.read`. */
|
/** Read company metadata. Requires `companies.read`. */
|
||||||
companies: PluginCompaniesClient;
|
companies: PluginCompaniesClient;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,36 @@ export interface TimeseriesChartProps {
|
||||||
export interface MarkdownBlockProps {
|
export interface MarkdownBlockProps {
|
||||||
/** Markdown content to render. */
|
/** Markdown content to render. */
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Optional CSS class name forwarded to the host renderer. */
|
||||||
|
className?: string;
|
||||||
|
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||||
|
enableWikiLinks?: boolean;
|
||||||
|
/** Base href used for wikilinks when no resolver is supplied. */
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for `MarkdownEditor`. */
|
||||||
|
export interface MarkdownEditorProps {
|
||||||
|
/** Markdown source controlled by the plugin. */
|
||||||
|
value: string;
|
||||||
|
/** Called whenever the markdown source changes. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Placeholder text shown when the document is empty. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Optional wrapper CSS class name. */
|
||||||
|
className?: string;
|
||||||
|
/** Optional editable content CSS class name. */
|
||||||
|
contentClassName?: string;
|
||||||
|
/** Called when the editor loses focus. */
|
||||||
|
onBlur?: () => void;
|
||||||
|
/** Render the editor with a host border treatment. */
|
||||||
|
bordered?: boolean;
|
||||||
|
/** Render the rich editor without allowing edits. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
/** Called on Cmd/Ctrl+Enter. */
|
||||||
|
onSubmit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single key-value pair for `KeyValueList`. */
|
/** A single key-value pair for `KeyValueList`. */
|
||||||
|
|
@ -217,6 +247,211 @@ export interface ErrorBoundaryProps {
|
||||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** File or directory node rendered by `FileTree`. */
|
||||||
|
export interface FileTreeNode {
|
||||||
|
/** Display name for this path segment. */
|
||||||
|
name: string;
|
||||||
|
/** Slash-separated path relative to the tree root. */
|
||||||
|
path: string;
|
||||||
|
/** Whether this node is a directory or file. */
|
||||||
|
kind: "dir" | "file";
|
||||||
|
/** Child nodes. Files should use an empty array. */
|
||||||
|
children: FileTreeNode[];
|
||||||
|
/** Optional stable action metadata for host/plugin workflows. */
|
||||||
|
action?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge status variants supported by `FileTree`. */
|
||||||
|
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||||
|
|
||||||
|
/** Serializable badge metadata keyed by file path. */
|
||||||
|
export interface FileTreeBadge {
|
||||||
|
label: string;
|
||||||
|
status: FileTreeBadgeVariant;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row tone variants supported by `FileTree`. */
|
||||||
|
export type FileTreeTone = "default" | "warning" | "error" | "muted";
|
||||||
|
|
||||||
|
/** Empty-state content shown when a tree has no nodes. */
|
||||||
|
export interface FileTreeEmptyState {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error-state content shown when a tree cannot be loaded. */
|
||||||
|
export interface FileTreeErrorState {
|
||||||
|
message: string;
|
||||||
|
retry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accepted path collection shape for expanded and checked file tree state. */
|
||||||
|
export type FileTreePathCollection = ReadonlySet<string> | readonly string[];
|
||||||
|
|
||||||
|
/** Props for `FileTree`. */
|
||||||
|
export interface FileTreeProps {
|
||||||
|
/** Tree nodes to render. */
|
||||||
|
nodes: FileTreeNode[];
|
||||||
|
/** Currently selected file path. */
|
||||||
|
selectedFile?: string | null;
|
||||||
|
/** Expanded directory paths. */
|
||||||
|
expandedPaths?: FileTreePathCollection;
|
||||||
|
/** Checked file paths. */
|
||||||
|
checkedPaths?: FileTreePathCollection;
|
||||||
|
/** Called when a directory row is toggled. */
|
||||||
|
onToggleDir?: (path: string) => void;
|
||||||
|
/** Called when a file row is selected. */
|
||||||
|
onSelectFile?: (path: string) => void;
|
||||||
|
/** Called when a checkbox is toggled. */
|
||||||
|
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||||
|
/** Badge metadata keyed by path. */
|
||||||
|
fileBadges?: Record<string, FileTreeBadge | undefined>;
|
||||||
|
/** Row tone metadata keyed by path. */
|
||||||
|
fileTones?: Record<string, FileTreeTone | undefined>;
|
||||||
|
/** Whether to render checkboxes. Defaults to false for plugin UIs. */
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
/** Allow long file and directory names to wrap. */
|
||||||
|
wrapLabels?: boolean;
|
||||||
|
/** Render a loading skeleton instead of nodes. */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Render a structured error state instead of nodes. */
|
||||||
|
error?: FileTreeErrorState | null;
|
||||||
|
/** Empty state content. */
|
||||||
|
empty?: FileTreeEmptyState;
|
||||||
|
/** Accessible label for the tree. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuesListFilters {
|
||||||
|
status?: string;
|
||||||
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
assigneeAgentId?: string;
|
||||||
|
participantAgentId?: string;
|
||||||
|
assigneeUserId?: string;
|
||||||
|
labelId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
|
originKind?: string;
|
||||||
|
originKindPrefix?: string;
|
||||||
|
originId?: string;
|
||||||
|
descendantOf?: string;
|
||||||
|
includeRoutineExecutions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuesListProps {
|
||||||
|
companyId: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
filters?: IssuesListFilters;
|
||||||
|
viewStateKey?: string;
|
||||||
|
initialSearch?: string;
|
||||||
|
createIssueLabel?: string;
|
||||||
|
searchWithinLoadedIssues?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssigneePickerSelection {
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
assigneeUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssigneePickerProps {
|
||||||
|
/** Company whose agents and users should be listed. Defaults to host context. */
|
||||||
|
companyId?: string | null;
|
||||||
|
/** Controlled value. Use `agent:<id>`, `user:<id>`, or an empty string. */
|
||||||
|
value: string;
|
||||||
|
/** Called with the encoded value plus parsed assignee IDs. */
|
||||||
|
onChange: (value: string, selection: AssigneePickerSelection) => void;
|
||||||
|
/** Button placeholder when no assignee is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Label for the empty option. */
|
||||||
|
noneLabel?: string;
|
||||||
|
/** Search input placeholder. */
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
/** Empty search result message. */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Include active board users alongside agents. Defaults to true. */
|
||||||
|
includeUsers?: boolean;
|
||||||
|
/** Include terminated agents. Defaults to false. */
|
||||||
|
includeTerminatedAgents?: boolean;
|
||||||
|
/** CSS class forwarded to the trigger button. */
|
||||||
|
className?: string;
|
||||||
|
/** Called after the user confirms a selection with Enter, Tab, or click. */
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPickerProps {
|
||||||
|
/** Company whose projects should be listed. Defaults to host context. */
|
||||||
|
companyId?: string | null;
|
||||||
|
/** Controlled project id, or an empty string for no project. */
|
||||||
|
value: string;
|
||||||
|
/** Called with the selected project id. Empty string means no project. */
|
||||||
|
onChange: (projectId: string) => void;
|
||||||
|
/** Button placeholder when no project is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Label for the empty option. */
|
||||||
|
noneLabel?: string;
|
||||||
|
/** Search input placeholder. */
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
/** Empty search result message. */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Include archived projects. Defaults to false. */
|
||||||
|
includeArchived?: boolean;
|
||||||
|
/** CSS class forwarded to the trigger button. */
|
||||||
|
className?: string;
|
||||||
|
/** Called after the user confirms a selection with Enter, Tab, or click. */
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRoutinesListAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRoutinesListProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRoutineMissingRef {
|
||||||
|
resourceKind: string;
|
||||||
|
resourceKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRoutinesListItem {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
routineId?: string | null;
|
||||||
|
href?: string | null;
|
||||||
|
resourceKey?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
cronExpression?: string | null;
|
||||||
|
lastRunAt?: Date | string | null;
|
||||||
|
lastRunStatus?: string | null;
|
||||||
|
managedByPluginDisplayName?: string | null;
|
||||||
|
missingRefs?: ManagedRoutineMissingRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedRoutinesListProps {
|
||||||
|
routines: ManagedRoutinesListItem[];
|
||||||
|
agents?: ManagedRoutinesListAgent[];
|
||||||
|
projects?: ManagedRoutinesListProject[];
|
||||||
|
pluginDisplayName?: string | null;
|
||||||
|
emptyMessage?: string;
|
||||||
|
runningRoutineKey?: string | null;
|
||||||
|
statusMutationRoutineKey?: string | null;
|
||||||
|
reconcilingRoutineKey?: string | null;
|
||||||
|
resettingRoutineKey?: string | null;
|
||||||
|
onRunNow?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
|
||||||
|
onReconcile?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
onReset?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Component declarations (provided by host at runtime)
|
// Component declarations (provided by host at runtime)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("Times
|
||||||
*/
|
*/
|
||||||
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Paperclip's shared Markdown editor.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||||
|
*/
|
||||||
|
export const MarkdownEditor = createSdkUiComponent<MarkdownEditorProps>("MarkdownEditor");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a definition-list of label/value pairs.
|
* Renders a definition-list of label/value pairs.
|
||||||
*
|
*
|
||||||
|
|
@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
*/
|
*/
|
||||||
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the host file tree component with a stable plugin-safe prop surface.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui";
|
||||||
|
*
|
||||||
|
* const nodes: FileTreeNode[] = [
|
||||||
|
* { name: "README.md", path: "README.md", kind: "file", children: [] },
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* <FileTree nodes={nodes} onSelectFile={(path) => console.log(path)} />;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const FileTree = createSdkUiComponent<FileTreeProps>("FileTree");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Paperclip's native issue list component for company-scoped plugin
|
||||||
|
* pages that need a standard board issue view.
|
||||||
|
*/
|
||||||
|
export const IssuesList = createSdkUiComponent<IssuesListProps>("IssuesList");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the same host assignee picker used by the new issue pane.
|
||||||
|
*/
|
||||||
|
export const AssigneePicker = createSdkUiComponent<AssigneePickerProps>("AssigneePicker");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the same host project picker used by the new issue pane.
|
||||||
|
*/
|
||||||
|
export const ProjectPicker = createSdkUiComponent<ProjectPickerProps>("ProjectPicker");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Paperclip's native managed routines list for plugin settings pages.
|
||||||
|
*/
|
||||||
|
export const ManagedRoutinesList = createSdkUiComponent<ManagedRoutinesListProps>("ManagedRoutinesList");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type {
|
import type {
|
||||||
PluginDataResult,
|
PluginDataResult,
|
||||||
PluginActionFn,
|
PluginActionFn,
|
||||||
|
HostLocation,
|
||||||
|
HostNavigation,
|
||||||
PluginHostContext,
|
PluginHostContext,
|
||||||
PluginStreamResult,
|
PluginStreamResult,
|
||||||
PluginToastFn,
|
PluginToastFn,
|
||||||
|
|
@ -115,6 +117,57 @@ export function useHostContext(): PluginHostContext {
|
||||||
return impl();
|
return impl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useHostNavigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate within the Paperclip host without forcing a full document reload.
|
||||||
|
*
|
||||||
|
* Use `linkProps()` for links so browser-native behavior still works:
|
||||||
|
* modifier-click, middle-click, copy-link, and open-in-new-tab all use the
|
||||||
|
* returned real `href`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function WikiSidebarLink() {
|
||||||
|
* const hostNavigation = useHostNavigation();
|
||||||
|
* return <a {...hostNavigation.linkProps("/wiki")}>Wiki</a>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useHostNavigation(): HostNavigation {
|
||||||
|
const impl = getSdkUiRuntimeValue<() => HostNavigation>("useHostNavigation");
|
||||||
|
return impl();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useHostLocation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe the current host router location.
|
||||||
|
*
|
||||||
|
* Returns a snapshot of the active `pathname`, `search`, and `hash`. The
|
||||||
|
* component re-renders when any of these change (e.g. after the host router
|
||||||
|
* pushes a new entry, or after the browser back/forward gestures). Use this
|
||||||
|
* for URL-driven plugin UI such as a takeover sidebar with section-aware
|
||||||
|
* active state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function WikiSection() {
|
||||||
|
* const { pathname } = useHostLocation();
|
||||||
|
* const section = pathname.split("/").filter(Boolean).at(-1) ?? "wiki";
|
||||||
|
* return <div>Active section: {section}</div>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useHostLocation(): HostLocation {
|
||||||
|
const impl = getSdkUiRuntimeValue<() => HostLocation>("useHostLocation");
|
||||||
|
return impl();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// usePluginStream
|
// usePluginStream
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -43,20 +43,89 @@
|
||||||
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||||
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||||
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||||
|
* - `useHostNavigation()` — navigate Paperclip-internal links through the host router
|
||||||
|
* - `useHostLocation()` — observe the current host pathname/search/hash for URL-driven UI
|
||||||
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
useHostNavigation,
|
||||||
|
useHostLocation,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
usePluginToast,
|
usePluginToast,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
MetricCard,
|
||||||
|
StatusBadge,
|
||||||
|
DataTable,
|
||||||
|
TimeseriesChart,
|
||||||
|
MarkdownBlock,
|
||||||
|
MarkdownEditor,
|
||||||
|
KeyValueList,
|
||||||
|
ActionBar,
|
||||||
|
LogView,
|
||||||
|
JsonTree,
|
||||||
|
Spinner,
|
||||||
|
ErrorBoundary,
|
||||||
|
FileTree,
|
||||||
|
IssuesList,
|
||||||
|
AssigneePicker,
|
||||||
|
ProjectPicker,
|
||||||
|
ManagedRoutinesList,
|
||||||
|
} from "./components.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MetricTrend,
|
||||||
|
MetricCardProps,
|
||||||
|
StatusBadgeVariant,
|
||||||
|
StatusBadgeProps,
|
||||||
|
DataTableColumn,
|
||||||
|
DataTableProps,
|
||||||
|
TimeseriesDataPoint,
|
||||||
|
TimeseriesChartProps,
|
||||||
|
MarkdownBlockProps,
|
||||||
|
MarkdownEditorProps,
|
||||||
|
KeyValuePair,
|
||||||
|
KeyValueListProps,
|
||||||
|
ActionBarItem,
|
||||||
|
ActionBarProps,
|
||||||
|
LogViewEntry,
|
||||||
|
LogViewProps,
|
||||||
|
JsonTreeProps,
|
||||||
|
SpinnerProps,
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
FileTreeNode,
|
||||||
|
FileTreeBadgeVariant,
|
||||||
|
FileTreeBadge,
|
||||||
|
FileTreeTone,
|
||||||
|
FileTreeEmptyState,
|
||||||
|
FileTreeErrorState,
|
||||||
|
FileTreePathCollection,
|
||||||
|
FileTreeProps,
|
||||||
|
IssuesListFilters,
|
||||||
|
IssuesListProps,
|
||||||
|
AssigneePickerSelection,
|
||||||
|
AssigneePickerProps,
|
||||||
|
ProjectPickerProps,
|
||||||
|
ManagedRoutineMissingRef,
|
||||||
|
ManagedRoutinesListAgent,
|
||||||
|
ManagedRoutinesListItem,
|
||||||
|
ManagedRoutinesListProject,
|
||||||
|
ManagedRoutinesListProps,
|
||||||
|
} from "./components.js";
|
||||||
|
|
||||||
// Bridge error and host context types
|
// Bridge error and host context types
|
||||||
export type {
|
export type {
|
||||||
PluginBridgeError,
|
PluginBridgeError,
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
|
HostNavigation,
|
||||||
|
HostNavigationOptions,
|
||||||
|
HostNavigationLinkOptions,
|
||||||
|
HostNavigationLinkProps,
|
||||||
|
HostLocation,
|
||||||
PluginHostContext,
|
PluginHostContext,
|
||||||
PluginModalBoundsRequest,
|
PluginModalBoundsRequest,
|
||||||
PluginRenderCloseEvent,
|
PluginRenderCloseEvent,
|
||||||
|
|
@ -80,6 +149,7 @@ export type {
|
||||||
PluginWidgetProps,
|
PluginWidgetProps,
|
||||||
PluginDetailTabProps,
|
PluginDetailTabProps,
|
||||||
PluginSidebarProps,
|
PluginSidebarProps,
|
||||||
|
PluginRouteSidebarProps,
|
||||||
PluginProjectSidebarItemProps,
|
PluginProjectSidebarItemProps,
|
||||||
PluginCommentAnnotationProps,
|
PluginCommentAnnotationProps,
|
||||||
PluginCommentContextMenuItemProps,
|
PluginCommentContextMenuItemProps,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@
|
||||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnchorHTMLAttributes,
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
PluginLauncherBounds,
|
PluginLauncherBounds,
|
||||||
|
|
@ -131,6 +135,83 @@ export interface PluginRenderEnvironmentContext
|
||||||
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Host navigation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for host-managed Paperclip navigation from plugin UI.
|
||||||
|
*/
|
||||||
|
export interface HostNavigationOptions {
|
||||||
|
/** Replace the current history entry instead of pushing a new one. */
|
||||||
|
replace?: boolean;
|
||||||
|
/** Optional state forwarded to the host router. */
|
||||||
|
state?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for `useHostNavigation().linkProps()`.
|
||||||
|
*/
|
||||||
|
export interface HostNavigationLinkOptions extends HostNavigationOptions {
|
||||||
|
/** Standard anchor target. Non-`_self` targets are not intercepted. */
|
||||||
|
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||||
|
/** Standard anchor rel attribute. */
|
||||||
|
rel?: AnchorHTMLAttributes<HTMLAnchorElement>["rel"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor props returned by `useHostNavigation().linkProps()`.
|
||||||
|
*
|
||||||
|
* The `href` is always real so browser affordances such as copy-link,
|
||||||
|
* modifier-click, middle-click, and open-in-new-tab continue to work.
|
||||||
|
*/
|
||||||
|
export interface HostNavigationLinkProps
|
||||||
|
extends Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "target" | "rel"> {
|
||||||
|
onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the host router location, exposed to plugin UI through
|
||||||
|
* `useHostLocation()`. Mirrors the relevant subset of `Location` from
|
||||||
|
* `react-router-dom` so plugins can react to URL changes without importing
|
||||||
|
* router internals.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||||
|
*/
|
||||||
|
export interface HostLocation {
|
||||||
|
/** Current pathname, e.g. `/PAP/wiki`. */
|
||||||
|
pathname: string;
|
||||||
|
/** Current search string, e.g. `?tab=config` (includes the leading `?`). */
|
||||||
|
search: string;
|
||||||
|
/** Current hash, e.g. `#document-plan` (includes the leading `#`). */
|
||||||
|
hash: string;
|
||||||
|
/** Optional state forwarded by the host router for same-tab SPA navigation. */
|
||||||
|
state?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-managed navigation helpers for plugin UI.
|
||||||
|
*/
|
||||||
|
export interface HostNavigation {
|
||||||
|
/**
|
||||||
|
* Resolve a Paperclip-internal path using the active company prefix.
|
||||||
|
*
|
||||||
|
* For example, in company `PAP`, `resolveHref("/wiki")` returns
|
||||||
|
* `"/PAP/wiki"`, while `resolveHref("/PAP/wiki")` stays unchanged.
|
||||||
|
*/
|
||||||
|
resolveHref(to: string): string;
|
||||||
|
/** Navigate through the host router without reloading the document. */
|
||||||
|
navigate(to: string, options?: HostNavigationOptions): void;
|
||||||
|
/**
|
||||||
|
* Build anchor props for host-managed links.
|
||||||
|
*
|
||||||
|
* Plain left-clicks are routed through the host SPA router. Browser-native
|
||||||
|
* link gestures are left alone because the returned props include a real
|
||||||
|
* `href`.
|
||||||
|
*/
|
||||||
|
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slot component prop interfaces
|
// Slot component prop interfaces
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -188,6 +269,19 @@ export interface PluginSidebarProps {
|
||||||
context: PluginHostContext;
|
context: PluginHostContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a plugin route sidebar component.
|
||||||
|
*
|
||||||
|
* A route sidebar replaces the normal company sidebar while the user is on a
|
||||||
|
* matching plugin page route declared with the same `routePath`.
|
||||||
|
*
|
||||||
|
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||||
|
*/
|
||||||
|
export interface PluginRouteSidebarProps {
|
||||||
|
/** The current host context. */
|
||||||
|
context: PluginHostContext;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props passed to a plugin project sidebar item component.
|
* Props passed to a plugin project sidebar item component.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
localFolders: {
|
||||||
|
declarations() {
|
||||||
|
if (!manifest) throw new Error("Plugin context accessed before initialization");
|
||||||
|
return manifest.localFolders ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async configure(input) {
|
||||||
|
return callHost("localFolders.configure", {
|
||||||
|
companyId: input.companyId,
|
||||||
|
folderKey: input.folderKey,
|
||||||
|
path: input.path,
|
||||||
|
access: input.access,
|
||||||
|
requiredDirectories: input.requiredDirectories,
|
||||||
|
requiredFiles: input.requiredFiles,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(companyId: string, folderKey: string) {
|
||||||
|
return callHost("localFolders.status", { companyId, folderKey });
|
||||||
|
},
|
||||||
|
|
||||||
|
async list(companyId: string, folderKey: string, options = {}) {
|
||||||
|
return callHost("localFolders.list", {
|
||||||
|
companyId,
|
||||||
|
folderKey,
|
||||||
|
relativePath: options.relativePath,
|
||||||
|
recursive: options.recursive,
|
||||||
|
maxEntries: options.maxEntries,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async readText(companyId: string, folderKey: string, relativePath: string) {
|
||||||
|
return callHost("localFolders.readText", { companyId, folderKey, relativePath });
|
||||||
|
},
|
||||||
|
|
||||||
|
async writeTextAtomic(companyId: string, folderKey: string, relativePath: string, contents: string) {
|
||||||
|
return callHost("localFolders.writeTextAtomic", {
|
||||||
|
companyId,
|
||||||
|
folderKey,
|
||||||
|
relativePath,
|
||||||
|
contents,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
events: {
|
events: {
|
||||||
on(
|
on(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -580,6 +625,50 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
async getWorkspaceForIssue(issueId: string, companyId: string) {
|
async getWorkspaceForIssue(issueId: string, companyId: string) {
|
||||||
return callHost("projects.getWorkspaceForIssue", { issueId, companyId });
|
return callHost("projects.getWorkspaceForIssue", { issueId, companyId });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
managed: {
|
||||||
|
async get(projectKey: string, companyId: string) {
|
||||||
|
return callHost("projects.managed.get", { projectKey, companyId });
|
||||||
|
},
|
||||||
|
async reconcile(projectKey: string, companyId: string) {
|
||||||
|
return callHost("projects.managed.reconcile", { projectKey, companyId });
|
||||||
|
},
|
||||||
|
async reset(projectKey: string, companyId: string) {
|
||||||
|
return callHost("projects.managed.reset", { projectKey, companyId });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
routines: {
|
||||||
|
managed: {
|
||||||
|
async get(routineKey: string, companyId: string) {
|
||||||
|
return callHost("routines.managed.get", { routineKey, companyId });
|
||||||
|
},
|
||||||
|
async reconcile(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
) {
|
||||||
|
return callHost("routines.managed.reconcile", { routineKey, companyId, ...overrides });
|
||||||
|
},
|
||||||
|
async reset(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
) {
|
||||||
|
return callHost("routines.managed.reset", { routineKey, companyId, ...overrides });
|
||||||
|
},
|
||||||
|
async update(routineKey: string, companyId: string, patch: { status?: string }) {
|
||||||
|
return callHost("routines.managed.update", { routineKey, companyId, ...patch });
|
||||||
|
},
|
||||||
|
async run(
|
||||||
|
routineKey: string,
|
||||||
|
companyId: string,
|
||||||
|
overrides?: { assigneeAgentId?: string | null; projectId?: string | null },
|
||||||
|
) {
|
||||||
|
return callHost("routines.managed.run", { routineKey, companyId, ...overrides });
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
companies: {
|
companies: {
|
||||||
|
|
@ -602,8 +691,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
assigneeAgentId: input.assigneeAgentId,
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
originKind: input.originKind,
|
originKind: input.originKind,
|
||||||
|
originKindPrefix: input.originKindPrefix,
|
||||||
originId: input.originId,
|
originId: input.originId,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
|
includePluginOperations: input.includePluginOperations,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
offset: input.offset,
|
offset: input.offset,
|
||||||
});
|
});
|
||||||
|
|
@ -628,6 +719,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
assigneeUserId: input.assigneeUserId,
|
assigneeUserId: input.assigneeUserId,
|
||||||
requestDepth: input.requestDepth,
|
requestDepth: input.requestDepth,
|
||||||
billingCode: input.billingCode,
|
billingCode: input.billingCode,
|
||||||
|
surfaceVisibility: input.surfaceVisibility,
|
||||||
originKind: input.originKind,
|
originKind: input.originKind,
|
||||||
originId: input.originId,
|
originId: input.originId,
|
||||||
originRunId: input.originRunId,
|
originRunId: input.originRunId,
|
||||||
|
|
@ -863,6 +955,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
||||||
return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason });
|
return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
managed: {
|
||||||
|
async get(agentKey: string, companyId: string) {
|
||||||
|
return callHost("agents.managed.get", { agentKey, companyId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reconcile(agentKey: string, companyId: string) {
|
||||||
|
return callHost("agents.managed.reconcile", { agentKey, companyId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reset(agentKey: string, companyId: string) {
|
||||||
|
return callHost("agents.managed.reset", { agentKey, companyId });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
sessions: {
|
sessions: {
|
||||||
async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) {
|
async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) {
|
||||||
return callHost("agents.sessions.create", {
|
return callHost("agents.sessions.create", {
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [
|
||||||
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
|
||||||
export type PluginIssueOriginKind = `plugin:${string}`;
|
export type PluginIssueOriginKind = `plugin:${string}`;
|
||||||
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
|
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 const ISSUE_RELATION_TYPES = ["blocks"] as const;
|
||||||
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
|
||||||
|
|
@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [
|
||||||
"issue.comments.create",
|
"issue.comments.create",
|
||||||
"issue.interactions.create",
|
"issue.interactions.create",
|
||||||
"issue.documents.write",
|
"issue.documents.write",
|
||||||
|
"projects.managed",
|
||||||
|
"routines.managed",
|
||||||
"agents.pause",
|
"agents.pause",
|
||||||
"agents.resume",
|
"agents.resume",
|
||||||
"agents.invoke",
|
"agents.invoke",
|
||||||
|
"agents.managed",
|
||||||
"agent.sessions.create",
|
"agent.sessions.create",
|
||||||
"agent.sessions.list",
|
"agent.sessions.list",
|
||||||
"agent.sessions.send",
|
"agent.sessions.send",
|
||||||
|
|
@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [
|
||||||
"http.outbound",
|
"http.outbound",
|
||||||
"secrets.read-ref",
|
"secrets.read-ref",
|
||||||
"environment.drivers.register",
|
"environment.drivers.register",
|
||||||
|
"local.folders",
|
||||||
// Agent Tools
|
// Agent Tools
|
||||||
"agent.tools.register",
|
"agent.tools.register",
|
||||||
// UI
|
// UI
|
||||||
|
|
@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
|
||||||
"taskDetailView",
|
"taskDetailView",
|
||||||
"dashboardWidget",
|
"dashboardWidget",
|
||||||
"sidebar",
|
"sidebar",
|
||||||
|
"routeSidebar",
|
||||||
"sidebarPanel",
|
"sidebarPanel",
|
||||||
"projectSidebarItem",
|
"projectSidebarItem",
|
||||||
"globalToolbarButton",
|
"globalToolbarButton",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ export {
|
||||||
ISSUE_THREAD_INTERACTION_STATUSES,
|
ISSUE_THREAD_INTERACTION_STATUSES,
|
||||||
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
|
||||||
ISSUE_ORIGIN_KINDS,
|
ISSUE_ORIGIN_KINDS,
|
||||||
|
ISSUE_SURFACE_VISIBILITIES,
|
||||||
|
pluginOperationIssueOriginKind,
|
||||||
|
isPluginOperationIssueOriginKind,
|
||||||
ISSUE_RELATION_TYPES,
|
ISSUE_RELATION_TYPES,
|
||||||
ISSUE_TREE_CONTROL_MODES,
|
ISSUE_TREE_CONTROL_MODES,
|
||||||
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
|
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
|
||||||
|
|
@ -133,6 +136,7 @@ export {
|
||||||
type BuiltInIssueOriginKind,
|
type BuiltInIssueOriginKind,
|
||||||
type PluginIssueOriginKind,
|
type PluginIssueOriginKind,
|
||||||
type IssueOriginKind,
|
type IssueOriginKind,
|
||||||
|
type IssueSurfaceVisibility,
|
||||||
type IssueRelationType,
|
type IssueRelationType,
|
||||||
type IssueTreeControlMode,
|
type IssueTreeControlMode,
|
||||||
type IssueTreeHoldReleasePolicyStrategy,
|
type IssueTreeHoldReleasePolicyStrategy,
|
||||||
|
|
@ -303,6 +307,7 @@ export type {
|
||||||
ProjectCodebase,
|
ProjectCodebase,
|
||||||
ProjectCodebaseOrigin,
|
ProjectCodebaseOrigin,
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
|
ProjectManagedByPlugin,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
ExecutionWorkspaceSummary,
|
ExecutionWorkspaceSummary,
|
||||||
|
|
@ -493,6 +498,7 @@ export type {
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
SecretProviderDescriptor,
|
SecretProviderDescriptor,
|
||||||
Routine,
|
Routine,
|
||||||
|
RoutineManagedByPlugin,
|
||||||
RoutineVariable,
|
RoutineVariable,
|
||||||
RoutineVariableDefaultValue,
|
RoutineVariableDefaultValue,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
|
|
@ -507,6 +513,15 @@ export type {
|
||||||
PluginWebhookDeclaration,
|
PluginWebhookDeclaration,
|
||||||
PluginToolDeclaration,
|
PluginToolDeclaration,
|
||||||
PluginEnvironmentDriverDeclaration,
|
PluginEnvironmentDriverDeclaration,
|
||||||
|
PluginManagedAgentDeclaration,
|
||||||
|
PluginManagedProjectDeclaration,
|
||||||
|
PluginManagedRoutineDeclaration,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
PluginManagedResourceKind,
|
||||||
|
PluginManagedResourceRef,
|
||||||
PluginUiSlotDeclaration,
|
PluginUiSlotDeclaration,
|
||||||
PluginLauncherActionDeclaration,
|
PluginLauncherActionDeclaration,
|
||||||
PluginLauncherRenderDeclaration,
|
PluginLauncherRenderDeclaration,
|
||||||
|
|
@ -523,6 +538,7 @@ export type {
|
||||||
PluginMigrationRecord,
|
PluginMigrationRecord,
|
||||||
PluginStateRecord,
|
PluginStateRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
|
PluginCompanySettings,
|
||||||
PluginEntityRecord,
|
PluginEntityRecord,
|
||||||
PluginEntityQuery,
|
PluginEntityQuery,
|
||||||
PluginJobRecord,
|
PluginJobRecord,
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export type {
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
export type { AssetImage } from "./asset.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 {
|
export type {
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
ExecutionWorkspaceSummary,
|
ExecutionWorkspaceSummary,
|
||||||
|
|
@ -221,6 +221,7 @@ export type {
|
||||||
} from "./secrets.js";
|
} from "./secrets.js";
|
||||||
export type {
|
export type {
|
||||||
Routine,
|
Routine,
|
||||||
|
RoutineManagedByPlugin,
|
||||||
RoutineVariable,
|
RoutineVariable,
|
||||||
RoutineVariableDefaultValue,
|
RoutineVariableDefaultValue,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
|
|
@ -315,6 +316,15 @@ export type {
|
||||||
PluginWebhookDeclaration,
|
PluginWebhookDeclaration,
|
||||||
PluginToolDeclaration,
|
PluginToolDeclaration,
|
||||||
PluginEnvironmentDriverDeclaration,
|
PluginEnvironmentDriverDeclaration,
|
||||||
|
PluginManagedAgentDeclaration,
|
||||||
|
PluginManagedProjectDeclaration,
|
||||||
|
PluginManagedRoutineDeclaration,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
|
PluginManagedAgentResolution,
|
||||||
|
PluginManagedProjectResolution,
|
||||||
|
PluginManagedRoutineResolution,
|
||||||
|
PluginManagedResourceKind,
|
||||||
|
PluginManagedResourceRef,
|
||||||
PluginUiSlotDeclaration,
|
PluginUiSlotDeclaration,
|
||||||
PluginLauncherActionDeclaration,
|
PluginLauncherActionDeclaration,
|
||||||
PluginLauncherRenderDeclaration,
|
PluginLauncherRenderDeclaration,
|
||||||
|
|
@ -331,6 +341,7 @@ export type {
|
||||||
PluginMigrationRecord,
|
PluginMigrationRecord,
|
||||||
PluginStateRecord,
|
PluginStateRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
|
PluginCompanySettings,
|
||||||
PluginEntityRecord,
|
PluginEntityRecord,
|
||||||
PluginEntityQuery,
|
PluginEntityQuery,
|
||||||
PluginJobRecord,
|
PluginJobRecord,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,19 @@ import type {
|
||||||
PluginDatabaseMigrationStatus,
|
PluginDatabaseMigrationStatus,
|
||||||
PluginDatabaseNamespaceMode,
|
PluginDatabaseNamespaceMode,
|
||||||
PluginDatabaseNamespaceStatus,
|
PluginDatabaseNamespaceStatus,
|
||||||
|
AgentAdapterType,
|
||||||
|
AgentRole,
|
||||||
|
AgentStatus,
|
||||||
|
IssuePriority,
|
||||||
|
ProjectStatus,
|
||||||
|
RoutineCatchUpPolicy,
|
||||||
|
RoutineConcurrencyPolicy,
|
||||||
|
RoutineStatus,
|
||||||
|
IssueSurfaceVisibility,
|
||||||
} from "../constants.js";
|
} 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
|
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
|
||||||
|
|
@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration {
|
||||||
configSchema: JsonSchema;
|
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.
|
* Declares a UI extension slot the plugin fills with a React component.
|
||||||
*
|
*
|
||||||
|
|
@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration {
|
||||||
*/
|
*/
|
||||||
entityTypes?: PluginUiSlotEntityType[];
|
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`.
|
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||||
*/
|
*/
|
||||||
routePath?: string;
|
routePath?: string;
|
||||||
|
|
@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 {
|
||||||
apiRoutes?: PluginApiRouteDeclaration[];
|
apiRoutes?: PluginApiRouteDeclaration[];
|
||||||
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
|
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
|
||||||
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
|
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.
|
* Legacy top-level launcher declarations.
|
||||||
* Prefer `ui.launchers` for new manifests.
|
* Prefer `ui.launchers` for new manifests.
|
||||||
|
|
@ -455,6 +631,22 @@ export interface PluginConfig {
|
||||||
updatedAt: Date;
|
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`.
|
* Query filter for `ctx.entities.list`.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,18 @@ export interface ProjectCodebase {
|
||||||
origin: ProjectCodebaseOrigin;
|
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 {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|
@ -73,6 +85,7 @@ export interface Project {
|
||||||
codebase: ProjectCodebase;
|
codebase: ProjectCodebase;
|
||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
|
managedByPlugin?: ProjectManagedByPlugin | null;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,19 @@ export interface Routine {
|
||||||
lastEnqueuedAt: Date | null;
|
lastEnqueuedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
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_AUTH_MODES,
|
||||||
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
|
||||||
PLUGIN_API_ROUTE_METHODS,
|
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";
|
} from "../constants.js";
|
||||||
|
import { routineVariableSchema } from "./routine.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
// 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 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
|
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||||
|
|
@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
|
||||||
path: ["entityTypes"],
|
path: ["entityTypes"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (value.routePath && value.type !== "page") {
|
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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"],
|
path: ["routePath"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
|
||||||
database: pluginDatabaseDeclarationSchema.optional(),
|
database: pluginDatabaseDeclarationSchema.optional(),
|
||||||
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
|
||||||
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).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(),
|
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||||
ui: z.object({
|
ui: z.object({
|
||||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
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)
|
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
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)
|
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||||
if (manifest.ui) {
|
if (manifest.ui) {
|
||||||
if (manifest.ui.slots) {
|
if (manifest.ui.slots) {
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||||
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
|
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 () => {
|
it("hides archived inbox issues until new external activity arrives", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const userId = "user-1";
|
const userId = "user-1";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
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 {
|
import {
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
|
@ -25,9 +25,11 @@ import {
|
||||||
validatePluginRuntimeExecute,
|
validatePluginRuntimeExecute,
|
||||||
validatePluginRuntimeQuery,
|
validatePluginRuntimeQuery,
|
||||||
} from "../services/plugin-database.js";
|
} from "../services/plugin-database.js";
|
||||||
|
import { pluginLoader } from "../services/plugin-loader.js";
|
||||||
|
|
||||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
const multiMigrationPluginKey = "paperclip.dbfixture";
|
||||||
|
|
||||||
if (!embeddedPostgresSupport.supported) {
|
if (!embeddedPostgresSupport.supported) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
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);
|
const namespace = derivePluginDatabaseNamespace(pluginKey);
|
||||||
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||||
return packageRoot;
|
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) {
|
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
|
||||||
const pluginId = randomUUID();
|
const pluginId = randomUUID();
|
||||||
await db.insert(plugins).values({
|
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 () => {
|
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
|
||||||
const pluginManifest = manifest();
|
const pluginManifest = manifest();
|
||||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
||||||
|
|
@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
|
||||||
expect(migration?.status).toBe("failed");
|
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 () => {
|
it("rejects checksum changes for already applied migrations", async () => {
|
||||||
const pluginManifest = manifest();
|
const pluginManifest = manifest();
|
||||||
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
|
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 { 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 { and, eq } from "drizzle-orm";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,6 +14,9 @@ import {
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issueRelations,
|
issueRelations,
|
||||||
issues,
|
issues,
|
||||||
|
pluginManagedResources,
|
||||||
|
plugins,
|
||||||
|
projects,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
|
|
@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
|
||||||
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||||
let db!: ReturnType<typeof createDb>;
|
let db!: ReturnType<typeof createDb>;
|
||||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
|
||||||
|
|
@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
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(activityLog);
|
||||||
await db.delete(costEvents);
|
await db.delete(costEvents);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(agentWakeupRequests);
|
await db.delete(agentWakeupRequests);
|
||||||
await db.delete(issueRelations);
|
await db.delete(issueRelations);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(pluginManagedResources);
|
||||||
|
await db.delete(projects);
|
||||||
|
await db.delete(plugins);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
await db.delete(companies);
|
await db.delete(companies);
|
||||||
});
|
});
|
||||||
|
|
@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||||
return { companyId, agentId };
|
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 () => {
|
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
|
||||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
const blockerIssueId = randomUUID();
|
const blockerIssueId = randomUUID();
|
||||||
|
|
@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
|
||||||
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
|
).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 () => {
|
it("asserts checkout ownership for run-scoped plugin actions", async () => {
|
||||||
const { companyId, agentId } = await seedCompanyAndAgent();
|
const { companyId, agentId } = await seedCompanyAndAgent();
|
||||||
const issueId = randomUUID();
|
const issueId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
getByKey: vi.fn(),
|
getByKey: vi.fn(),
|
||||||
upsertConfig: vi.fn(),
|
upsertConfig: vi.fn(),
|
||||||
|
getCompanySettings: vi.fn(),
|
||||||
|
upsertCompanySettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockLifecycle = vi.hoisted(() => ({
|
const mockLifecycle = vi.hoisted(() => ({
|
||||||
|
|
@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => {
|
||||||
}, 20_000);
|
}, 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", () => {
|
describe.sequential("plugin tool and bridge authz", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
|
||||||
const pluginId = "11111111-1111-4111-8111-111111111111";
|
const pluginId = "11111111-1111-4111-8111-111111111111";
|
||||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
const agentId = "33333333-3333-4333-8333-333333333333";
|
const agentId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const peerAgentId = "33333333-3333-4333-8333-333333333334";
|
||||||
const runId = "44444444-4444-4444-8444-444444444444";
|
const runId = "44444444-4444-4444-8444-444444444444";
|
||||||
const issueId = "55555555-5555-4555-8555-555555555555";
|
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 () => {
|
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
|
||||||
const apiRoutes = manifest([
|
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");
|
).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 () => {
|
it("enforces checkout and wakeup capabilities in the test harness", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const agentId = randomUUID();
|
const agentId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,8 @@ export async function createApp(
|
||||||
instanceInfo: {
|
instanceInfo: {
|
||||||
instanceId: opts.instanceId ?? "default",
|
instanceId: opts.instanceId ?? "default",
|
||||||
hostVersion: opts.hostVersion ?? "0.0.0",
|
hostVersion: opts.hostVersion ?? "0.0.0",
|
||||||
|
deploymentMode: opts.deploymentMode,
|
||||||
|
deploymentExposure: opts.deploymentExposure,
|
||||||
},
|
},
|
||||||
buildHostHandlers: (pluginId, manifest) => {
|
buildHostHandlers: (pluginId, manifest) => {
|
||||||
const notifyWorker = (method: string, params: unknown) => {
|
const notifyWorker = (method: string, params: unknown) => {
|
||||||
|
|
@ -261,6 +263,7 @@ export async function createApp(
|
||||||
};
|
};
|
||||||
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
|
||||||
pluginWorkerManager: workerManager,
|
pluginWorkerManager: workerManager,
|
||||||
|
manifest,
|
||||||
});
|
});
|
||||||
hostServicesDisposers.set(pluginId, () => services.dispose());
|
hostServicesDisposers.set(pluginId, () => services.dispose());
|
||||||
return createHostClientHandlers({
|
return createHostClientHandlers({
|
||||||
|
|
|
||||||
|
|
@ -1020,11 +1020,14 @@ export function issueRoutes(
|
||||||
descendantOf: req.query.descendantOf as string | undefined,
|
descendantOf: req.query.descendantOf as string | undefined,
|
||||||
labelId: req.query.labelId as string | undefined,
|
labelId: req.query.labelId as string | undefined,
|
||||||
originKind: req.query.originKind as string | undefined,
|
originKind: req.query.originKind as string | undefined,
|
||||||
|
originKindPrefix: req.query.originKindPrefix as string | undefined,
|
||||||
originId: req.query.originId as string | undefined,
|
originId: req.query.originId as string | undefined,
|
||||||
includeRoutineExecutions:
|
includeRoutineExecutions:
|
||||||
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
||||||
excludeRoutineExecutions:
|
excludeRoutineExecutions:
|
||||||
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
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",
|
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
limit,
|
limit,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,13 @@ import {
|
||||||
getActorInfo,
|
getActorInfo,
|
||||||
} from "./authz.js";
|
} from "./authz.js";
|
||||||
import { validateInstanceConfig } from "../services/plugin-config-validator.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";
|
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
/** UI slot declaration extracted from plugin manifest */
|
/** 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
|
// Plugin health dashboard — aggregated diagnostics for the settings page
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Buffer } from "node:buffer";
|
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 type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
|
|
@ -127,9 +127,11 @@ export interface IssueFilters {
|
||||||
descendantOf?: string;
|
descendantOf?: string;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
|
originKindPrefix?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
excludeRoutineExecutions?: boolean;
|
excludeRoutineExecutions?: boolean;
|
||||||
|
includePluginOperations?: boolean;
|
||||||
includeBlockedBy?: boolean;
|
includeBlockedBy?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
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. */
|
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
|
||||||
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
|
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
|
||||||
amp: "&",
|
amp: "&",
|
||||||
|
|
@ -2201,7 +2216,11 @@ export function issueService(db: Db) {
|
||||||
}
|
}
|
||||||
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
|
||||||
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
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 (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||||
|
if (!shouldIncludePluginOperationIssues(filters)) {
|
||||||
|
conditions.push(nonPluginOperationIssueCondition());
|
||||||
|
}
|
||||||
if (filters?.labelId) {
|
if (filters?.labelId) {
|
||||||
const labeledIssueIds = await db
|
const labeledIssueIds = await db
|
||||||
.select({ issueId: issueLabels.issueId })
|
.select({ issueId: issueLabels.issueId })
|
||||||
|
|
@ -2333,6 +2352,7 @@ export function issueService(db: Db) {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(issues.companyId, companyId),
|
eq(issues.companyId, companyId),
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
|
nonPluginOperationIssueCondition(),
|
||||||
unreadForUserCondition(companyId, userId),
|
unreadForUserCondition(companyId, userId),
|
||||||
];
|
];
|
||||||
if (status) {
|
if (status) {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||||
"companies.get": ["companies.read"],
|
"companies.get": ["companies.read"],
|
||||||
"projects.list": ["projects.read"],
|
"projects.list": ["projects.read"],
|
||||||
"projects.get": ["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.list": ["project.workspaces.read"],
|
||||||
"project.workspaces.get": ["project.workspaces.read"],
|
"project.workspaces.get": ["project.workspaces.read"],
|
||||||
"issues.list": ["issues.read"],
|
"issues.list": ["issues.read"],
|
||||||
|
|
@ -56,6 +62,9 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||||
"issue.comments.get": ["issue.comments.read"],
|
"issue.comments.get": ["issue.comments.read"],
|
||||||
"agents.list": ["agents.read"],
|
"agents.list": ["agents.read"],
|
||||||
"agents.get": ["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.list": ["goals.read"],
|
||||||
"goals.get": ["goals.read"],
|
"goals.get": ["goals.read"],
|
||||||
"activity.list": ["activity.read"],
|
"activity.list": ["activity.read"],
|
||||||
|
|
@ -65,6 +74,12 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||||
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
|
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
|
||||||
"db.namespace": ["database.namespace.read"],
|
"db.namespace": ["database.namespace.read"],
|
||||||
"db.query": ["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
|
// Data write operations
|
||||||
"issues.create": ["issues.create"],
|
"issues.create": ["issues.create"],
|
||||||
|
|
@ -133,6 +148,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||||
commentAnnotation: "ui.commentAnnotation.register",
|
commentAnnotation: "ui.commentAnnotation.register",
|
||||||
commentContextMenuItem: "ui.action.register",
|
commentContextMenuItem: "ui.action.register",
|
||||||
settingsPage: "instance.settings.register",
|
settingsPage: "instance.settings.register",
|
||||||
|
routeSidebar: "ui.sidebar.register",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -167,6 +183,9 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||||
webhooks: "webhooks.receive",
|
webhooks: "webhooks.receive",
|
||||||
database: "database.namespace.migrate",
|
database: "database.namespace.migrate",
|
||||||
environmentDrivers: "environment.drivers.register",
|
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;
|
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) {
|
async function getPluginRecord(pluginId: string) {
|
||||||
const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1);
|
const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1);
|
||||||
const plugin = rows[0];
|
const plugin = rows[0];
|
||||||
|
|
@ -311,14 +323,18 @@ export function pluginDatabaseService(db: Db) {
|
||||||
return plugin;
|
return plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
|
async function ensureNamespaceWithClient(
|
||||||
|
client: PluginDatabaseClient,
|
||||||
|
pluginId: string,
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
) {
|
||||||
if (!manifest.database) return null;
|
if (!manifest.database) return null;
|
||||||
const namespaceName = derivePluginDatabaseNamespace(
|
const namespaceName = derivePluginDatabaseNamespace(
|
||||||
manifest.id,
|
manifest.id,
|
||||||
manifest.database.namespaceSlug,
|
manifest.database.namespaceSlug,
|
||||||
);
|
);
|
||||||
await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
|
await client.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`));
|
||||||
const rows = await db
|
const rows = await client
|
||||||
.insert(pluginDatabaseNamespaces)
|
.insert(pluginDatabaseNamespaces)
|
||||||
.values({
|
.values({
|
||||||
pluginId,
|
pluginId,
|
||||||
|
|
@ -341,6 +357,10 @@ export function pluginDatabaseService(db: Db) {
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) {
|
||||||
|
return ensureNamespaceWithClient(db, pluginId, manifest);
|
||||||
|
}
|
||||||
|
|
||||||
async function getNamespace(pluginId: string) {
|
async function getNamespace(pluginId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -358,7 +378,7 @@ export function pluginDatabaseService(db: Db) {
|
||||||
return namespace.namespaceName;
|
return namespace.namespaceName;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordMigrationFailure(input: {
|
async function recordMigrationFailure(client: PluginDatabaseClient, input: {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
pluginKey: string;
|
pluginKey: string;
|
||||||
namespaceName: string;
|
namespaceName: string;
|
||||||
|
|
@ -368,7 +388,7 @@ export function pluginDatabaseService(db: Db) {
|
||||||
error: unknown;
|
error: unknown;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
const message = input.error instanceof Error ? input.error.message : String(input.error);
|
||||||
await db
|
await client
|
||||||
.insert(pluginMigrations)
|
.insert(pluginMigrations)
|
||||||
.values({
|
.values({
|
||||||
pluginId: input.pluginId,
|
pluginId: input.pluginId,
|
||||||
|
|
@ -391,7 +411,7 @@ export function pluginDatabaseService(db: Db) {
|
||||||
appliedAt: null,
|
appliedAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db
|
await client
|
||||||
.update(pluginDatabaseNamespaces)
|
.update(pluginDatabaseNamespaces)
|
||||||
.set({ status: "migration_failed", updatedAt: new Date() })
|
.set({ status: "migration_failed", updatedAt: new Date() })
|
||||||
.where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId));
|
.where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId));
|
||||||
|
|
@ -400,7 +420,12 @@ export function pluginDatabaseService(db: Db) {
|
||||||
return {
|
return {
|
||||||
ensureNamespace,
|
ensureNamespace,
|
||||||
|
|
||||||
async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) {
|
async applyMigrations(
|
||||||
|
pluginId: string,
|
||||||
|
manifest: PaperclipPluginManifestV1,
|
||||||
|
packageRoot: string,
|
||||||
|
options: ApplyPluginMigrationsOptions = {},
|
||||||
|
) {
|
||||||
if (!manifest.database) return null;
|
if (!manifest.database) return null;
|
||||||
const namespace = await ensureNamespace(pluginId, manifest);
|
const namespace = await ensureNamespace(pluginId, manifest);
|
||||||
if (!namespace) return null;
|
if (!namespace) return null;
|
||||||
|
|
@ -409,13 +434,14 @@ export function pluginDatabaseService(db: Db) {
|
||||||
const migrationFiles = await listSqlMigrationFiles(migrationDir);
|
const migrationFiles = await listSqlMigrationFiles(migrationDir);
|
||||||
const coreReadTables = manifest.database.coreReadTables ?? [];
|
const coreReadTables = manifest.database.coreReadTables ?? [];
|
||||||
const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16);
|
const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16);
|
||||||
|
const persistFailure = options.persistFailure ?? true;
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
const applyWithClient = async (client: PluginDatabaseClient) => {
|
||||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
|
await client.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`);
|
||||||
for (const migrationKey of migrationFiles) {
|
for (const migrationKey of migrationFiles) {
|
||||||
const content = await readFile(path.join(migrationDir, migrationKey), "utf8");
|
const content = await readFile(path.join(migrationDir, migrationKey), "utf8");
|
||||||
const checksum = createHash("sha256").update(content).digest("hex");
|
const checksum = createHash("sha256").update(content).digest("hex");
|
||||||
const existingRows = await tx
|
const existingRows = await client
|
||||||
.select()
|
.select()
|
||||||
.from(pluginMigrations)
|
.from(pluginMigrations)
|
||||||
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey)))
|
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey)))
|
||||||
|
|
@ -435,9 +461,9 @@ export function pluginDatabaseService(db: Db) {
|
||||||
}
|
}
|
||||||
for (const statement of statements) {
|
for (const statement of statements) {
|
||||||
validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables);
|
validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables);
|
||||||
await tx.execute(sql.raw(statement));
|
await client.execute(sql.raw(statement));
|
||||||
}
|
}
|
||||||
await tx
|
await client
|
||||||
.insert(pluginMigrations)
|
.insert(pluginMigrations)
|
||||||
.values({
|
.values({
|
||||||
pluginId,
|
pluginId,
|
||||||
|
|
@ -461,19 +487,27 @@ export function pluginDatabaseService(db: Db) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await recordMigrationFailure({
|
if (persistFailure) {
|
||||||
pluginId,
|
await recordMigrationFailure(db, {
|
||||||
pluginKey: manifest.id,
|
pluginId,
|
||||||
namespaceName: namespace.namespaceName,
|
pluginKey: manifest.id,
|
||||||
migrationKey,
|
namespaceName: namespace.namespaceName,
|
||||||
checksum,
|
migrationKey,
|
||||||
pluginVersion: manifest.version,
|
checksum,
|
||||||
error,
|
pluginVersion: manifest.version,
|
||||||
});
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (typeof db.transaction === "function") {
|
||||||
|
await db.transaction(async (tx) => applyWithClient(tx as PluginDatabaseClient));
|
||||||
|
} else {
|
||||||
|
await applyWithClient(db);
|
||||||
|
}
|
||||||
|
|
||||||
return namespace;
|
return namespace;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
PluginIssueOrchestrationSummary,
|
PluginIssueOrchestrationSummary,
|
||||||
} from "@paperclipai/plugin-sdk";
|
} from "@paperclipai/plugin-sdk";
|
||||||
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
|
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
|
||||||
|
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||||
import { companyService } from "./companies.js";
|
import { companyService } from "./companies.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
|
|
@ -34,12 +35,27 @@ import { budgetService } from "./budgets.js";
|
||||||
import { issueApprovalService } from "./issue-approvals.js";
|
import { issueApprovalService } from "./issue-approvals.js";
|
||||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
import { activityService } from "./activity.js";
|
import { activityService } from "./activity.js";
|
||||||
import { costService } from "./costs.js";
|
import { costService } from "./costs.js";
|
||||||
import { assetService } from "./assets.js";
|
import { assetService } from "./assets.js";
|
||||||
import { pluginRegistryService } from "./plugin-registry.js";
|
import { pluginRegistryService } from "./plugin-registry.js";
|
||||||
import { pluginStateStore } from "./plugin-state-store.js";
|
import { pluginStateStore } from "./plugin-state-store.js";
|
||||||
import { pluginDatabaseService } from "./plugin-database.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 { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
|
||||||
import { logActivity } from "./activity-log.js";
|
import { logActivity } from "./activity-log.js";
|
||||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||||
|
|
@ -460,7 +476,7 @@ export function buildHostServices(
|
||||||
pluginKey: string,
|
pluginKey: string,
|
||||||
eventBus: PluginEventBus,
|
eventBus: PluginEventBus,
|
||||||
notifyWorker?: (method: string, params: unknown) => void,
|
notifyWorker?: (method: string, params: unknown) => void,
|
||||||
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 } = {},
|
||||||
): HostServices & { dispose(): void } {
|
): HostServices & { dispose(): void } {
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
const stateStore = pluginStateStore(db);
|
const stateStore = pluginStateStore(db);
|
||||||
|
|
@ -468,6 +484,31 @@ export function buildHostServices(
|
||||||
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||||
const companies = companyService(db);
|
const companies = companyService(db);
|
||||||
const agents = agentService(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, {
|
const heartbeat = heartbeatService(db, {
|
||||||
pluginWorkerManager: options.pluginWorkerManager,
|
pluginWorkerManager: options.pluginWorkerManager,
|
||||||
});
|
});
|
||||||
|
|
@ -518,6 +559,23 @@ export function buildHostServices(
|
||||||
*/
|
*/
|
||||||
const ensurePluginAvailableForCompany = async (_companyId: string) => {};
|
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 }>(
|
const inCompany = <T extends { companyId: string | null | undefined }>(
|
||||||
record: T | null | undefined,
|
record: T | null | undefined,
|
||||||
companyId: string,
|
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: {
|
state: {
|
||||||
async get(params) {
|
async get(params) {
|
||||||
return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, {
|
return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, {
|
||||||
|
|
@ -1013,6 +1151,77 @@ export function buildHostServices(
|
||||||
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
|
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: {
|
issues: {
|
||||||
|
|
@ -1031,8 +1240,12 @@ export function buildHostServices(
|
||||||
async create(params) {
|
async create(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params;
|
const { actorAgentId, actorUserId, actorRunId, originKind, surfaceVisibility, ...issueInput } = params;
|
||||||
const normalizedOriginKind = normalizePluginOriginKind(originKind);
|
const normalizedOriginKind = normalizePluginOriginKind(
|
||||||
|
surfaceVisibility === "plugin_operation" && !originKind
|
||||||
|
? pluginOperationIssueOriginKind(pluginKey)
|
||||||
|
: originKind,
|
||||||
|
);
|
||||||
const issue = (await issues.create(companyId, {
|
const issue = (await issues.create(companyId, {
|
||||||
...(issueInput as any),
|
...(issueInput as any),
|
||||||
originKind: normalizedOriginKind,
|
originKind: normalizedOriginKind,
|
||||||
|
|
@ -1641,6 +1854,21 @@ export function buildHostServices(
|
||||||
if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy");
|
if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy");
|
||||||
return { runId: run.id };
|
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: {
|
goals: {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { readdir, readFile, rm, stat } from "node:fs/promises";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -248,6 +248,8 @@ export interface PluginRuntimeServices {
|
||||||
instanceInfo: {
|
instanceInfo: {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
hostVersion: string;
|
hostVersion: string;
|
||||||
|
deploymentMode?: "local_trusted" | "authenticated";
|
||||||
|
deploymentExposure?: "private" | "public";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -932,7 +934,10 @@ export function pluginLoader(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import works for both .js (ESM) and .cjs (CJS) manifests
|
// 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
|
// The manifest may be the default export or the module itself
|
||||||
raw = mod["default"] ?? mod;
|
raw = mod["default"] ?? mod;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -944,6 +949,51 @@ export function pluginLoader(
|
||||||
return manifestValidator.parseOrThrow(raw);
|
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
|
* Build a DiscoveredPlugin from a resolved package directory, or null
|
||||||
* if the package is not a Paperclip plugin.
|
* if the package is not a Paperclip plugin.
|
||||||
|
|
@ -1256,22 +1306,43 @@ export function pluginLoader(
|
||||||
|
|
||||||
async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> {
|
async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> {
|
||||||
const discovered = await fetchAndValidate(installOptions);
|
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)
|
// Step 6: Persist install record and apply plugin-owned schema migrations
|
||||||
await registry.install(
|
// in one database transaction. If migration validation fails, the plugin
|
||||||
{
|
// row, namespace record, migration ledger, and created schema all roll back.
|
||||||
packageName: discovered.packageName,
|
const installDb = manifest.database ? migrationDb : db;
|
||||||
packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined,
|
await installDb.transaction(async (tx) => {
|
||||||
},
|
const txDb = tx as unknown as Db;
|
||||||
discovered.manifest!,
|
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(
|
log.info(
|
||||||
{
|
{
|
||||||
pluginId: discovered.manifest!.id,
|
pluginId: manifest.id,
|
||||||
packageName: discovered.packageName,
|
packageName: discovered.packageName,
|
||||||
version: discovered.version,
|
version: discovered.version,
|
||||||
capabilities: discovered.manifest!.capabilities,
|
capabilities: manifest.capabilities,
|
||||||
},
|
},
|
||||||
"plugin-loader: plugin installed successfully",
|
"plugin-loader: plugin installed successfully",
|
||||||
);
|
);
|
||||||
|
|
@ -1663,9 +1734,10 @@ export function pluginLoader(
|
||||||
* `error` in the database when activation fails.
|
* `error` in the database when activation fails.
|
||||||
*/
|
*/
|
||||||
async function activatePlugin(plugin: PluginRecord): Promise<PluginLoadResult> {
|
async function activatePlugin(plugin: PluginRecord): Promise<PluginLoadResult> {
|
||||||
const manifest = plugin.manifestJson;
|
|
||||||
const pluginId = plugin.id;
|
const pluginId = plugin.id;
|
||||||
const pluginKey = plugin.pluginKey;
|
const pluginKey = plugin.pluginKey;
|
||||||
|
let activePlugin = plugin;
|
||||||
|
let manifest = activePlugin.manifestJson;
|
||||||
|
|
||||||
const registered: PluginLoadResult["registered"] = {
|
const registered: PluginLoadResult["registered"] = {
|
||||||
worker: false,
|
worker: false,
|
||||||
|
|
@ -1705,8 +1777,10 @@ export function pluginLoader(
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 1. Resolve worker entrypoint
|
// 1. Resolve worker entrypoint
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
|
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||||
const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir);
|
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
||||||
|
manifest = activePlugin.manifestJson;
|
||||||
|
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 2. Apply restricted database migrations before worker startup
|
// 2. Apply restricted database migrations before worker startup
|
||||||
|
|
@ -1746,12 +1820,16 @@ export function pluginLoader(
|
||||||
databaseNamespace,
|
databaseNamespace,
|
||||||
hostHandlers,
|
hostHandlers,
|
||||||
autoRestart: true,
|
autoRestart: true,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_DEPLOYMENT_MODE: instanceInfo.deploymentMode ?? "",
|
||||||
|
PAPERCLIP_DEPLOYMENT_EXPOSURE: instanceInfo.deploymentExposure ?? "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Repo-local plugin installs can resolve workspace TS sources at runtime
|
// Repo-local plugin installs can resolve workspace TS sources at runtime
|
||||||
// (for example @paperclipai/shared exports). Run those workers through
|
// (for example @paperclipai/shared exports). Run those workers through
|
||||||
// the tsx loader so first-party example plugins work in development.
|
// 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];
|
workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1842,13 +1920,13 @@ export function pluginLoader(
|
||||||
{
|
{
|
||||||
pluginId,
|
pluginId,
|
||||||
pluginKey,
|
pluginKey,
|
||||||
version: plugin.version,
|
version: activePlugin.version,
|
||||||
registered,
|
registered,
|
||||||
},
|
},
|
||||||
"plugin-loader: plugin activated successfully",
|
"plugin-loader: plugin activated successfully",
|
||||||
);
|
);
|
||||||
|
|
||||||
return { plugin, success: true, registered };
|
return { plugin: activePlugin, success: true, registered };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
|
||||||
|
|
@ -1872,7 +1950,7 @@ export function pluginLoader(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugin,
|
plugin: activePlugin,
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
registered,
|
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 {
|
import {
|
||||||
plugins,
|
plugins,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
|
pluginCompanySettings,
|
||||||
pluginEntities,
|
pluginEntities,
|
||||||
pluginJobs,
|
pluginJobs,
|
||||||
pluginJobRuns,
|
pluginJobRuns,
|
||||||
|
|
@ -15,6 +16,7 @@ import type {
|
||||||
UpdatePluginStatus,
|
UpdatePluginStatus,
|
||||||
UpsertPluginConfig,
|
UpsertPluginConfig,
|
||||||
PatchPluginConfig,
|
PatchPluginConfig,
|
||||||
|
PluginCompanySettings,
|
||||||
PluginEntityRecord,
|
PluginEntityRecord,
|
||||||
PluginEntityQuery,
|
PluginEntityQuery,
|
||||||
PluginJobRecord,
|
PluginJobRecord,
|
||||||
|
|
@ -387,6 +389,64 @@ export function pluginRegistryService(db: Db) {
|
||||||
return rows[0] ?? null;
|
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 -------------------------------------------------------
|
// ----- Entities -------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
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 {
|
import {
|
||||||
PROJECT_COLORS,
|
PROJECT_COLORS,
|
||||||
deriveProjectUrlKey,
|
deriveProjectUrlKey,
|
||||||
|
|
@ -10,9 +18,12 @@ import {
|
||||||
type ProjectCodebase,
|
type ProjectCodebase,
|
||||||
type ProjectExecutionWorkspacePolicy,
|
type ProjectExecutionWorkspacePolicy,
|
||||||
type ProjectGoalRef,
|
type ProjectGoalRef,
|
||||||
|
type ProjectManagedByPlugin,
|
||||||
type ProjectWorkspaceRuntimeConfig,
|
type ProjectWorkspaceRuntimeConfig,
|
||||||
type ProjectWorkspace,
|
type ProjectWorkspace,
|
||||||
type WorkspaceRuntimeService,
|
type WorkspaceRuntimeService,
|
||||||
|
type PluginManagedProjectDeclaration,
|
||||||
|
type PluginManagedProjectResolution,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
|
|
@ -50,6 +61,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
|
||||||
codebase: ProjectCodebase;
|
codebase: ProjectCodebase;
|
||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
|
managedByPlugin: ProjectManagedByPlugin | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectShortnameRow {
|
interface ProjectShortnameRow {
|
||||||
|
|
@ -245,6 +257,40 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||||
arr.push(row);
|
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) => {
|
return rows.map((row) => {
|
||||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||||
|
|
@ -264,6 +310,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||||
}),
|
}),
|
||||||
workspaces,
|
workspaces,
|
||||||
primaryWorkspace,
|
primaryWorkspace,
|
||||||
|
managedByPlugin: managedByProjectId.get(row.id) ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -337,6 +384,17 @@ function deriveWorkspaceName(input: {
|
||||||
return "Workspace";
|
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(
|
export function resolveProjectNameForUniqueShortname(
|
||||||
requestedName: string,
|
requestedName: string,
|
||||||
existingProjects: ProjectShortnameRow[],
|
existingProjects: ProjectShortnameRow[],
|
||||||
|
|
@ -398,6 +456,58 @@ async function ensureSinglePrimaryWorkspace(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectService(db: Db) {
|
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 {
|
return {
|
||||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
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));
|
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
getById: getProjectById,
|
||||||
const row = await db
|
|
||||||
.select()
|
resolveManagedProject: async (input: {
|
||||||
.from(projects)
|
companyId: string;
|
||||||
.where(eq(projects.id, id))
|
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);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!row) return null;
|
if (!plugin || plugin.pluginKey !== input.pluginKey) {
|
||||||
const [withGoals] = await attachGoals(db, [row]);
|
return {
|
||||||
if (!withGoals) return null;
|
pluginKey: input.pluginKey,
|
||||||
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
resourceKind: "project",
|
||||||
return enriched ?? null;
|
resourceKey: input.projectKey,
|
||||||
},
|
companyId: input.companyId,
|
||||||
|
projectId: null,
|
||||||
create: async (
|
project: null,
|
||||||
companyId: string,
|
status: "missing",
|
||||||
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
|
const declaration = plugin.manifestJson.projects?.find((project) => project.projectKey === input.projectKey);
|
||||||
.select({ id: projects.id, name: projects.name })
|
if (!declaration) {
|
||||||
.from(projects)
|
return {
|
||||||
.where(eq(projects.companyId, companyId));
|
pluginKey: input.pluginKey,
|
||||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
resourceKind: "project",
|
||||||
|
resourceKey: input.projectKey,
|
||||||
// Also write goalId to the legacy column (first goal or null)
|
companyId: input.companyId,
|
||||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
projectId: null,
|
||||||
|
project: null,
|
||||||
const row = await db
|
status: "missing",
|
||||||
.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 defaults = buildManagedProjectDefaults(declaration);
|
||||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
const existingBinding = await db
|
||||||
return enriched!;
|
.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 (
|
update: async (
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
issueInboxArchives,
|
issueInboxArchives,
|
||||||
issueReadStates,
|
issueReadStates,
|
||||||
issues,
|
issues,
|
||||||
|
pluginManagedResources,
|
||||||
|
plugins,
|
||||||
projects,
|
projects,
|
||||||
routineRuns,
|
routineRuns,
|
||||||
routines,
|
routines,
|
||||||
|
|
@ -21,6 +23,7 @@ import type {
|
||||||
Routine,
|
Routine,
|
||||||
RoutineDetail,
|
RoutineDetail,
|
||||||
RoutineListItem,
|
RoutineListItem,
|
||||||
|
RoutineManagedByPlugin,
|
||||||
RoutineRunSummary,
|
RoutineRunSummary,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
RoutineTriggerSecretMaterial,
|
RoutineTriggerSecretMaterial,
|
||||||
|
|
@ -34,6 +37,7 @@ import {
|
||||||
getBuiltinRoutineVariableValues,
|
getBuiltinRoutineVariableValues,
|
||||||
extractRoutineVariableNames,
|
extractRoutineVariableNames,
|
||||||
interpolateRoutineTemplate,
|
interpolateRoutineTemplate,
|
||||||
|
pluginOperationIssueOriginKind,
|
||||||
stringifyRoutineVariableValue,
|
stringifyRoutineVariableValue,
|
||||||
syncRoutineVariablesWithTemplate,
|
syncRoutineVariablesWithTemplate,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -354,6 +358,16 @@ function createRoutineDispatchFingerprint(input: {
|
||||||
return crypto.createHash("sha256").update(canonical).digest("hex");
|
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) {
|
function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) {
|
||||||
return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE)
|
return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE)
|
||||||
|| extractRoutineVariableNames([routine.title, routine.description]).includes(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);
|
.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) {
|
async function getTriggerById(id: string) {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -664,8 +735,11 @@ export function routineService(
|
||||||
routine: typeof routines.$inferSelect,
|
routine: typeof routines.$inferSelect,
|
||||||
executor: Db = db,
|
executor: Db = db,
|
||||||
dispatchFingerprint?: string | null,
|
dispatchFingerprint?: string | null,
|
||||||
|
origin?: { kind: string; id: string | null },
|
||||||
) {
|
) {
|
||||||
const fingerprintCondition = routineExecutionFingerprintCondition(dispatchFingerprint);
|
const fingerprintCondition = routineExecutionFingerprintCondition(dispatchFingerprint);
|
||||||
|
const originKind = origin?.kind ?? "routine_execution";
|
||||||
|
const originId = origin?.id ?? routine.id;
|
||||||
const executionBoundIssue = await executor
|
const executionBoundIssue = await executor
|
||||||
.select()
|
.select()
|
||||||
.from(issues)
|
.from(issues)
|
||||||
|
|
@ -679,8 +753,8 @@ export function routineService(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(issues.companyId, routine.companyId),
|
eq(issues.companyId, routine.companyId),
|
||||||
eq(issues.originKind, "routine_execution"),
|
eq(issues.originKind, originKind),
|
||||||
eq(issues.originId, routine.id),
|
eq(issues.originId, originId),
|
||||||
inArray(issues.status, OPEN_ISSUE_STATUSES),
|
inArray(issues.status, OPEN_ISSUE_STATUSES),
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
...(fingerprintCondition ? [fingerprintCondition] : []),
|
...(fingerprintCondition ? [fingerprintCondition] : []),
|
||||||
|
|
@ -705,8 +779,8 @@ export function routineService(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(issues.companyId, routine.companyId),
|
eq(issues.companyId, routine.companyId),
|
||||||
eq(issues.originKind, "routine_execution"),
|
eq(issues.originKind, originKind),
|
||||||
eq(issues.originId, routine.id),
|
eq(issues.originId, originId),
|
||||||
inArray(issues.status, OPEN_ISSUE_STATUSES),
|
inArray(issues.status, OPEN_ISSUE_STATUSES),
|
||||||
isNull(issues.hiddenAt),
|
isNull(issues.hiddenAt),
|
||||||
...(fingerprintCondition ? [fingerprintCondition] : []),
|
...(fingerprintCondition ? [fingerprintCondition] : []),
|
||||||
|
|
@ -844,6 +918,13 @@ export function routineService(
|
||||||
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
|
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
|
||||||
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
|
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
|
||||||
const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables });
|
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({
|
const dispatchFingerprint = createRoutineDispatchFingerprint({
|
||||||
payload: triggerPayload,
|
payload: triggerPayload,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -902,7 +983,10 @@ export function routineService(
|
||||||
|
|
||||||
let createdIssue: Awaited<ReturnType<typeof issueSvc.create>> | null = null;
|
let createdIssue: Awaited<ReturnType<typeof issueSvc.create>> | null = null;
|
||||||
try {
|
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") {
|
if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") {
|
||||||
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
|
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
|
||||||
if (manualRunnerUserId) {
|
if (manualRunnerUserId) {
|
||||||
|
|
@ -942,10 +1026,11 @@ export function routineService(
|
||||||
assigneeAgentId,
|
assigneeAgentId,
|
||||||
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
|
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
|
||||||
createdByUserId: manualRunnerUserId,
|
createdByUserId: manualRunnerUserId,
|
||||||
originKind: "routine_execution",
|
originKind: issueOriginKind,
|
||||||
originId: input.routine.id,
|
originId: issueOriginId,
|
||||||
originRunId: createdRun.id,
|
originRunId: createdRun.id,
|
||||||
originFingerprint: dispatchFingerprint,
|
originFingerprint: dispatchFingerprint,
|
||||||
|
billingCode: issueBillingCode,
|
||||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||||
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
executionWorkspacePreference: input.executionWorkspacePreference ?? null,
|
||||||
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
executionWorkspaceSettings: input.executionWorkspaceSettings ?? null,
|
||||||
|
|
@ -962,7 +1047,10 @@ export function routineService(
|
||||||
throw error;
|
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;
|
if (!existingIssue) throw error;
|
||||||
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
|
const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced";
|
||||||
if (manualRunnerUserId) {
|
if (manualRunnerUserId) {
|
||||||
|
|
@ -1084,13 +1172,15 @@ export function routineService(
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(routines.updatedAt), asc(routines.title));
|
.orderBy(desc(routines.updatedAt), asc(routines.title));
|
||||||
const routineIds = rows.map((row) => row.id);
|
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),
|
listTriggersForRoutineIds(companyId, routineIds),
|
||||||
listLatestRunByRoutineIds(companyId, routineIds),
|
listLatestRunByRoutineIds(companyId, routineIds),
|
||||||
listLiveIssueByRoutineIds(companyId, routineIds),
|
listLiveIssueByRoutineIds(companyId, routineIds),
|
||||||
|
listManagedRoutineMetadata(routineIds),
|
||||||
]);
|
]);
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
managedByPlugin: managedByRoutine.get(row.id) ?? null,
|
||||||
triggers: (triggersByRoutine.get(row.id) ?? []).map((trigger) => ({
|
triggers: (triggersByRoutine.get(row.id) ?? []).map((trigger) => ({
|
||||||
id: trigger.id,
|
id: trigger.id,
|
||||||
kind: trigger.kind as RoutineListItem["triggers"][number]["kind"],
|
kind: trigger.kind as RoutineListItem["triggers"][number]["kind"],
|
||||||
|
|
@ -1110,7 +1200,7 @@ export function routineService(
|
||||||
getDetail: async (id: string): Promise<RoutineDetail | null> => {
|
getDetail: async (id: string): Promise<RoutineDetail | null> => {
|
||||||
const row = await getRoutineById(id);
|
const row = await getRoutineById(id);
|
||||||
if (!row) return null;
|
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
|
row.projectId
|
||||||
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
|
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -1189,10 +1279,12 @@ export function routineService(
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
findLiveExecutionIssue(row),
|
findLiveExecutionIssue(row),
|
||||||
|
listManagedRoutineMetadata([row.id]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
managedByPlugin: managedByRoutine.get(row.id) ?? null,
|
||||||
project,
|
project,
|
||||||
assignee,
|
assignee,
|
||||||
parentIssue,
|
parentIssue,
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ function boardRoutes() {
|
||||||
<Route path="u/:userSlug" element={<UserProfile />} />
|
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
<Route path="instance/settings/adapters" element={<AdapterManager />} />
|
||||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
<Route path=":pluginRoutePath/*" element={<PluginPage />} />
|
||||||
<Route path="*" element={<NotFoundPage scope="board" />} />
|
<Route path="*" element={<NotFoundPage scope="board" />} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const issuesApi = {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
executionWorkspaceId?: string;
|
executionWorkspaceId?: string;
|
||||||
originKind?: string;
|
originKind?: string;
|
||||||
|
originKindPrefix?: string;
|
||||||
originId?: string;
|
originId?: string;
|
||||||
descendantOf?: string;
|
descendantOf?: string;
|
||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
|
|
@ -66,6 +67,7 @@ export const issuesApi = {
|
||||||
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
|
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
|
||||||
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
|
||||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||||
|
if (filters?.originKindPrefix) params.set("originKindPrefix", filters.originKindPrefix);
|
||||||
if (filters?.originId) params.set("originId", filters.originId);
|
if (filters?.originId) params.set("originId", filters.originId);
|
||||||
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf);
|
||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
|
|
|
||||||
64
ui/src/api/plugins.test.ts
Normal file
64
ui/src/api/plugins.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockApi = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
api: mockApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pluginsApi } from "./plugins";
|
||||||
|
|
||||||
|
describe("pluginsApi local folders", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApi.get.mockReset();
|
||||||
|
mockApi.post.mockReset();
|
||||||
|
mockApi.put.mockReset();
|
||||||
|
mockApi.get.mockResolvedValue({});
|
||||||
|
mockApi.post.mockResolvedValue({});
|
||||||
|
mockApi.put.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists company-scoped local folders for a plugin", async () => {
|
||||||
|
await pluginsApi.listLocalFolders("plugin-1", "company-1");
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/plugins/plugin-1/companies/company-1/local-folders",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates a candidate folder path without saving", async () => {
|
||||||
|
await pluginsApi.validateLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
access: "readWrite",
|
||||||
|
requiredFiles: ["WIKI.md"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.post).toHaveBeenCalledWith(
|
||||||
|
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root/validate",
|
||||||
|
{
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
access: "readWrite",
|
||||||
|
requiredFiles: ["WIKI.md"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves through the local-folder PUT endpoint", async () => {
|
||||||
|
await pluginsApi.configureLocalFolder("plugin-1", "company-1", "wiki-root", {
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
requiredDirectories: ["wiki"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockApi.put).toHaveBeenCalledWith(
|
||||||
|
"/plugins/plugin-1/companies/company-1/local-folders/wiki-root",
|
||||||
|
{
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
requiredDirectories: ["wiki"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
PluginLauncherDeclaration,
|
PluginLauncherDeclaration,
|
||||||
PluginLauncherRenderContextSnapshot,
|
PluginLauncherRenderContextSnapshot,
|
||||||
PluginUiSlotDeclaration,
|
PluginUiSlotDeclaration,
|
||||||
|
PluginLocalFolderDeclaration,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
PluginConfig,
|
PluginConfig,
|
||||||
PluginStatus,
|
PluginStatus,
|
||||||
|
|
@ -140,6 +141,54 @@ export interface AvailablePluginExample {
|
||||||
tag: "example";
|
tag: "example";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderProblem {
|
||||||
|
code:
|
||||||
|
| "not_configured"
|
||||||
|
| "not_absolute"
|
||||||
|
| "missing"
|
||||||
|
| "not_directory"
|
||||||
|
| "not_readable"
|
||||||
|
| "not_writable"
|
||||||
|
| "missing_directory"
|
||||||
|
| "missing_file"
|
||||||
|
| "path_traversal"
|
||||||
|
| "symlink_escape"
|
||||||
|
| "atomic_write_failed";
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderStatus {
|
||||||
|
folderKey: string;
|
||||||
|
configured: boolean;
|
||||||
|
path: string | null;
|
||||||
|
realPath: string | null;
|
||||||
|
access: "read" | "readWrite";
|
||||||
|
readable: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
requiredDirectories: string[];
|
||||||
|
requiredFiles: string[];
|
||||||
|
missingDirectories: string[];
|
||||||
|
missingFiles: string[];
|
||||||
|
healthy: boolean;
|
||||||
|
problems: PluginLocalFolderProblem[];
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFoldersResponse {
|
||||||
|
pluginId: string;
|
||||||
|
companyId: string;
|
||||||
|
declarations: PluginLocalFolderDeclaration[];
|
||||||
|
folders: PluginLocalFolderStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalFolderSaveInput {
|
||||||
|
path: string;
|
||||||
|
access?: "read" | "readWrite";
|
||||||
|
requiredDirectories?: string[];
|
||||||
|
requiredFiles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin management API client.
|
* Plugin management API client.
|
||||||
*
|
*
|
||||||
|
|
@ -337,6 +386,48 @@ export const pluginsApi = {
|
||||||
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
|
||||||
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List manifest-declared and stored company-scoped local folders for a plugin.
|
||||||
|
*/
|
||||||
|
listLocalFolders: (pluginId: string, companyId: string) =>
|
||||||
|
api.get<PluginLocalFoldersResponse>(`/plugins/${pluginId}/companies/${companyId}/local-folders`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a configured local folder without changing persisted settings.
|
||||||
|
*/
|
||||||
|
localFolderStatus: (pluginId: string, companyId: string, folderKey: string) =>
|
||||||
|
api.get<PluginLocalFolderStatus>(
|
||||||
|
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/status`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a candidate local folder path without saving it.
|
||||||
|
*/
|
||||||
|
validateLocalFolder: (
|
||||||
|
pluginId: string,
|
||||||
|
companyId: string,
|
||||||
|
folderKey: string,
|
||||||
|
input: PluginLocalFolderSaveInput,
|
||||||
|
) =>
|
||||||
|
api.post<PluginLocalFolderStatus>(
|
||||||
|
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/validate`,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a company-scoped local folder path and return its inspected status.
|
||||||
|
*/
|
||||||
|
configureLocalFolder: (
|
||||||
|
pluginId: string,
|
||||||
|
companyId: string,
|
||||||
|
folderKey: string,
|
||||||
|
input: PluginLocalFolderSaveInput,
|
||||||
|
) =>
|
||||||
|
api.put<PluginLocalFolderStatus>(
|
||||||
|
`/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}`,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Bridge proxy endpoints — used by the plugin UI bridge runtime
|
// Bridge proxy endpoints — used by the plugin UI bridge runtime
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { useCompany } from "@/context/CompanyContext";
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
import { useSidebar } from "@/context/SidebarContext";
|
import { useSidebar } from "@/context/SidebarContext";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
|
||||||
|
|
||||||
export function CompanySettingsSidebar() {
|
export function CompanySettingsSidebar() {
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||||
|
|
@ -32,11 +31,8 @@ export function CompanySettingsSidebar() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||||
<SidebarCompanyMenu />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 px-3 pb-3 shrink-0">
|
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
190
ui/src/components/FileTree.test.tsx
Normal file
190
ui/src/components/FileTree.test.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { FileTree, buildFileTree } from "./FileTree";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("FileTree", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function row(path: string) {
|
||||||
|
return container.querySelector(`[data-file-tree-path="${path}"]`) as HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("selects file rows and expands directory rows", () => {
|
||||||
|
const onSelectFile = vi.fn();
|
||||||
|
const onToggleDir = vi.fn();
|
||||||
|
const nodes = buildFileTree({
|
||||||
|
"README.md": "",
|
||||||
|
"docs/guide.md": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile="README.md"
|
||||||
|
expandedDirs={new Set(["docs"])}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onToggleDir={onToggleDir}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row("README.md")?.getAttribute("aria-selected")).toBe("true");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
row("docs/guide.md")?.click();
|
||||||
|
});
|
||||||
|
expect(onSelectFile).toHaveBeenCalledWith("docs/guide.md");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
row("docs")?.click();
|
||||||
|
});
|
||||||
|
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks partially selected directories as indeterminate", () => {
|
||||||
|
const nodes = buildFileTree({
|
||||||
|
"docs/a.md": "",
|
||||||
|
"docs/b.md": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={null}
|
||||||
|
expandedDirs={new Set(["docs"])}
|
||||||
|
checkedFiles={new Set(["docs/a.md"])}
|
||||||
|
onSelectFile={() => {}}
|
||||||
|
onToggleDir={() => {}}
|
||||||
|
onToggleCheck={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = row("docs")?.querySelector("input[type='checkbox']") as HTMLInputElement | null;
|
||||||
|
expect(input?.checked).toBe(false);
|
||||||
|
expect(input?.indeterminate).toBe(true);
|
||||||
|
expect(row("docs")?.getAttribute("aria-checked")).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders file badges and host-only file extras", () => {
|
||||||
|
const nodes = buildFileTree({
|
||||||
|
"wiki/very-long-page-slug.md": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={null}
|
||||||
|
expandedDirs={new Set(["wiki"])}
|
||||||
|
onSelectFile={() => {}}
|
||||||
|
onToggleDir={() => {}}
|
||||||
|
fileBadges={{
|
||||||
|
"wiki/very-long-page-slug.md": {
|
||||||
|
label: "fresh",
|
||||||
|
status: "ok",
|
||||||
|
tooltip: "Synced",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
renderFileExtra={(node) => (
|
||||||
|
node.kind === "file" ? <span data-testid="file-extra">{node.name.length} chars</span> : null
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("fresh");
|
||||||
|
expect(container.querySelector("[title='Synced']")).not.toBeNull();
|
||||||
|
expect(container.querySelector("[data-testid='file-extra']")?.textContent).toBe("22 chars");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps long labels by default and can opt back into truncation", () => {
|
||||||
|
const nodes = buildFileTree({
|
||||||
|
"wiki/extremely-long-page-slug-that-wraps-on-mobile.md": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={null}
|
||||||
|
expandedDirs={new Set(["wiki"])}
|
||||||
|
onSelectFile={() => {}}
|
||||||
|
onToggleDir={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("break-all");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={null}
|
||||||
|
expandedDirs={new Set(["wiki"])}
|
||||||
|
onSelectFile={() => {}}
|
||||||
|
onToggleDir={() => {}}
|
||||||
|
wrapLabels={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row("wiki/extremely-long-page-slug-that-wraps-on-mobile.md")?.innerHTML).toContain("truncate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports tree keyboard expansion and checkbox toggling", () => {
|
||||||
|
const onToggleDir = vi.fn();
|
||||||
|
const onToggleCheck = vi.fn();
|
||||||
|
const nodes = buildFileTree({
|
||||||
|
"docs/a.md": "",
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<FileTree
|
||||||
|
nodes={nodes}
|
||||||
|
selectedFile={null}
|
||||||
|
expandedDirs={new Set()}
|
||||||
|
onSelectFile={() => {}}
|
||||||
|
onToggleDir={onToggleDir}
|
||||||
|
onToggleCheck={onToggleCheck}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const docsRow = row("docs");
|
||||||
|
act(() => {
|
||||||
|
docsRow?.focus();
|
||||||
|
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onToggleDir).toHaveBeenCalledWith("docs");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
docsRow?.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onToggleCheck).toHaveBeenCalledWith("docs", "dir");
|
||||||
|
});
|
||||||
|
});
|
||||||
500
ui/src/components/FileTree.tsx
Normal file
500
ui/src/components/FileTree.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
||||||
|
import type { KeyboardEvent, ReactNode } from "react";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileCode2,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
|
// -- Tree types --------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FileTreeNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: "dir" | "file";
|
||||||
|
children: FileTreeNode[];
|
||||||
|
/** Optional per-node metadata (e.g. import action) */
|
||||||
|
action?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||||
|
|
||||||
|
export type FileTreeBadge = {
|
||||||
|
label: string;
|
||||||
|
status: FileTreeBadgeVariant;
|
||||||
|
tooltip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeTone = "default" | "warning" | "error" | "muted";
|
||||||
|
|
||||||
|
export type FileTreeEmptyState = {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileTreeErrorState = {
|
||||||
|
message: string;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisibleFileTreeNode = {
|
||||||
|
node: FileTreeNode;
|
||||||
|
depth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TREE_BASE_INDENT = 16;
|
||||||
|
const TREE_STEP_INDENT = 24;
|
||||||
|
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||||
|
|
||||||
|
const fileTreeToneClass: Record<FileTreeTone, string | undefined> = {
|
||||||
|
default: undefined,
|
||||||
|
warning: "bg-amber-500/5 text-amber-700 dark:text-amber-300",
|
||||||
|
error: "bg-destructive/5 text-destructive",
|
||||||
|
muted: "opacity-50",
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildFileTree(
|
||||||
|
files: Record<string, unknown>,
|
||||||
|
actionMap?: Map<string, string>,
|
||||||
|
): FileTreeNode[] {
|
||||||
|
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||||
|
|
||||||
|
for (const filePath of Object.keys(files)) {
|
||||||
|
const segments = filePath.split("/").filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
let currentPath = "";
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||||
|
const isLeaf = i === segments.length - 1;
|
||||||
|
let next = current.children.find((c) => c.name === segment);
|
||||||
|
if (!next) {
|
||||||
|
next = {
|
||||||
|
name: segment,
|
||||||
|
path: currentPath,
|
||||||
|
kind: isLeaf ? "file" : "dir",
|
||||||
|
children: [],
|
||||||
|
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||||
|
};
|
||||||
|
current.children.push(next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortNode(node: FileTreeNode) {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
// Files before directories so PROJECT.md appears above tasks/
|
||||||
|
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
node.children.forEach(sortNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortNode(root);
|
||||||
|
return root.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countFiles(nodes: FileTreeNode[]): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kind === "file") count++;
|
||||||
|
else count += countFiles(node.children);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectAllPaths(
|
||||||
|
nodes: FileTreeNode[],
|
||||||
|
type: "file" | "dir" | "all" = "all",
|
||||||
|
): Set<string> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (type === "all" || node.kind === type) paths.add(node.path);
|
||||||
|
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(name: string) {
|
||||||
|
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
||||||
|
return FileText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenVisibleNodes(
|
||||||
|
nodes: FileTreeNode[],
|
||||||
|
expandedDirs: Set<string>,
|
||||||
|
depth = 0,
|
||||||
|
): VisibleFileTreeNode[] {
|
||||||
|
const flattened: VisibleFileTreeNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
flattened.push({ node, depth });
|
||||||
|
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||||
|
flattened.push(...flattenVisibleNodes(node.children, expandedDirs, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
|
||||||
|
if (node.kind === "file") {
|
||||||
|
return {
|
||||||
|
allChecked: checkedFiles.has(node.path),
|
||||||
|
someChecked: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const childFiles = collectAllPaths(node.children, "file");
|
||||||
|
const childFilePaths = [...childFiles];
|
||||||
|
const allChecked = childFilePaths.length > 0 && childFilePaths.every((p) => checkedFiles.has(p));
|
||||||
|
const someChecked = childFilePaths.some((p) => checkedFiles.has(p));
|
||||||
|
return { allChecked, someChecked: someChecked && !allChecked };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Frontmatter helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
export type FrontmatterData = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
||||||
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const data: FrontmatterData = {};
|
||||||
|
const rawYaml = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
let currentList: string[] | null = null;
|
||||||
|
|
||||||
|
for (const line of rawYaml.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
|
||||||
|
if (trimmed.startsWith("- ") && currentKey) {
|
||||||
|
if (!currentList) currentList = [];
|
||||||
|
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentKey && currentList) {
|
||||||
|
data[currentKey] = currentList;
|
||||||
|
currentList = null;
|
||||||
|
currentKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
||||||
|
if (kvMatch) {
|
||||||
|
const key = kvMatch[1];
|
||||||
|
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
||||||
|
if (val === "null") {
|
||||||
|
currentKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (val) {
|
||||||
|
data[key] = val;
|
||||||
|
currentKey = null;
|
||||||
|
} else {
|
||||||
|
currentKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentKey && currentList) {
|
||||||
|
data[currentKey] = currentList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(data).length > 0 ? { data, body } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: "Name",
|
||||||
|
title: "Title",
|
||||||
|
kind: "Kind",
|
||||||
|
reportsTo: "Reports to",
|
||||||
|
skills: "Skills",
|
||||||
|
status: "Status",
|
||||||
|
description: "Description",
|
||||||
|
priority: "Priority",
|
||||||
|
assignee: "Assignee",
|
||||||
|
project: "Project",
|
||||||
|
recurring: "Recurring",
|
||||||
|
targetDate: "Target date",
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- File tree component -----------------------------------------------------
|
||||||
|
|
||||||
|
export type FileTreeProps = {
|
||||||
|
nodes: FileTreeNode[];
|
||||||
|
selectedFile: string | null;
|
||||||
|
expandedDirs: Set<string>;
|
||||||
|
checkedFiles?: Set<string>;
|
||||||
|
onToggleDir: (path: string) => void;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
||||||
|
/** Serializable badge metadata keyed by path. This is safe to expose through plugin UI contracts. */
|
||||||
|
fileBadges?: Record<string, FileTreeBadge | undefined>;
|
||||||
|
/** Closed row tone metadata keyed by path. This avoids raw host class names in public contracts. */
|
||||||
|
fileTones?: Record<string, FileTreeTone | undefined>;
|
||||||
|
/** Internal-only escape hatch for current host call sites that need richer row content. */
|
||||||
|
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||||
|
/** @deprecated Use fileTones for public surfaces. Kept for compatibility with host-only callers. */
|
||||||
|
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
||||||
|
wrapLabels?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: FileTreeErrorState | null;
|
||||||
|
empty?: FileTreeEmptyState;
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileTree({
|
||||||
|
nodes,
|
||||||
|
selectedFile,
|
||||||
|
expandedDirs,
|
||||||
|
checkedFiles,
|
||||||
|
onToggleDir,
|
||||||
|
onSelectFile,
|
||||||
|
onToggleCheck,
|
||||||
|
fileBadges,
|
||||||
|
fileTones,
|
||||||
|
renderFileExtra,
|
||||||
|
fileRowClassName,
|
||||||
|
showCheckboxes = true,
|
||||||
|
wrapLabels = true,
|
||||||
|
loading = false,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
ariaLabel = "Files",
|
||||||
|
}: FileTreeProps) {
|
||||||
|
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
||||||
|
const visibleNodes = useMemo(
|
||||||
|
() => flattenVisibleNodes(nodes, expandedDirs),
|
||||||
|
[expandedDirs, nodes],
|
||||||
|
);
|
||||||
|
const [focusedPath, setFocusedPath] = useState<string | null>(null);
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLDivElement>());
|
||||||
|
|
||||||
|
function focusPath(path: string) {
|
||||||
|
setFocusedPath(path);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
rowRefs.current.get(path)?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNode(node: FileTreeNode) {
|
||||||
|
if (node.kind === "dir") onToggleDir(node.path);
|
||||||
|
else onSelectFile(node.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowKeyDown(event: KeyboardEvent<HTMLDivElement>, index: number, node: FileTreeNode) {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown": {
|
||||||
|
event.preventDefault();
|
||||||
|
const next = visibleNodes[Math.min(index + 1, visibleNodes.length - 1)];
|
||||||
|
if (next) focusPath(next.node.path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowUp": {
|
||||||
|
event.preventDefault();
|
||||||
|
const previous = visibleNodes[Math.max(index - 1, 0)];
|
||||||
|
if (previous) focusPath(previous.node.path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowRight":
|
||||||
|
if (node.kind === "dir" && !expandedDirs.has(node.path)) {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleDir(node.path);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
if (node.kind === "dir" && expandedDirs.has(node.path)) {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleDir(node.path);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
event.preventDefault();
|
||||||
|
toggleNode(node);
|
||||||
|
break;
|
||||||
|
case " ":
|
||||||
|
if (showCheckboxes && onToggleCheck) {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleCheck(node.path, node.kind);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div aria-busy="true" aria-label={ariaLabel} role="tree" className="py-1">
|
||||||
|
{[0, 1, 2, 3].map((row) => (
|
||||||
|
<div key={row} className={cn("flex items-center gap-2 px-4", TREE_ROW_HEIGHT_CLASS)}>
|
||||||
|
<Skeleton className="h-4 w-4 shrink-0 rounded-sm" />
|
||||||
|
<Skeleton className={cn("h-3.5", row === 1 ? "w-3/5" : "w-4/5")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||||
|
<div
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={1}
|
||||||
|
className="flex min-h-9 items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||||
|
statusBadge.error ?? statusBadgeDefault,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 text-destructive">{error.message}</span>
|
||||||
|
</div>
|
||||||
|
{error.retry && (
|
||||||
|
<Button type="button" size="xs" variant="outline" onClick={error.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} role="tree" className="p-3">
|
||||||
|
<div className="rounded-md border border-dashed border-border px-4 py-8 text-center">
|
||||||
|
<div className="text-sm font-medium">{empty?.title ?? "No files"}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{empty?.description ?? "Files will appear here when they are available."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div aria-label={ariaLabel} role="tree">
|
||||||
|
{visibleNodes.map(({ node, depth }, index) => {
|
||||||
|
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||||
|
const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles);
|
||||||
|
const badge = fileBadges?.[node.path];
|
||||||
|
const tone = fileTones?.[node.path] ?? "default";
|
||||||
|
const extraClassName = node.kind === "file" ? fileRowClassName?.(node, allChecked) : undefined;
|
||||||
|
const FileIcon = node.kind === "file" ? fileIcon(node.name) : null;
|
||||||
|
const isSelected = node.kind === "file" && node.path === selectedFile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.path}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) rowRefs.current.set(node.path, element);
|
||||||
|
else rowRefs.current.delete(node.path);
|
||||||
|
}}
|
||||||
|
role="treeitem"
|
||||||
|
aria-level={depth + 1}
|
||||||
|
aria-expanded={node.kind === "dir" ? expanded : undefined}
|
||||||
|
aria-selected={node.kind === "file" ? isSelected : undefined}
|
||||||
|
aria-checked={showCheckboxes ? (someChecked ? "mixed" : allChecked) : undefined}
|
||||||
|
tabIndex={(focusedPath ?? visibleNodes[0]?.node.path) === node.path ? 0 : -1}
|
||||||
|
className={cn(
|
||||||
|
node.kind === "dir"
|
||||||
|
? showCheckboxes
|
||||||
|
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
||||||
|
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground max-[480px]:grid-cols-[minmax(0,1fr)]"
|
||||||
|
: "group flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||||
|
TREE_ROW_HEIGHT_CLASS,
|
||||||
|
isSelected && "text-foreground bg-accent/20",
|
||||||
|
fileTreeToneClass[tone],
|
||||||
|
extraClassName,
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocusedPath(node.path)}
|
||||||
|
onClick={() => toggleNode(node)}
|
||||||
|
onKeyDown={(event) => handleRowKeyDown(event, index, node)}
|
||||||
|
data-file-tree-path={node.path}
|
||||||
|
>
|
||||||
|
{showCheckboxes && (
|
||||||
|
<label className="flex items-center pl-2" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
ref={(element) => {
|
||||||
|
if (element) element.indeterminate = someChecked;
|
||||||
|
}}
|
||||||
|
onChange={() => onToggleCheck?.(node.path, node.kind)}
|
||||||
|
className="mr-2 accent-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left">
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
{node.kind === "dir" ? (
|
||||||
|
expanded ? (
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Folder className="h-3.5 w-3.5" />
|
||||||
|
)
|
||||||
|
) : FileIcon ? (
|
||||||
|
<FileIcon className="h-3.5 w-3.5" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-3 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||||
|
statusBadge[badge.status] ?? statusBadgeDefault,
|
||||||
|
)}
|
||||||
|
title={badge.tooltip}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.kind === "file" && renderFileExtra?.(node, allChecked)}
|
||||||
|
{node.kind === "dir" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50 max-[480px]:hidden"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onToggleDir(node.path);
|
||||||
|
}}
|
||||||
|
aria-label={expanded ? `Collapse ${node.name}` : `Expand ${node.name}`}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import { pluginsApi } from "@/api/plugins";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
|
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
|
||||||
|
|
||||||
export function InstanceSidebar() {
|
export function InstanceSidebar() {
|
||||||
const { data: plugins } = useQuery({
|
const { data: plugins } = useQuery({
|
||||||
|
|
@ -14,13 +13,12 @@ export function InstanceSidebar() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
|
||||||
<SidebarCompanyMenu />
|
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
|
||||||
</div>
|
<span className="flex-1 text-sm font-bold text-foreground truncate">
|
||||||
<div className="flex items-center gap-2 px-5 pb-3 shrink-0">
|
Instance Settings
|
||||||
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
|
</span>
|
||||||
<span className="flex-1 truncate text-sm font-bold text-foreground">Instance Settings</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
||||||
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCompanyState = vi.hoisted(() => ({
|
||||||
|
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
||||||
|
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}));
|
||||||
|
const mockPluginSlots = vi.hoisted(() => ({
|
||||||
|
slots: [] as Array<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||||
|
const mockPluginSlotContexts = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||||
let currentPathname = "/PAP/dashboard";
|
let currentPathname = "/PAP/dashboard";
|
||||||
|
|
||||||
vi.mock("@/lib/router", () => ({
|
vi.mock("@/lib/router", () => ({
|
||||||
|
|
@ -24,7 +34,10 @@ vi.mock("@/lib/router", () => ({
|
||||||
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
|
||||||
useNavigate: () => mockNavigate,
|
useNavigate: () => mockNavigate,
|
||||||
useNavigationType: () => "PUSH",
|
useNavigationType: () => "PUSH",
|
||||||
useParams: () => ({ companyPrefix: "PAP" }),
|
useParams: () => {
|
||||||
|
const firstSegment = currentPathname.split("/").filter(Boolean)[0];
|
||||||
|
return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" };
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./CompanyRail", () => ({
|
vi.mock("./CompanyRail", () => ({
|
||||||
|
|
@ -95,6 +108,33 @@ vi.mock("./SidebarAccountMenu", () => ({
|
||||||
SidebarAccountMenu: () => <div>Account menu</div>,
|
SidebarAccountMenu: () => <div>Account menu</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/slots", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../plugins/slots")>("../plugins/slots");
|
||||||
|
return {
|
||||||
|
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
||||||
|
usePluginSlots: (params: Record<string, unknown>) => {
|
||||||
|
mockUsePluginSlots(params);
|
||||||
|
return {
|
||||||
|
slots: mockPluginSlots.slots,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PluginSlotMount: ({
|
||||||
|
slot,
|
||||||
|
context,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
slot: { displayName: string };
|
||||||
|
context: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
mockPluginSlotContexts.push(context);
|
||||||
|
return <div data-plugin-slot-class={className}>Plugin route sidebar: {slot.displayName}</div>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../context/DialogContext", () => ({
|
vi.mock("../context/DialogContext", () => ({
|
||||||
useDialog: () => ({
|
useDialog: () => ({
|
||||||
openNewIssue: vi.fn(),
|
openNewIssue: vi.fn(),
|
||||||
|
|
@ -114,10 +154,10 @@ vi.mock("../context/PanelContext", () => ({
|
||||||
|
|
||||||
vi.mock("../context/CompanyContext", () => ({
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
useCompany: () => ({
|
useCompany: () => ({
|
||||||
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
companies: mockCompanyState.companies,
|
||||||
loading: false,
|
loading: false,
|
||||||
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
selectedCompany: mockCompanyState.selectedCompany,
|
||||||
selectedCompanyId: "company-1",
|
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
||||||
selectionSource: "manual",
|
selectionSource: "manual",
|
||||||
setSelectedCompanyId: mockSetSelectedCompanyId,
|
setSelectedCompanyId: mockSetSelectedCompanyId,
|
||||||
}),
|
}),
|
||||||
|
|
@ -179,6 +219,9 @@ describe("Layout", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
currentPathname = "/PAP/dashboard";
|
currentPathname = "/PAP/dashboard";
|
||||||
|
mockCompanyState.companies = [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }];
|
||||||
|
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||||
|
mockCompanyState.selectedCompanyId = "company-1";
|
||||||
mockHealthApi.get.mockResolvedValue({
|
mockHealthApi.get.mockResolvedValue({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
deploymentMode: "authenticated",
|
deploymentMode: "authenticated",
|
||||||
|
|
@ -188,6 +231,8 @@ describe("Layout", () => {
|
||||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||||
keyboardShortcuts: false,
|
keyboardShortcuts: false,
|
||||||
});
|
});
|
||||||
|
mockPluginSlots.slots = [];
|
||||||
|
mockPluginSlotContexts.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -227,6 +272,30 @@ describe("Layout", () => {
|
||||||
|
|
||||||
it("renders the company settings sidebar on company settings routes", async () => {
|
it("renders the company settings sidebar on company settings routes", async () => {
|
||||||
currentPathname = "/PAP/company/settings/access";
|
currentPathname = "/PAP/company/settings/access";
|
||||||
|
mockPluginSlots.slots = [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "company-page",
|
||||||
|
displayName: "Company Page",
|
||||||
|
exportName: "CompanyPage",
|
||||||
|
routePath: "company",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "fake-plugin",
|
||||||
|
pluginDisplayName: "Fake Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "company-sidebar",
|
||||||
|
displayName: "Company Route Sidebar",
|
||||||
|
exportName: "CompanySidebar",
|
||||||
|
routePath: "company",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "fake-plugin",
|
||||||
|
pluginDisplayName: "Fake Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
];
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false } },
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
|
@ -245,6 +314,213 @@ describe("Layout", () => {
|
||||||
expect(container.textContent).toContain("Company settings sidebar");
|
expect(container.textContent).toContain("Company settings sidebar");
|
||||||
expect(container.textContent).not.toContain("Instance sidebar");
|
expect(container.textContent).not.toContain("Instance sidebar");
|
||||||
expect(container.textContent).not.toContain("Main company nav");
|
expect(container.textContent).not.toContain("Main company nav");
|
||||||
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the instance settings sidebar on instance settings routes", async () => {
|
||||||
|
currentPathname = "/instance/settings/general";
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Layout />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Instance sidebar");
|
||||||
|
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||||
|
expect(container.textContent).not.toContain("Main company nav");
|
||||||
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
|
||||||
|
currentPathname = "/PAP/wiki";
|
||||||
|
mockPluginSlots.slots = [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page",
|
||||||
|
displayName: "Wiki Page",
|
||||||
|
exportName: "WikiPage",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin",
|
||||||
|
pluginDisplayName: "Wiki Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "wiki-route-sidebar",
|
||||||
|
displayName: "Wiki Sidebar",
|
||||||
|
exportName: "WikiSidebar",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin",
|
||||||
|
pluginDisplayName: "Wiki Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Layout />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Plugin route sidebar: Wiki Sidebar");
|
||||||
|
expect(container.querySelector("[data-plugin-slot-class='h-full w-full']")).not.toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Main company nav");
|
||||||
|
expect(container.textContent).not.toContain("Company settings sidebar");
|
||||||
|
expect(container.textContent).not.toContain("Instance sidebar");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the route company context for plugin route sidebars on the first render", async () => {
|
||||||
|
currentPathname = "/ALT/wiki";
|
||||||
|
mockCompanyState.companies = [
|
||||||
|
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
||||||
|
{ id: "company-2", issuePrefix: "ALT", name: "Alternate" },
|
||||||
|
];
|
||||||
|
mockCompanyState.selectedCompany = { id: "company-1", issuePrefix: "PAP", name: "Paperclip" };
|
||||||
|
mockCompanyState.selectedCompanyId = "company-1";
|
||||||
|
mockPluginSlots.slots = [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page",
|
||||||
|
displayName: "Wiki Page",
|
||||||
|
exportName: "WikiPage",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin",
|
||||||
|
pluginDisplayName: "Wiki Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "wiki-route-sidebar",
|
||||||
|
displayName: "Wiki Sidebar",
|
||||||
|
exportName: "WikiSidebar",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin",
|
||||||
|
pluginDisplayName: "Wiki Plugin",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Layout />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(mockUsePluginSlots).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
companyId: "company-2",
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockPluginSlotContexts).toContainEqual({
|
||||||
|
companyId: "company-2",
|
||||||
|
companyPrefix: "ALT",
|
||||||
|
});
|
||||||
|
expect(mockPluginSlotContexts).not.toContainEqual({
|
||||||
|
companyId: "company-1",
|
||||||
|
companyPrefix: "PAP",
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the normal company sidebar when a plugin page route is ambiguous", async () => {
|
||||||
|
currentPathname = "/PAP/wiki";
|
||||||
|
mockPluginSlots.slots = [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page-a",
|
||||||
|
displayName: "Wiki Page A",
|
||||||
|
exportName: "WikiPageA",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin-a",
|
||||||
|
pluginDisplayName: "Wiki Plugin A",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page-b",
|
||||||
|
displayName: "Wiki Page B",
|
||||||
|
exportName: "WikiPageB",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-2",
|
||||||
|
pluginKey: "wiki-plugin-b",
|
||||||
|
pluginDisplayName: "Wiki Plugin B",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "wiki-route-sidebar",
|
||||||
|
displayName: "Wiki Sidebar",
|
||||||
|
exportName: "WikiSidebar",
|
||||||
|
routePath: "wiki",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "wiki-plugin-a",
|
||||||
|
pluginDisplayName: "Wiki Plugin A",
|
||||||
|
pluginVersion: "1.0.0",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Layout />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Main company nav");
|
||||||
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||||
|
import { CompanyRail } from "./CompanyRail";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { InstanceSidebar } from "./InstanceSidebar";
|
import { InstanceSidebar } from "./InstanceSidebar";
|
||||||
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
||||||
|
|
@ -16,6 +17,7 @@ import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
|
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
||||||
|
|
@ -39,9 +41,18 @@ import { queryKeys } from "../lib/queryKeys";
|
||||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
|
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
|
||||||
|
|
||||||
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
const INSTANCE_SETTINGS_MEMORY_KEY = "paperclip.lastInstanceSettingsPath";
|
||||||
|
|
||||||
|
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
|
||||||
|
if (!companyPrefix) return null;
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
if (segments.length < 2) return null;
|
||||||
|
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null;
|
||||||
|
return segments[1]?.toLowerCase() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function readRememberedInstanceSettingsPath(): string {
|
function readRememberedInstanceSettingsPath(): string {
|
||||||
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
|
||||||
try {
|
try {
|
||||||
|
|
@ -83,6 +94,38 @@ export function Layout() {
|
||||||
}, [companies, companyPrefix]);
|
}, [companies, companyPrefix]);
|
||||||
const hasUnknownCompanyPrefix =
|
const hasUnknownCompanyPrefix =
|
||||||
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
||||||
|
const pluginRoutePath = useMemo(
|
||||||
|
() => getCompanyRouteSegment(location.pathname, companyPrefix),
|
||||||
|
[companyPrefix, location.pathname],
|
||||||
|
);
|
||||||
|
const routeSidebarCompanyId = matchedCompany?.id ?? null;
|
||||||
|
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
|
||||||
|
const { slots: routeSidebarSlots } = usePluginSlots({
|
||||||
|
slotTypes: ["page", "routeSidebar"],
|
||||||
|
companyId: routeSidebarCompanyId,
|
||||||
|
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
|
||||||
|
});
|
||||||
|
const routeSidebarSlot = useMemo(
|
||||||
|
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
|
||||||
|
[pluginRoutePath, routeSidebarSlots],
|
||||||
|
);
|
||||||
|
const sidebarContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
companyId: routeSidebarCompanyId,
|
||||||
|
companyPrefix: routeSidebarCompanyPrefix,
|
||||||
|
}),
|
||||||
|
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
||||||
|
);
|
||||||
|
const companySidebar = routeSidebarSlot ? (
|
||||||
|
<PluginSlotMount
|
||||||
|
slot={routeSidebarSlot}
|
||||||
|
context={sidebarContext}
|
||||||
|
className="h-full w-full"
|
||||||
|
missingBehavior="placeholder"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Sidebar />
|
||||||
|
);
|
||||||
const { data: health } = useQuery({
|
const { data: health } = useQuery({
|
||||||
queryKey: queryKeys.health,
|
queryKey: queryKeys.health,
|
||||||
queryFn: () => healthApi.get(),
|
queryFn: () => healthApi.get(),
|
||||||
|
|
@ -335,13 +378,16 @@ export function Layout() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
{isInstanceSettingsRoute ? (
|
<CompanyRail />
|
||||||
<InstanceSidebar />
|
<div className="w-60 shrink-0 overflow-hidden">
|
||||||
) : isCompanySettingsRoute ? (
|
{isInstanceSettingsRoute ? (
|
||||||
<CompanySettingsSidebar />
|
<InstanceSidebar />
|
||||||
) : (
|
) : isCompanySettingsRoute ? (
|
||||||
<Sidebar />
|
<CompanySettingsSidebar />
|
||||||
)}
|
) : (
|
||||||
|
companySidebar
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SidebarAccountMenu
|
<SidebarAccountMenu
|
||||||
deploymentMode={health?.deploymentMode}
|
deploymentMode={health?.deploymentMode}
|
||||||
|
|
@ -352,20 +398,16 @@ export function Layout() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col shrink-0">
|
<div className="flex h-full flex-col shrink-0">
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div
|
<CompanyRail />
|
||||||
className={cn(
|
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
|
||||||
"overflow-hidden transition-[width] duration-100 ease-out",
|
|
||||||
sidebarOpen ? "w-60" : "w-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isInstanceSettingsRoute ? (
|
{isInstanceSettingsRoute ? (
|
||||||
<InstanceSidebar />
|
<InstanceSidebar />
|
||||||
) : isCompanySettingsRoute ? (
|
) : isCompanySettingsRoute ? (
|
||||||
<CompanySettingsSidebar />
|
<CompanySettingsSidebar />
|
||||||
) : (
|
) : (
|
||||||
<Sidebar />
|
companySidebar
|
||||||
)}
|
)}
|
||||||
</div>
|
</ResizableSidebarPane>
|
||||||
</div>
|
</div>
|
||||||
<SidebarAccountMenu
|
<SidebarAccountMenu
|
||||||
deploymentMode={health?.deploymentMode}
|
deploymentMode={health?.deploymentMode}
|
||||||
|
|
|
||||||
180
ui/src/components/ManagedRoutinesList.tsx
Normal file
180
ui/src/components/ManagedRoutinesList.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
RoutineListRow,
|
||||||
|
type RoutineListAgentSummary,
|
||||||
|
type RoutineListProjectSummary,
|
||||||
|
type RoutineListRowItem,
|
||||||
|
} from "@/components/RoutineList";
|
||||||
|
|
||||||
|
export type ManagedRoutinesListAgent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedRoutinesListProject = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedRoutineMissingRef = {
|
||||||
|
resourceKind: string;
|
||||||
|
resourceKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedRoutinesListItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
routineId?: string | null;
|
||||||
|
href?: string | null;
|
||||||
|
resourceKey?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
assigneeAgentId?: string | null;
|
||||||
|
cronExpression?: string | null;
|
||||||
|
lastRunAt?: Date | string | null;
|
||||||
|
lastRunStatus?: string | null;
|
||||||
|
managedByPluginDisplayName?: string | null;
|
||||||
|
missingRefs?: ManagedRoutineMissingRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedRoutinesListProps = {
|
||||||
|
routines: ManagedRoutinesListItem[];
|
||||||
|
agents?: ManagedRoutinesListAgent[];
|
||||||
|
projects?: ManagedRoutinesListProject[];
|
||||||
|
pluginDisplayName?: string | null;
|
||||||
|
emptyMessage?: string;
|
||||||
|
runningRoutineKey?: string | null;
|
||||||
|
statusMutationRoutineKey?: string | null;
|
||||||
|
reconcilingRoutineKey?: string | null;
|
||||||
|
resettingRoutineKey?: string | null;
|
||||||
|
onRunNow?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void;
|
||||||
|
onReconcile?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
onReset?: (routine: ManagedRoutinesListItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function managedRoutineToRow(routine: ManagedRoutinesListItem): RoutineListRowItem {
|
||||||
|
return {
|
||||||
|
id: routine.key,
|
||||||
|
title: routine.title,
|
||||||
|
status: routine.status,
|
||||||
|
projectId: routine.projectId ?? null,
|
||||||
|
assigneeAgentId: routine.assigneeAgentId ?? null,
|
||||||
|
lastRun: routine.lastRunAt || routine.lastRunStatus
|
||||||
|
? {
|
||||||
|
triggeredAt: routine.lastRunAt ?? null,
|
||||||
|
status: routine.lastRunStatus ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagedRoutinesList({
|
||||||
|
routines,
|
||||||
|
agents = [],
|
||||||
|
projects = [],
|
||||||
|
pluginDisplayName = null,
|
||||||
|
emptyMessage = "No managed routines.",
|
||||||
|
runningRoutineKey = null,
|
||||||
|
statusMutationRoutineKey = null,
|
||||||
|
reconcilingRoutineKey = null,
|
||||||
|
resettingRoutineKey = null,
|
||||||
|
onRunNow,
|
||||||
|
onToggleEnabled,
|
||||||
|
onReconcile,
|
||||||
|
onReset,
|
||||||
|
}: ManagedRoutinesListProps) {
|
||||||
|
const agentById = new Map<string, RoutineListAgentSummary>(
|
||||||
|
agents.map((agent) => [agent.id, { name: agent.name, icon: agent.icon }]),
|
||||||
|
);
|
||||||
|
const projectById = new Map<string, RoutineListProjectSummary>(
|
||||||
|
projects.map((project) => [project.id, { name: project.name, color: project.color }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routines.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border">
|
||||||
|
{routines.map((routine) => {
|
||||||
|
const row = managedRoutineToRow(routine);
|
||||||
|
const href = routine.href ?? (routine.routineId ? `/routines/${routine.routineId}` : "/routines");
|
||||||
|
const missingRefs = routine.missingRefs ?? [];
|
||||||
|
const canUseRoutine = Boolean(routine.routineId && routine.resourceKey && missingRefs.length === 0);
|
||||||
|
const managedBy = routine.managedByPluginDisplayName ?? pluginDisplayName;
|
||||||
|
const hasRepairActions = Boolean(onReconcile || onReset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={routine.key} className="last:[&_a]:border-b-0">
|
||||||
|
<RoutineListRow
|
||||||
|
routine={row}
|
||||||
|
projectById={projectById}
|
||||||
|
agentById={agentById}
|
||||||
|
runningRoutineId={runningRoutineKey}
|
||||||
|
statusMutationRoutineId={statusMutationRoutineKey}
|
||||||
|
href={href}
|
||||||
|
configureLabel="Configure"
|
||||||
|
managedByLabel={managedBy ? `Managed by ${managedBy}` : null}
|
||||||
|
runNowButton
|
||||||
|
hideArchiveAction
|
||||||
|
disableRunNow={!canUseRoutine}
|
||||||
|
disableToggle={!canUseRoutine}
|
||||||
|
secondaryDetails={
|
||||||
|
<span className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
{routine.resourceKey ? <span>{routine.resourceKey}</span> : null}
|
||||||
|
{routine.cronExpression ? <span>Schedule {routine.cronExpression}</span> : null}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onRunNow={() => onRunNow?.(routine)}
|
||||||
|
onToggleEnabled={() => onToggleEnabled?.(routine, row.status === "active")}
|
||||||
|
/>
|
||||||
|
{hasRepairActions ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 pb-3 text-xs text-muted-foreground last:border-b-0"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{missingRefs.length
|
||||||
|
? `Missing ${missingRefs.map((ref) => `${ref.resourceKind}:${ref.resourceKey}`).join(", ")}`
|
||||||
|
: "Routine defaults can be repaired."}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{onReconcile ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={reconcilingRoutineKey === routine.key}
|
||||||
|
onClick={() => onReconcile(routine)}
|
||||||
|
>
|
||||||
|
{reconcilingRoutineKey === routine.key ? "Reconciling..." : "Reconcile"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{onReset ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={resettingRoutineKey === routine.key}
|
||||||
|
onClick={() => onReset(routine)}
|
||||||
|
>
|
||||||
|
{resettingRoutineKey === routine.key ? "Resetting..." : "Reset"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
@ -33,7 +33,11 @@ vi.mock("../api/issues", () => ({
|
||||||
issuesApi: mockIssuesApi,
|
issuesApi: mockIssuesApi,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string; title?: string }> = []) {
|
function renderMarkdown(
|
||||||
|
children: string,
|
||||||
|
seededIssues: Array<{ identifier: string; status: string; title?: string }> = [],
|
||||||
|
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
|
||||||
|
) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
|
@ -54,7 +58,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
|
||||||
return renderToStaticMarkup(
|
return renderToStaticMarkup(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MarkdownBody>{children}</MarkdownBody>
|
<MarkdownBody {...props}>{children}</MarkdownBody>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -279,6 +283,64 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="PAP-1271"');
|
expect(html).toContain('href="PAP-1271"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves wiki links as text unless explicitly enabled", () => {
|
||||||
|
const html = renderMarkdown("See [[wiki/entities/paperclip]].");
|
||||||
|
|
||||||
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||||
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders wiki links with a custom resolver when enabled", () => {
|
||||||
|
const html = renderMarkdown(
|
||||||
|
"See [[wiki/entities/paperclip|Paperclip]] and [[wiki/entities/dotta-b]].",
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
enableWikiLinks: true,
|
||||||
|
resolveWikiLinkHref: (target) => `/wiki/page/${target.endsWith(".md") ? target : `${target}.md`}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('href="/wiki/page/wiki/entities/paperclip.md"');
|
||||||
|
expect(html).toContain('data-paperclip-wiki-link="true"');
|
||||||
|
expect(html).toContain('data-paperclip-wiki-target="wiki/entities/paperclip"');
|
||||||
|
expect(html).toContain(">Paperclip</a>");
|
||||||
|
expect(html).toContain('href="/wiki/page/wiki/entities/dotta-b.md"');
|
||||||
|
expect(html).toContain(">wiki/entities/dotta-b</a>");
|
||||||
|
expect(html).not.toContain("[[wiki/entities/paperclip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps wiki links as text when the custom resolver rejects them", () => {
|
||||||
|
const html = renderMarkdown(
|
||||||
|
"See [[wiki/entities/paperclip]].",
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
enableWikiLinks: true,
|
||||||
|
wikiLinkRoot: "/wiki/page",
|
||||||
|
resolveWikiLinkHref: () => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||||
|
expect(html).not.toContain('data-paperclip-wiki-link="true"');
|
||||||
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render wiki links inside code spans or code blocks", () => {
|
||||||
|
const html = renderMarkdown(
|
||||||
|
"Inline `[[wiki/entities/paperclip]]`.\n\n```md\n[[wiki/entities/dotta-b]]\n```",
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
enableWikiLinks: true,
|
||||||
|
wikiLinkRoot: "/wiki/page",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("[[wiki/entities/paperclip]]");
|
||||||
|
expect(html).toContain("[[wiki/entities/dotta-b]]");
|
||||||
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
|
||||||
|
expect(html).not.toContain('href="/wiki/page/wiki/entities/dotta-b"');
|
||||||
|
});
|
||||||
|
|
||||||
it("applies wrap-friendly styles to long inline content", () => {
|
it("applies wrap-friendly styles to long inline content", () => {
|
||||||
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
|
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@ interface MarkdownBodyProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
softBreaks?: boolean;
|
softBreaks?: boolean;
|
||||||
linkIssueReferences?: boolean;
|
linkIssueReferences?: boolean;
|
||||||
|
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
||||||
|
enableWikiLinks?: boolean;
|
||||||
|
/** Base href used for wikilinks when no resolver is supplied. */
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
resolveImageSrc?: (src: string) => string | null;
|
||||||
/** Called when a user clicks an inline image */
|
/** Called when a user clicks an inline image */
|
||||||
|
|
@ -111,6 +117,160 @@ function safeMarkdownUrlTransform(url: string): string {
|
||||||
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MarkdownAstNode = {
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
children?: MarkdownAstNode[];
|
||||||
|
url?: string;
|
||||||
|
title?: string | null;
|
||||||
|
data?: {
|
||||||
|
hProperties?: Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedWikiLink = {
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g;
|
||||||
|
const WIKI_LINK_SKIP_PARENT_TYPES = new Set([
|
||||||
|
"definition",
|
||||||
|
"image",
|
||||||
|
"imageReference",
|
||||||
|
"link",
|
||||||
|
"linkReference",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseWikiLinkBody(body: string): ParsedWikiLink | null {
|
||||||
|
const [rawTarget, ...rawLabelParts] = body.split("|");
|
||||||
|
const target = rawTarget?.trim() ?? "";
|
||||||
|
const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target;
|
||||||
|
if (!target || target.includes("[") || target.includes("]")) return null;
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
label: label || target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeWikiLinkTarget(target: string): string | null {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
||||||
|
|
||||||
|
const hashIndex = trimmed.indexOf("#");
|
||||||
|
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/+/, "");
|
||||||
|
if (
|
||||||
|
!rawPath ||
|
||||||
|
rawPath.includes("\\") ||
|
||||||
|
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||||
|
const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : "";
|
||||||
|
return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null {
|
||||||
|
const encodedTarget = encodeWikiLinkTarget(target);
|
||||||
|
if (!encodedTarget) return null;
|
||||||
|
const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? "";
|
||||||
|
return root ? `${root}/${encodedTarget}` : encodedTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode {
|
||||||
|
return {
|
||||||
|
type: "link",
|
||||||
|
url: href,
|
||||||
|
title: null,
|
||||||
|
data: {
|
||||||
|
hProperties: {
|
||||||
|
"data-paperclip-wiki-link": "true",
|
||||||
|
"data-paperclip-wiki-target": wikiLink.target,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
children: [{ type: "text", value: wikiLink.label }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTextByWikiLinks(
|
||||||
|
value: string,
|
||||||
|
options: {
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
|
},
|
||||||
|
): MarkdownAstNode[] {
|
||||||
|
const nodes: MarkdownAstNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of value.matchAll(WIKI_LINK_PATTERN)) {
|
||||||
|
const raw = match[0] ?? "";
|
||||||
|
const body = match[1] ?? "";
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
if (start > lastIndex) {
|
||||||
|
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wikiLink = parseWikiLinkBody(body);
|
||||||
|
let resolvedHref: string | null = null;
|
||||||
|
if (wikiLink) {
|
||||||
|
if (options.resolveWikiLinkHref) {
|
||||||
|
const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label);
|
||||||
|
resolvedHref = customHref === undefined
|
||||||
|
? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot)
|
||||||
|
: customHref;
|
||||||
|
} else {
|
||||||
|
resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wikiLink && resolvedHref) {
|
||||||
|
nodes.push(createWikiLinkNode(resolvedHref, wikiLink));
|
||||||
|
} else {
|
||||||
|
nodes.push({ type: "text", value: raw });
|
||||||
|
}
|
||||||
|
lastIndex = start + raw.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < value.length) {
|
||||||
|
nodes.push({ type: "text", value: value.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformWikiLinkChildren(
|
||||||
|
node: MarkdownAstNode,
|
||||||
|
options: {
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return;
|
||||||
|
|
||||||
|
node.children = node.children.flatMap((child) => {
|
||||||
|
if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) {
|
||||||
|
return splitTextByWikiLinks(child.value, options);
|
||||||
|
}
|
||||||
|
transformWikiLinkChildren(child, options);
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemarkWikiLinks(options: {
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return function remarkWikiLinks() {
|
||||||
|
return (tree: MarkdownAstNode) => {
|
||||||
|
transformWikiLinkChildren(tree, options);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isGitHubUrl(href: string | null | undefined): boolean {
|
function isGitHubUrl(href: string | null | undefined): boolean {
|
||||||
if (!href) return false;
|
if (!href) return false;
|
||||||
try {
|
try {
|
||||||
|
|
@ -321,11 +481,17 @@ export function MarkdownBody({
|
||||||
style,
|
style,
|
||||||
softBreaks = true,
|
softBreaks = true,
|
||||||
linkIssueReferences = true,
|
linkIssueReferences = true,
|
||||||
|
enableWikiLinks = false,
|
||||||
|
wikiLinkRoot,
|
||||||
|
resolveWikiLinkHref,
|
||||||
resolveImageSrc,
|
resolveImageSrc,
|
||||||
onImageClick,
|
onImageClick,
|
||||||
}: MarkdownBodyProps) {
|
}: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
|
||||||
|
if (enableWikiLinks) {
|
||||||
|
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
||||||
|
}
|
||||||
if (linkIssueReferences) {
|
if (linkIssueReferences) {
|
||||||
remarkPlugins.push(remarkLinkIssueReferences);
|
remarkPlugins.push(remarkLinkIssueReferences);
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +536,22 @@ export function MarkdownBody({
|
||||||
{codeChildren}
|
{codeChildren}
|
||||||
</code>
|
</code>
|
||||||
),
|
),
|
||||||
a: ({ href, style: linkStyle, children: linkChildren }) => {
|
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
||||||
|
const dataProps = anchorProps as Record<string, unknown>;
|
||||||
|
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
||||||
|
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
{...anchorProps}
|
||||||
|
rel="noreferrer"
|
||||||
|
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||||
|
>
|
||||||
|
{linkChildren}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
||||||
if (issueRef) {
|
if (issueRef) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,326 +1,24 @@
|
||||||
import type { ReactNode } from "react";
|
import { FileTree } from "./FileTree";
|
||||||
import { cn } from "../lib/utils";
|
import type { FileTreeProps } from "./FileTree";
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
FileCode2,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
// ── Tree types ────────────────────────────────────────────────────────
|
export function PackageFileTree({ wrapLabels = false, ...props }: FileTreeProps) {
|
||||||
|
return <FileTree {...props} wrapLabels={wrapLabels} />;
|
||||||
export type FileTreeNode = {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
kind: "dir" | "file";
|
|
||||||
children: FileTreeNode[];
|
|
||||||
/** Optional per-node metadata (e.g. import action) */
|
|
||||||
action?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TREE_BASE_INDENT = 16;
|
|
||||||
const TREE_STEP_INDENT = 24;
|
|
||||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function buildFileTree(
|
|
||||||
files: Record<string, unknown>,
|
|
||||||
actionMap?: Map<string, string>,
|
|
||||||
): FileTreeNode[] {
|
|
||||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
|
||||||
|
|
||||||
for (const filePath of Object.keys(files)) {
|
|
||||||
const segments = filePath.split("/").filter(Boolean);
|
|
||||||
let current = root;
|
|
||||||
let currentPath = "";
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const segment = segments[i];
|
|
||||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
||||||
const isLeaf = i === segments.length - 1;
|
|
||||||
let next = current.children.find((c) => c.name === segment);
|
|
||||||
if (!next) {
|
|
||||||
next = {
|
|
||||||
name: segment,
|
|
||||||
path: currentPath,
|
|
||||||
kind: isLeaf ? "file" : "dir",
|
|
||||||
children: [],
|
|
||||||
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
|
||||||
};
|
|
||||||
current.children.push(next);
|
|
||||||
}
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNode(node: FileTreeNode) {
|
|
||||||
node.children.sort((a, b) => {
|
|
||||||
// Files before directories so PROJECT.md appears above tasks/
|
|
||||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
node.children.forEach(sortNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNode(root);
|
|
||||||
return root.children;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countFiles(nodes: FileTreeNode[]): number {
|
export {
|
||||||
let count = 0;
|
FRONTMATTER_FIELD_LABELS,
|
||||||
for (const node of nodes) {
|
buildFileTree,
|
||||||
if (node.kind === "file") count++;
|
collectAllPaths,
|
||||||
else count += countFiles(node.children);
|
countFiles,
|
||||||
}
|
parseFrontmatter,
|
||||||
return count;
|
} from "./FileTree";
|
||||||
}
|
export type {
|
||||||
|
FileTreeBadge,
|
||||||
export function collectAllPaths(
|
FileTreeBadgeVariant,
|
||||||
nodes: FileTreeNode[],
|
FileTreeEmptyState,
|
||||||
type: "file" | "dir" | "all" = "all",
|
FileTreeErrorState,
|
||||||
): Set<string> {
|
FileTreeNode,
|
||||||
const paths = new Set<string>();
|
FileTreeProps,
|
||||||
for (const node of nodes) {
|
FileTreeTone,
|
||||||
if (type === "all" || node.kind === type) paths.add(node.path);
|
FrontmatterData,
|
||||||
for (const p of collectAllPaths(node.children, type)) paths.add(p);
|
} from "./FileTree";
|
||||||
}
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileIcon(name: string) {
|
|
||||||
if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2;
|
|
||||||
return FileText;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Frontmatter helpers ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type FrontmatterData = Record<string, string | string[]>;
|
|
||||||
|
|
||||||
export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null {
|
|
||||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const data: FrontmatterData = {};
|
|
||||||
const rawYaml = match[1];
|
|
||||||
const body = match[2];
|
|
||||||
|
|
||||||
let currentKey: string | null = null;
|
|
||||||
let currentList: string[] | null = null;
|
|
||||||
|
|
||||||
for (const line of rawYaml.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
|
|
||||||
if (trimmed.startsWith("- ") && currentKey) {
|
|
||||||
if (!currentList) currentList = [];
|
|
||||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentKey && currentList) {
|
|
||||||
data[currentKey] = currentList;
|
|
||||||
currentList = null;
|
|
||||||
currentKey = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
|
|
||||||
if (kvMatch) {
|
|
||||||
const key = kvMatch[1];
|
|
||||||
const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
|
|
||||||
if (val === "null") {
|
|
||||||
currentKey = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (val) {
|
|
||||||
data[key] = val;
|
|
||||||
currentKey = null;
|
|
||||||
} else {
|
|
||||||
currentKey = key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentKey && currentList) {
|
|
||||||
data[currentKey] = currentList;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(data).length > 0 ? { data, body } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
|
||||||
name: "Name",
|
|
||||||
title: "Title",
|
|
||||||
kind: "Kind",
|
|
||||||
reportsTo: "Reports to",
|
|
||||||
skills: "Skills",
|
|
||||||
status: "Status",
|
|
||||||
description: "Description",
|
|
||||||
priority: "Priority",
|
|
||||||
assignee: "Assignee",
|
|
||||||
project: "Project",
|
|
||||||
recurring: "Recurring",
|
|
||||||
targetDate: "Target date",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── File tree component ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function PackageFileTree({
|
|
||||||
nodes,
|
|
||||||
selectedFile,
|
|
||||||
expandedDirs,
|
|
||||||
checkedFiles,
|
|
||||||
onToggleDir,
|
|
||||||
onSelectFile,
|
|
||||||
onToggleCheck,
|
|
||||||
renderFileExtra,
|
|
||||||
fileRowClassName,
|
|
||||||
showCheckboxes = true,
|
|
||||||
wrapLabels = false,
|
|
||||||
depth = 0,
|
|
||||||
}: {
|
|
||||||
nodes: FileTreeNode[];
|
|
||||||
selectedFile: string | null;
|
|
||||||
expandedDirs: Set<string>;
|
|
||||||
checkedFiles?: Set<string>;
|
|
||||||
onToggleDir: (path: string) => void;
|
|
||||||
onSelectFile: (path: string) => void;
|
|
||||||
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
|
|
||||||
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
|
|
||||||
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
|
||||||
/** Optional additional className for file rows */
|
|
||||||
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
|
||||||
showCheckboxes?: boolean;
|
|
||||||
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
|
|
||||||
wrapLabels?: boolean;
|
|
||||||
depth?: number;
|
|
||||||
}) {
|
|
||||||
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{nodes.map((node) => {
|
|
||||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
|
||||||
if (node.kind === "dir") {
|
|
||||||
const childFiles = collectAllPaths(node.children, "file");
|
|
||||||
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
|
|
||||||
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
|
|
||||||
return (
|
|
||||||
<div key={node.path}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
showCheckboxes
|
|
||||||
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
|
|
||||||
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showCheckboxes && (
|
|
||||||
<label className="flex items-center pl-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allChecked}
|
|
||||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
|
||||||
onChange={() => onToggleCheck?.(node.path, "dir")}
|
|
||||||
className="mr-2 accent-foreground"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 items-center gap-2 py-1 text-left"
|
|
||||||
onClick={() => onToggleDir(node.path)}
|
|
||||||
>
|
|
||||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
||||||
{expanded ? (
|
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Folder className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
|
||||||
{node.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
|
||||||
onClick={() => onToggleDir(node.path)}
|
|
||||||
>
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<PackageFileTree
|
|
||||||
nodes={node.children}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
expandedDirs={expandedDirs}
|
|
||||||
checkedFiles={effectiveCheckedFiles}
|
|
||||||
onToggleDir={onToggleDir}
|
|
||||||
onSelectFile={onSelectFile}
|
|
||||||
onToggleCheck={onToggleCheck}
|
|
||||||
renderFileExtra={renderFileExtra}
|
|
||||||
fileRowClassName={fileRowClassName}
|
|
||||||
showCheckboxes={showCheckboxes}
|
|
||||||
wrapLabels={wrapLabels}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileIcon = fileIcon(node.name);
|
|
||||||
const checked = effectiveCheckedFiles.has(node.path);
|
|
||||||
const extraClassName = fileRowClassName?.(node, checked);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={node.path}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
|
||||||
node.path === selectedFile && "text-foreground bg-accent/20",
|
|
||||||
extraClassName,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
|
||||||
}}
|
|
||||||
onClick={() => onSelectFile(node.path)}
|
|
||||||
>
|
|
||||||
{showCheckboxes && (
|
|
||||||
<label className="flex items-center pl-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => onToggleCheck?.(node.path, "file")}
|
|
||||||
className="mr-2 accent-foreground"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
|
||||||
onClick={() => onSelectFile(node.path)}
|
|
||||||
>
|
|
||||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
||||||
<FileIcon className="h-3.5 w-3.5" />
|
|
||||||
</span>
|
|
||||||
<span className={cn("min-w-0", wrapLabels ? "break-all leading-4" : "truncate")}>
|
|
||||||
{node.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{renderFileExtra?.(node, checked)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
121
ui/src/components/ResizableSidebarPane.test.tsx
Normal file
121
ui/src/components/ResizableSidebarPane.test.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function pointerEvent(type: string, clientX: number) {
|
||||||
|
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||||
|
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ResizableSidebarPane", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pane() {
|
||||||
|
return container.firstElementChild as HTMLDivElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle() {
|
||||||
|
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("uses a persisted width when open", () => {
|
||||||
|
window.localStorage.setItem("test.sidebar.width", "320");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||||
|
<div>Sidebar</div>
|
||||||
|
</ResizableSidebarPane>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pane().style.width).toBe("320px");
|
||||||
|
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes by dragging and persists the new width", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||||
|
<div>Sidebar</div>
|
||||||
|
</ResizableSidebarPane>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const separator = handle();
|
||||||
|
expect(separator).not.toBeNull();
|
||||||
|
separator!.setPointerCapture = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
|
||||||
|
separator!.dispatchEvent(pointerEvent("pointermove", 320));
|
||||||
|
separator!.dispatchEvent(pointerEvent("pointerup", 320));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pane().style.width).toBe("320px");
|
||||||
|
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports keyboard resizing and clamps to the configured bounds", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ResizableSidebarPane open resizable storageKey="test.sidebar.width">
|
||||||
|
<div>Sidebar</div>
|
||||||
|
</ResizableSidebarPane>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const separator = handle();
|
||||||
|
act(() => {
|
||||||
|
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(pane().style.width).toBe("256px");
|
||||||
|
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(pane().style.width).toBe("208px");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(pane().style.width).toBe("420px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can render without a resize handle", () => {
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<ResizableSidebarPane open resizable={false}>
|
||||||
|
<div>Sidebar</div>
|
||||||
|
</ResizableSidebarPane>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handle()).toBeNull();
|
||||||
|
expect(pane().style.width).toBe("240px");
|
||||||
|
});
|
||||||
|
});
|
||||||
176
ui/src/components/ResizableSidebarPane.tsx
Normal file
176
ui/src/components/ResizableSidebarPane.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||||
|
const MIN_SIDEBAR_WIDTH = 208;
|
||||||
|
const MAX_SIDEBAR_WIDTH = 420;
|
||||||
|
const SIDEBAR_WIDTH_STEP = 16;
|
||||||
|
|
||||||
|
function clampSidebarWidth(width: number) {
|
||||||
|
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSidebarWidth(storageKey: string) {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(storageKey);
|
||||||
|
if (!stored) return DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
const parsed = Number.parseInt(stored, 10);
|
||||||
|
if (!Number.isFinite(parsed)) return DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
return clampSidebarWidth(parsed);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredSidebarWidth(storageKey: string, width: number) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, String(clampSidebarWidth(width)));
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private contexts; resizing should still work.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResizableSidebarPaneProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
open: boolean;
|
||||||
|
resizable?: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResizableSidebarPane({
|
||||||
|
children,
|
||||||
|
open,
|
||||||
|
resizable = false,
|
||||||
|
storageKey = "paperclip.sidebar.width",
|
||||||
|
className,
|
||||||
|
}: ResizableSidebarPaneProps) {
|
||||||
|
const [width, setWidth] = useState(() => readStoredSidebarWidth(storageKey));
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const widthRef = useRef(width);
|
||||||
|
const dragState = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedWidth = readStoredSidebarWidth(storageKey);
|
||||||
|
widthRef.current = storedWidth;
|
||||||
|
setWidth(storedWidth);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const visibleWidth = open ? width : 0;
|
||||||
|
const paneStyle = useMemo(
|
||||||
|
() => ({ width: `${visibleWidth}px` }),
|
||||||
|
[visibleWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitWidth = useCallback(
|
||||||
|
(nextWidth: number) => {
|
||||||
|
const clamped = clampSidebarWidth(nextWidth);
|
||||||
|
widthRef.current = clamped;
|
||||||
|
setWidth(clamped);
|
||||||
|
writeStoredSidebarWidth(storageKey, clamped);
|
||||||
|
},
|
||||||
|
[storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!open || !resizable) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragState.current = { startX: event.clientX, startWidth: widthRef.current };
|
||||||
|
setIsResizing(true);
|
||||||
|
},
|
||||||
|
[open, resizable],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragState.current) return;
|
||||||
|
|
||||||
|
const nextWidth = dragState.current.startWidth + event.clientX - dragState.current.startX;
|
||||||
|
const clamped = clampSidebarWidth(nextWidth);
|
||||||
|
widthRef.current = clamped;
|
||||||
|
setWidth(clamped);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endResize = useCallback(() => {
|
||||||
|
if (!dragState.current) return;
|
||||||
|
|
||||||
|
dragState.current = null;
|
||||||
|
setIsResizing(false);
|
||||||
|
writeStoredSidebarWidth(storageKey, widthRef.current);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (!open || !resizable) return;
|
||||||
|
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitWidth(width - SIDEBAR_WIDTH_STEP);
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitWidth(width + SIDEBAR_WIDTH_STEP);
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitWidth(MIN_SIDEBAR_WIDTH);
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitWidth(MAX_SIDEBAR_WIDTH);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitWidth, open, resizable, width],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden",
|
||||||
|
!isResizing && "transition-[width] duration-100 ease-out",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={paneStyle}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{resizable && open ? (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize sidebar"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemin={MIN_SIDEBAR_WIDTH}
|
||||||
|
aria-valuemax={MAX_SIDEBAR_WIDTH}
|
||||||
|
aria-valuenow={width}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 right-0 z-20 w-3 cursor-col-resize touch-none outline-none",
|
||||||
|
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
|
||||||
|
"hover:before:bg-border focus-visible:before:bg-ring",
|
||||||
|
isResizing && "before:bg-ring",
|
||||||
|
)}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endResize}
|
||||||
|
onPointerCancel={endResize}
|
||||||
|
onLostPointerCapture={endResize}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
ui/src/components/RoutineList.tsx
Normal file
196
ui/src/components/RoutineList.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MoreHorizontal, Play } from "lucide-react";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
|
|
||||||
|
export type RoutineListProjectSummary = {
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoutineListAgentSummary = {
|
||||||
|
name: string;
|
||||||
|
icon?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoutineListRowItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
projectId: string | null;
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
lastRun?: {
|
||||||
|
triggeredAt?: Date | string | null;
|
||||||
|
status?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
||||||
|
if (!value) return "Never";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRoutineRunStatus(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||||
|
if (currentStatus === "archived" && enabled) return "active";
|
||||||
|
return enabled ? "active" : "paused";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||||
|
routine,
|
||||||
|
projectById,
|
||||||
|
agentById,
|
||||||
|
runningRoutineId,
|
||||||
|
statusMutationRoutineId,
|
||||||
|
href,
|
||||||
|
configureLabel = "Edit",
|
||||||
|
managedByLabel,
|
||||||
|
secondaryDetails,
|
||||||
|
runNowButton = false,
|
||||||
|
disableRunNow = false,
|
||||||
|
disableToggle = false,
|
||||||
|
hideArchiveAction = false,
|
||||||
|
onRunNow,
|
||||||
|
onToggleEnabled,
|
||||||
|
onToggleArchived,
|
||||||
|
}: {
|
||||||
|
routine: TRoutine;
|
||||||
|
projectById: Map<string, RoutineListProjectSummary>;
|
||||||
|
agentById: Map<string, RoutineListAgentSummary>;
|
||||||
|
runningRoutineId: string | null;
|
||||||
|
statusMutationRoutineId: string | null;
|
||||||
|
href: string;
|
||||||
|
configureLabel?: string;
|
||||||
|
managedByLabel?: string | null;
|
||||||
|
secondaryDetails?: ReactNode;
|
||||||
|
runNowButton?: boolean;
|
||||||
|
disableRunNow?: boolean;
|
||||||
|
disableToggle?: boolean;
|
||||||
|
hideArchiveAction?: boolean;
|
||||||
|
onRunNow: (routine: TRoutine) => void;
|
||||||
|
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
|
||||||
|
onToggleArchived?: (routine: TRoutine) => void;
|
||||||
|
}) {
|
||||||
|
const enabled = routine.status === "active";
|
||||||
|
const isArchived = routine.status === "archived";
|
||||||
|
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||||
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
|
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||||
|
const isDraft = !isArchived && !routine.assigneeAgentId;
|
||||||
|
const runDisabled = runningRoutineId === routine.id || isArchived || disableRunNow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||||
|
{(isArchived || routine.status === "paused" || isDraft) ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{managedByLabel ? (
|
||||||
|
<span className="text-xs text-muted-foreground">{managedByLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||||
|
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||||
|
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{secondaryDetails ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{secondaryDetails}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||||
|
{runNowButton ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={runDisabled}
|
||||||
|
onClick={() => onRunNow(routine)}
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
size="lg"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived || disableToggle}
|
||||||
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={href}>{configureLabel}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={runDisabled}
|
||||||
|
onClick={() => onRunNow(routine)}
|
||||||
|
>
|
||||||
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived || disableToggle}
|
||||||
|
>
|
||||||
|
{enabled ? "Pause" : "Enable"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!hideArchiveAction && onToggleArchived ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleArchived(routine)}
|
||||||
|
disabled={isStatusPending}
|
||||||
|
>
|
||||||
|
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ export function Sidebar() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||||
<SidebarCompanyMenu />
|
<SidebarCompanyMenu />
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export const queryKeys = {
|
||||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||||
listByProject: (companyId: string, projectId: string) =>
|
listByProject: (companyId: string, projectId: string) =>
|
||||||
["issues", companyId, "project", projectId] as const,
|
["issues", companyId, "project", projectId] as const,
|
||||||
|
listPluginOperationsByProject: (companyId: string, projectId: string, originKindPrefix: string) =>
|
||||||
|
["issues", companyId, "project", projectId, "plugin-operations", originKindPrefix] as const,
|
||||||
listByParent: (companyId: string, parentId: string) =>
|
listByParent: (companyId: string, parentId: string) =>
|
||||||
["issues", companyId, "parent", parentId] as const,
|
["issues", companyId, "parent", parentId] as const,
|
||||||
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
|
listByDescendantRoot: (companyId: string, rootIssueId: string) =>
|
||||||
|
|
@ -171,6 +173,8 @@ export const queryKeys = {
|
||||||
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
health: (pluginId: string) => ["plugins", pluginId, "health"] as const,
|
||||||
uiContributions: ["plugins", "ui-contributions"] as const,
|
uiContributions: ["plugins", "ui-contributions"] as const,
|
||||||
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
config: (pluginId: string) => ["plugins", pluginId, "config"] as const,
|
||||||
|
localFolders: (pluginId: string, companyId: string) =>
|
||||||
|
["plugins", pluginId, "companies", companyId, "local-folders"] as const,
|
||||||
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
|
||||||
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ export const statusBadge: Record<string, string> = {
|
||||||
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||||
timed_out: "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300",
|
timed_out: "bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300",
|
||||||
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||||
|
ok: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||||
|
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||||
error: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
error: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||||
|
info: "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
|
||||||
terminated: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
terminated: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||||
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300",
|
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300",
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import { Identity } from "../components/Identity";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
|
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -2276,7 +2276,7 @@ function PromptsTab({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PackageFileTree
|
<FileTree
|
||||||
nodes={fileTree}
|
nodes={fileTree}
|
||||||
selectedFile={selectedOrEntryFile}
|
selectedFile={selectedOrEntryFile}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ import {
|
||||||
collectAllPaths,
|
collectAllPaths,
|
||||||
parseFrontmatter,
|
parseFrontmatter,
|
||||||
FRONTMATTER_FIELD_LABELS,
|
FRONTMATTER_FIELD_LABELS,
|
||||||
PackageFileTree,
|
FileTree,
|
||||||
} from "../components/PackageFileTree";
|
} from "../components/FileTree";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the set of agent/project/task slugs that are "checked" based on
|
* Extract the set of agent/project/task slugs that are "checked" based on
|
||||||
|
|
@ -988,7 +988,7 @@ export function CompanyExport() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<PackageFileTree
|
<FileTree
|
||||||
nodes={displayTree}
|
nodes={displayTree}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
|
|
@ -996,6 +996,7 @@ export function CompanyExport() {
|
||||||
onToggleDir={handleToggleDir}
|
onToggleDir={handleToggleDir}
|
||||||
onSelectFile={selectFile}
|
onSelectFile={selectFile}
|
||||||
onToggleCheck={handleToggleCheck}
|
onToggleCheck={handleToggleCheck}
|
||||||
|
wrapLabels={false}
|
||||||
/>
|
/>
|
||||||
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
|
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
|
||||||
<div className="px-4 py-2">
|
<div className="px-4 py-2">
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ import {
|
||||||
collectAllPaths,
|
collectAllPaths,
|
||||||
parseFrontmatter,
|
parseFrontmatter,
|
||||||
FRONTMATTER_FIELD_LABELS,
|
FRONTMATTER_FIELD_LABELS,
|
||||||
PackageFileTree,
|
FileTree,
|
||||||
} from "../components/PackageFileTree";
|
} from "../components/FileTree";
|
||||||
import { readZipArchive } from "../lib/zip";
|
import { readZipArchive } from "../lib/zip";
|
||||||
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||||
|
|
||||||
|
|
@ -1325,7 +1325,7 @@ export function CompanyImport() {
|
||||||
<h2 className="text-base font-semibold">Package files</h2>
|
<h2 className="text-base font-semibold">Package files</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<PackageFileTree
|
<FileTree
|
||||||
nodes={tree}
|
nodes={tree}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
|
|
@ -1335,6 +1335,7 @@ export function CompanyImport() {
|
||||||
onToggleCheck={handleToggleCheck}
|
onToggleCheck={handleToggleCheck}
|
||||||
renderFileExtra={(node, checked) => renderImportFileExtra(node, checked, renameMap)}
|
renderFileExtra={(node, checked) => renderImportFileExtra(node, checked, renameMap)}
|
||||||
fileRowClassName={importFileRowClassName}
|
fileRowClassName={importFileRowClassName}
|
||||||
|
wrapLabels={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
207
ui/src/pages/PluginPage.test.tsx
Normal file
207
ui/src/pages/PluginPage.test.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PluginPage } from "./PluginPage";
|
||||||
|
|
||||||
|
const mockPluginsApi = vi.hoisted(() => ({
|
||||||
|
listUiContributions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||||
|
const mockParams = vi.hoisted(() => ({
|
||||||
|
companyPrefix: "PAP" as string | undefined,
|
||||||
|
pluginId: undefined as string | undefined,
|
||||||
|
pluginRoutePath: undefined as string | undefined,
|
||||||
|
"*": undefined as string | undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/plugins", () => ({
|
||||||
|
pluginsApi: mockPluginsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/context/BreadcrumbContext", () => ({
|
||||||
|
useBreadcrumbs: () => ({
|
||||||
|
setBreadcrumbs: mockSetBreadcrumbs,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||||
|
Navigate: () => null,
|
||||||
|
useParams: () => mockParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/plugins/slots", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/plugins/slots")>("@/plugins/slots");
|
||||||
|
return {
|
||||||
|
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
||||||
|
PluginSlotMount: ({ slot }: { slot: { displayName: string } }) => (
|
||||||
|
<div data-testid="plugin-slot-mount">{slot.displayName}</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function flushReact() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageContribution(overrides: Partial<{ slots: unknown[] }> = {}) {
|
||||||
|
return {
|
||||||
|
pluginId: "plugin-wiki",
|
||||||
|
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||||
|
displayName: "LLM Wiki",
|
||||||
|
version: "0.1.0",
|
||||||
|
uiEntryFile: "ui.js",
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page",
|
||||||
|
displayName: "Wiki",
|
||||||
|
exportName: "WikiPage",
|
||||||
|
routePath: "wiki",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
launchers: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(container: HTMLDivElement) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PluginPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PluginPage", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockParams.companyPrefix = "PAP";
|
||||||
|
mockParams.pluginId = undefined;
|
||||||
|
mockParams.pluginRoutePath = undefined;
|
||||||
|
mockParams["*"] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the breadcrumb and Back button on a legacy plugin route (no routeSidebar)", async () => {
|
||||||
|
mockParams.pluginRoutePath = "wiki";
|
||||||
|
mockPluginsApi.listUiContributions.mockResolvedValue([pageContribution()]);
|
||||||
|
|
||||||
|
const root = await renderPage(container);
|
||||||
|
|
||||||
|
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
|
||||||
|
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||||
|
{ label: "LLM Wiki" },
|
||||||
|
]);
|
||||||
|
expect(container.textContent).toContain("Back");
|
||||||
|
expect(container.querySelector('a[href="/PAP/dashboard"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a route title and hides the Back button when a routeSidebar matches the active route", async () => {
|
||||||
|
mockParams.pluginRoutePath = "wiki";
|
||||||
|
mockPluginsApi.listUiContributions.mockResolvedValue([
|
||||||
|
pageContribution({
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page",
|
||||||
|
displayName: "Wiki",
|
||||||
|
exportName: "WikiPage",
|
||||||
|
routePath: "wiki",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "wiki-sidebar",
|
||||||
|
displayName: "Wiki Sidebar",
|
||||||
|
exportName: "WikiRouteSidebar",
|
||||||
|
routePath: "wiki",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const root = await renderPage(container);
|
||||||
|
|
||||||
|
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "Wiki" }]);
|
||||||
|
expect(container.textContent).not.toContain("Back");
|
||||||
|
expect(container.querySelector('a[href="/PAP/dashboard"]')).toBeNull();
|
||||||
|
// Page slot itself still renders.
|
||||||
|
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe("Wiki");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the selected plugin page path as the route-sidebar title", async () => {
|
||||||
|
mockParams.pluginRoutePath = "wiki";
|
||||||
|
mockParams["*"] = "page/templates%3A%3Aindex.md";
|
||||||
|
mockPluginsApi.listUiContributions.mockResolvedValue([
|
||||||
|
pageContribution({
|
||||||
|
slots: [
|
||||||
|
{
|
||||||
|
type: "page",
|
||||||
|
id: "wiki-page",
|
||||||
|
displayName: "Wiki",
|
||||||
|
exportName: "WikiPage",
|
||||||
|
routePath: "wiki",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "routeSidebar",
|
||||||
|
id: "wiki-sidebar",
|
||||||
|
displayName: "Wiki Sidebar",
|
||||||
|
exportName: "WikiRouteSidebar",
|
||||||
|
routePath: "wiki",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const root = await renderPage(container);
|
||||||
|
|
||||||
|
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "index" }]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,11 @@ import { useCompany } from "@/context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||||
import { pluginsApi } from "@/api/plugins";
|
import { pluginsApi } from "@/api/plugins";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { PluginSlotMount } from "@/plugins/slots";
|
import {
|
||||||
|
PluginSlotMount,
|
||||||
|
resolveRouteSidebarSlot,
|
||||||
|
type ResolvedPluginSlot,
|
||||||
|
} from "@/plugins/slots";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { NotFoundPage } from "./NotFound";
|
import { NotFoundPage } from "./NotFound";
|
||||||
|
|
@ -19,11 +23,14 @@ import { NotFoundPage } from "./NotFound";
|
||||||
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
|
||||||
*/
|
*/
|
||||||
export function PluginPage() {
|
export function PluginPage() {
|
||||||
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
|
const params = useParams<{
|
||||||
companyPrefix?: string;
|
companyPrefix?: string;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
pluginRoutePath?: string;
|
pluginRoutePath?: string;
|
||||||
|
"*": string | undefined;
|
||||||
}>();
|
}>();
|
||||||
|
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = params;
|
||||||
|
const pluginRouteSplat = params["*"];
|
||||||
const { companies, selectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const routeCompany = useMemo(() => {
|
const routeCompany = useMemo(() => {
|
||||||
|
|
@ -89,14 +96,33 @@ export function PluginPage() {
|
||||||
[resolvedCompanyId, companyPrefix],
|
[resolvedCompanyId, companyPrefix],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When the active route has a routeSidebar slot, the sidebar provides the
|
||||||
|
// back affordance, but the top bar still needs a route-specific title.
|
||||||
|
const routeSidebarActive = useMemo(() => {
|
||||||
|
if (!pluginRoutePath || !contributions) return false;
|
||||||
|
const flattened: ResolvedPluginSlot[] = contributions.flatMap((contribution) =>
|
||||||
|
contribution.slots.map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
pluginId: contribution.pluginId,
|
||||||
|
pluginKey: contribution.pluginKey,
|
||||||
|
pluginDisplayName: contribution.displayName,
|
||||||
|
pluginVersion: contribution.version,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return resolveRouteSidebarSlot(flattened, pluginRoutePath) !== null;
|
||||||
|
}, [contributions, pluginRoutePath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageSlot) {
|
if (!pageSlot) return;
|
||||||
setBreadcrumbs([
|
if (routeSidebarActive) {
|
||||||
{ label: "Plugins", href: "/instance/settings/plugins" },
|
setBreadcrumbs([{ label: resolveRouteSidebarPageTitle(pageSlot, pluginRouteSplat) }]);
|
||||||
{ label: pageSlot.pluginDisplayName },
|
return;
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}, [pageSlot, companyPrefix, setBreadcrumbs]);
|
setBreadcrumbs([
|
||||||
|
{ label: "Plugins", href: "/instance/settings/plugins" },
|
||||||
|
{ label: pageSlot.pluginDisplayName },
|
||||||
|
]);
|
||||||
|
}, [pageSlot, pluginRouteSplat, setBreadcrumbs, routeSidebarActive]);
|
||||||
|
|
||||||
if (!resolvedCompanyId) {
|
if (!resolvedCompanyId) {
|
||||||
if (hasInvalidCompanyPrefix) {
|
if (hasInvalidCompanyPrefix) {
|
||||||
|
|
@ -137,14 +163,16 @@ export function PluginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
{!routeSidebarActive && (
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
|
||||||
Back
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
</Link>
|
Back
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<PluginSlotMount
|
<PluginSlotMount
|
||||||
slot={pageSlot}
|
slot={pageSlot}
|
||||||
context={context}
|
context={context}
|
||||||
|
|
@ -154,3 +182,42 @@ export function PluginPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRouteSidebarPageTitle(pageSlot: ResolvedPluginSlot, routeSplat: string | undefined): string {
|
||||||
|
const title = titleFromRouteSplat(routeSplat);
|
||||||
|
return title ?? pageSlot.displayName ?? pageSlot.pluginDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromRouteSplat(routeSplat: string | undefined): string | null {
|
||||||
|
const segments = (routeSplat ?? "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(decodeRouteSegment);
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
|
||||||
|
if (segments[0] === "page" && segments.length > 1) {
|
||||||
|
return titleFromPath(segments.slice(1).join("/"), { preserveCase: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return titleFromPath(segments[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromPath(path: string | null | undefined, options: { preserveCase?: boolean } = {}): string | null {
|
||||||
|
const trimmed = path?.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const basename = trimmed.split("/").filter(Boolean).at(-1) ?? trimmed;
|
||||||
|
const withoutNamespace = basename.split("::").at(-1) ?? basename;
|
||||||
|
const withoutExtension = withoutNamespace.replace(/\.[^.]+$/, "");
|
||||||
|
const normalized = withoutExtension.replace(/[-_]+/g, " ").trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (options.preserveCase) return normalized;
|
||||||
|
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRouteSegment(segment: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(segment);
|
||||||
|
} catch {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ const mockPluginsApi = vi.hoisted(() => ({
|
||||||
dashboard: vi.fn(),
|
dashboard: vi.fn(),
|
||||||
logs: vi.fn(),
|
logs: vi.fn(),
|
||||||
getConfig: vi.fn(),
|
getConfig: vi.fn(),
|
||||||
|
listLocalFolders: vi.fn(),
|
||||||
|
configureLocalFolder: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -58,6 +60,82 @@ async function flushReact() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function basePlugin(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "plugin-1",
|
||||||
|
pluginKey: "paperclip.e2b-sandbox-provider",
|
||||||
|
packageName: "@paperclipai/plugin-e2b",
|
||||||
|
version: "0.1.0",
|
||||||
|
status: "error",
|
||||||
|
categories: ["automation"],
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "E2B Sandbox Provider",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "E2B environments for Paperclip.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: ["environment.drivers.register"],
|
||||||
|
environmentDrivers: [
|
||||||
|
{
|
||||||
|
driverKey: "e2b",
|
||||||
|
kind: "sandbox_provider",
|
||||||
|
displayName: "E2B Cloud Sandbox",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lastError: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiFolderDeclaration() {
|
||||||
|
return {
|
||||||
|
folderKey: "wiki-root",
|
||||||
|
displayName: "Wiki root",
|
||||||
|
description: "Company-scoped local folder that stores wiki files.",
|
||||||
|
access: "readWrite" as const,
|
||||||
|
requiredDirectories: ["raw", "wiki"],
|
||||||
|
requiredFiles: ["WIKI.md", "index.md"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderStatus(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
folderKey: "wiki-root",
|
||||||
|
configured: false,
|
||||||
|
path: null,
|
||||||
|
realPath: null,
|
||||||
|
access: "readWrite",
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
requiredDirectories: ["raw", "wiki"],
|
||||||
|
requiredFiles: ["WIKI.md", "index.md"],
|
||||||
|
missingDirectories: ["raw", "wiki"],
|
||||||
|
missingFiles: ["WIKI.md", "index.md"],
|
||||||
|
healthy: false,
|
||||||
|
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
|
||||||
|
checkedAt: "2026-05-02T16:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSettings(container: HTMLDivElement) {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PluginSettings />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
describe("PluginSettings", () => {
|
describe("PluginSettings", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
|
@ -65,30 +143,16 @@ describe("PluginSettings", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
mockPluginsApi.get.mockResolvedValue({
|
mockPluginsApi.get.mockResolvedValue(basePlugin());
|
||||||
id: "plugin-1",
|
|
||||||
pluginKey: "paperclip.e2b-sandbox-provider",
|
|
||||||
packageName: "@paperclipai/plugin-e2b",
|
|
||||||
version: "0.1.0",
|
|
||||||
status: "error",
|
|
||||||
categories: ["automation"],
|
|
||||||
manifestJson: {
|
|
||||||
displayName: "E2B Sandbox Provider",
|
|
||||||
version: "0.1.0",
|
|
||||||
description: "E2B environments for Paperclip.",
|
|
||||||
author: "Paperclip",
|
|
||||||
capabilities: ["environment.drivers.register"],
|
|
||||||
environmentDrivers: [
|
|
||||||
{
|
|
||||||
driverKey: "e2b",
|
|
||||||
kind: "sandbox_provider",
|
|
||||||
displayName: "E2B Cloud Sandbox",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
lastError: null,
|
|
||||||
});
|
|
||||||
mockPluginsApi.dashboard.mockResolvedValue(null);
|
mockPluginsApi.dashboard.mockResolvedValue(null);
|
||||||
|
mockPluginsApi.health.mockResolvedValue({ pluginId: "plugin-1", status: "ready", healthy: true, checks: [] });
|
||||||
|
mockPluginsApi.logs.mockResolvedValue([]);
|
||||||
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
declarations: [],
|
||||||
|
folders: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -98,20 +162,7 @@ describe("PluginSettings", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
|
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
|
||||||
const root = createRoot(container);
|
const root = await renderSettings(container);
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root.render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<PluginSettings />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
await flushReact();
|
|
||||||
await flushReact();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
|
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
|
||||||
expect(container.textContent).toContain("company-scoped instead of instance-global");
|
expect(container.textContent).toContain("company-scoped instead of instance-global");
|
||||||
|
|
@ -122,4 +173,165 @@ describe("PluginSettings", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders unconfigured manifest local folders with required paths", async () => {
|
||||||
|
const declaration = wikiFolderDeclaration();
|
||||||
|
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||||
|
pluginKey: "paperclipai.plugin-llm-wiki",
|
||||||
|
packageName: "@paperclipai/plugin-llm-wiki",
|
||||||
|
status: "ready",
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "LLM Wiki",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Local-file LLM Wiki plugin.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [declaration],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
declarations: [declaration],
|
||||||
|
folders: [folderStatus()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await renderSettings(container);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Local folders");
|
||||||
|
expect(container.textContent).toContain("Wiki root");
|
||||||
|
expect(container.textContent).toContain("Needs attention");
|
||||||
|
expect(container.textContent).toContain("No local folder path is configured.");
|
||||||
|
expect(container.textContent).toContain("Missing directories: raw, wiki");
|
||||||
|
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders invalid configured folders with validation problems", async () => {
|
||||||
|
const declaration = wikiFolderDeclaration();
|
||||||
|
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "LLM Wiki",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Local-file LLM Wiki plugin.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [declaration],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
declarations: [declaration],
|
||||||
|
folders: [folderStatus({
|
||||||
|
configured: true,
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
realPath: "/tmp/wiki",
|
||||||
|
readable: true,
|
||||||
|
writable: true,
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: ["WIKI.md"],
|
||||||
|
problems: [{ code: "missing_file", message: "Required file is missing.", path: "WIKI.md" }],
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await renderSettings(container);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("/tmp/wiki");
|
||||||
|
expect(container.textContent).toContain("ReadableYes");
|
||||||
|
expect(container.textContent).toContain("WritableYes");
|
||||||
|
expect(container.textContent).toContain("Validation problems");
|
||||||
|
expect(container.textContent).toContain("Required file is missing.");
|
||||||
|
expect(container.textContent).toContain("Missing files: WIKI.md");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render required paths as present when the configured root cannot be inspected", async () => {
|
||||||
|
const declaration = wikiFolderDeclaration();
|
||||||
|
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "LLM Wiki",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Local-file LLM Wiki plugin.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [declaration],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
declarations: [declaration],
|
||||||
|
folders: [folderStatus({
|
||||||
|
configured: true,
|
||||||
|
path: "/tmp/wiki-missing",
|
||||||
|
readable: false,
|
||||||
|
writable: false,
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: [],
|
||||||
|
problems: [{ code: "missing", message: "Configured local folder cannot be inspected.", path: "/tmp/wiki-missing" }],
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await renderSettings(container);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Configured local folder cannot be inspected.");
|
||||||
|
expect(container.textContent).toContain("Not inspected");
|
||||||
|
expect(container.textContent).toContain("Configured root was not inspected.");
|
||||||
|
expect(container.textContent).not.toContain("Present");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders healthy folders without validation problems", async () => {
|
||||||
|
const declaration = wikiFolderDeclaration();
|
||||||
|
mockPluginsApi.get.mockResolvedValue(basePlugin({
|
||||||
|
manifestJson: {
|
||||||
|
displayName: "LLM Wiki",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Local-file LLM Wiki plugin.",
|
||||||
|
author: "Paperclip",
|
||||||
|
capabilities: ["local.folders"],
|
||||||
|
localFolders: [declaration],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mockPluginsApi.listLocalFolders.mockResolvedValue({
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
declarations: [declaration],
|
||||||
|
folders: [folderStatus({
|
||||||
|
configured: true,
|
||||||
|
path: "/tmp/wiki",
|
||||||
|
realPath: "/private/tmp/wiki",
|
||||||
|
readable: true,
|
||||||
|
writable: true,
|
||||||
|
missingDirectories: [],
|
||||||
|
missingFiles: [],
|
||||||
|
healthy: true,
|
||||||
|
problems: [],
|
||||||
|
})],
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await renderSettings(container);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Healthy");
|
||||||
|
expect(container.textContent).toContain("Configured path");
|
||||||
|
expect(container.textContent).toContain("/tmp/wiki");
|
||||||
|
expect(container.textContent).toContain("ReadableYes");
|
||||||
|
expect(container.textContent).toContain("WritableYes");
|
||||||
|
expect(container.textContent).toContain("Present");
|
||||||
|
expect(container.textContent).not.toContain("Validation problems");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
|
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle, FolderOpen, Save } from "lucide-react";
|
||||||
|
import type { PluginLocalFolderDeclaration } from "@paperclipai/shared";
|
||||||
import { useCompany } from "@/context/CompanyContext";
|
import { useCompany } from "@/context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||||
import { Link, Navigate, useParams } from "@/lib/router";
|
import { Link, Navigate, useParams } from "@/lib/router";
|
||||||
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
|
||||||
import { pluginsApi } from "@/api/plugins";
|
import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ChoosePathButton } from "@/components/PathInstructionsModal";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -143,6 +145,8 @@ export function PluginSettings() {
|
||||||
const pluginDescription = plugin.manifestJson.description || "No description provided.";
|
const pluginDescription = plugin.manifestJson.description || "No description provided.";
|
||||||
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
|
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
|
||||||
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
|
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
|
||||||
|
const localFolderDeclarations = plugin.manifestJson.localFolders ?? [];
|
||||||
|
const hasLocalFolders = localFolderDeclarations.length > 0;
|
||||||
const environmentDriverNames = environmentDrivers
|
const environmentDriverNames = environmentDrivers
|
||||||
.map((driver) => driver.displayName?.trim() || driver.driverKey)
|
.map((driver) => driver.displayName?.trim() || driver.driverKey)
|
||||||
.filter((name, index, values) => values.indexOf(name) === index);
|
.filter((name, index, values) => values.indexOf(name) === index);
|
||||||
|
|
@ -217,6 +221,13 @@ export function PluginSettings() {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-base font-semibold">Settings</h2>
|
<h2 className="text-base font-semibold">Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{hasLocalFolders ? (
|
||||||
|
<PluginLocalFoldersSettings
|
||||||
|
pluginId={pluginId!}
|
||||||
|
companyId={selectedCompanyId}
|
||||||
|
declarations={localFolderDeclarations}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{hasCustomSettingsPage ? (
|
{hasCustomSettingsPage ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pluginSlots.map((slot) => (
|
{pluginSlots.map((slot) => (
|
||||||
|
|
@ -253,11 +264,11 @@ export function PluginSettings() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : !hasLocalFolders ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This plugin does not require any settings.
|
This plugin does not require any settings.
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -558,6 +569,350 @@ export function PluginSettings() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PluginLocalFoldersSettings — host-managed company-scoped folders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PluginLocalFoldersSettingsProps {
|
||||||
|
pluginId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
declarations: PluginLocalFolderDeclaration[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: companyId
|
||||||
|
? queryKeys.plugins.localFolders(pluginId, companyId)
|
||||||
|
: ["plugins", pluginId, "companies", "none", "local-folders"],
|
||||||
|
queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder]));
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Select a company to configure this plugin's local folders.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h3 className="text-sm font-medium">Local folders</h3>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{(error as Error).message || "Failed to load local folder settings."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading local folders...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{declarations.map((declaration) => (
|
||||||
|
<PluginLocalFolderRow
|
||||||
|
key={declaration.folderKey}
|
||||||
|
pluginId={pluginId}
|
||||||
|
companyId={companyId}
|
||||||
|
declaration={declaration}
|
||||||
|
status={statusByKey.get(declaration.folderKey)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginLocalFolderRowProps {
|
||||||
|
pluginId: string;
|
||||||
|
companyId: string;
|
||||||
|
declaration: PluginLocalFolderDeclaration;
|
||||||
|
status?: PluginLocalFolderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: PluginLocalFolderRowProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const serverPath = status?.path ?? "";
|
||||||
|
const [pathValue, setPathValue] = useState(serverPath);
|
||||||
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPathValue(serverPath);
|
||||||
|
setMessage(null);
|
||||||
|
}, [serverPath, declaration.folderKey]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (path: string) =>
|
||||||
|
pluginsApi.configureLocalFolder(pluginId, companyId, declaration.folderKey, {
|
||||||
|
path,
|
||||||
|
access: declaration.access,
|
||||||
|
requiredDirectories: declaration.requiredDirectories,
|
||||||
|
requiredFiles: declaration.requiredFiles,
|
||||||
|
}),
|
||||||
|
onSuccess: (nextStatus) => {
|
||||||
|
setMessage({
|
||||||
|
type: nextStatus.healthy ? "success" : "error",
|
||||||
|
text: nextStatus.healthy
|
||||||
|
? "Local folder saved."
|
||||||
|
: "Local folder saved, but validation still needs attention.",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.localFolders(pluginId, companyId) });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setMessage({ type: "error", text: err.message || "Failed to save local folder." });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimmedPath = pathValue.trim();
|
||||||
|
const isDirty = trimmedPath !== serverPath;
|
||||||
|
const access = status?.access ?? declaration.access ?? "readWrite";
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!trimmedPath) {
|
||||||
|
setMessage({ type: "error", text: "Local folder path is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLikelyAbsolutePath(trimmedPath)) {
|
||||||
|
setMessage({ type: "error", text: "Local folder must be a full absolute path." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(null);
|
||||||
|
saveMutation.mutate(trimmedPath);
|
||||||
|
}, [saveMutation, trimmedPath]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-md border border-border/70 bg-background px-4 py-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium">{declaration.displayName}</h4>
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
|
{declaration.folderKey}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={status?.healthy ? "default" : "secondary"}>
|
||||||
|
{status?.healthy ? "Healthy" : "Needs attention"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{declaration.description ? (
|
||||||
|
<p className="max-w-3xl text-sm leading-5 text-muted-foreground">
|
||||||
|
{declaration.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge variant={access === "readWrite" ? "default" : "outline"}>
|
||||||
|
{access === "readWrite" ? "Read/write" : "Read only"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 text-sm sm:grid-cols-3">
|
||||||
|
<FolderStatusMetric label="Configured" value={status?.configured ? "Yes" : "No"} ok={!!status?.configured} />
|
||||||
|
<FolderStatusMetric label="Readable" value={status?.readable ? "Yes" : "No"} ok={!!status?.readable} />
|
||||||
|
<FolderStatusMetric
|
||||||
|
label="Writable"
|
||||||
|
value={access === "read" ? "Not requested" : status?.writable ? "Yes" : "No"}
|
||||||
|
ok={access === "read" || !!status?.writable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.path ? (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Configured path</div>
|
||||||
|
<div className="break-all rounded-md bg-muted/60 px-2 py-1.5 font-mono text-xs text-foreground">
|
||||||
|
{status.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`local-folder-${declaration.folderKey}`}>
|
||||||
|
Local folder path
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={`local-folder-${declaration.folderKey}`}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-sm outline-none focus:border-foreground/40 focus:ring-2 focus:ring-ring/20"
|
||||||
|
value={pathValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPathValue(event.target.value);
|
||||||
|
setMessage(null);
|
||||||
|
}}
|
||||||
|
placeholder="/absolute/path/to/folder"
|
||||||
|
/>
|
||||||
|
<ChoosePathButton className="h-8" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending || !isDirty}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FolderRequirements status={status} declaration={declaration} />
|
||||||
|
|
||||||
|
{status?.problems?.length ? (
|
||||||
|
<div className="space-y-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
<div className="font-medium">Validation problems</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{status.problems.map((problem, index) => (
|
||||||
|
<li key={`${problem.code}:${problem.path ?? ""}:${index}`}>
|
||||||
|
{problem.message}
|
||||||
|
{problem.path ? <span className="font-mono"> {problem.path}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm ${
|
||||||
|
message.type === "success"
|
||||||
|
? "border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/30 dark:text-green-400"
|
||||||
|
: "border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<Badge variant={ok ? "default" : "secondary"}>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderRequirements({
|
||||||
|
status,
|
||||||
|
declaration,
|
||||||
|
}: {
|
||||||
|
status?: PluginLocalFolderStatus;
|
||||||
|
declaration: PluginLocalFolderDeclaration;
|
||||||
|
}) {
|
||||||
|
const requiredDirectories = status?.requiredDirectories ?? declaration.requiredDirectories ?? [];
|
||||||
|
const requiredFiles = status?.requiredFiles ?? declaration.requiredFiles ?? [];
|
||||||
|
const missingDirectories = status?.missingDirectories ?? requiredDirectories;
|
||||||
|
const missingFiles = status?.missingFiles ?? requiredFiles;
|
||||||
|
const rootNotInspected = isRootNotInspected(status);
|
||||||
|
|
||||||
|
if (requiredDirectories.length === 0 && requiredFiles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||||
|
<RequirementList
|
||||||
|
title="Required directories"
|
||||||
|
items={requiredDirectories}
|
||||||
|
missingItems={missingDirectories}
|
||||||
|
missingLabel="Missing directories"
|
||||||
|
inspectionUnavailable={rootNotInspected}
|
||||||
|
/>
|
||||||
|
<RequirementList
|
||||||
|
title="Required files"
|
||||||
|
items={requiredFiles}
|
||||||
|
missingItems={missingFiles}
|
||||||
|
missingLabel="Missing files"
|
||||||
|
inspectionUnavailable={rootNotInspected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRootNotInspected(status?: PluginLocalFolderStatus) {
|
||||||
|
if (!status?.configured || status.readable) return false;
|
||||||
|
return status.problems.some((problem) =>
|
||||||
|
problem.code === "missing" || problem.code === "not_readable" || problem.code === "not_directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequirementList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
missingItems,
|
||||||
|
missingLabel,
|
||||||
|
inspectionUnavailable,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
missingItems: string[];
|
||||||
|
missingLabel: string;
|
||||||
|
inspectionUnavailable?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border border-border/60 px-3 py-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{title}</span>
|
||||||
|
{inspectionUnavailable ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Not inspected
|
||||||
|
</Badge>
|
||||||
|
) : missingItems.length > 0 ? (
|
||||||
|
<Badge variant="destructive" className="text-[10px]">
|
||||||
|
{missingItems.length} missing
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-[10px]">Present</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const missing = missingItems.includes(item);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
|
||||||
|
inspectionUnavailable
|
||||||
|
? "border-amber-300/60 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-300"
|
||||||
|
: missing
|
||||||
|
? "border-destructive/30 bg-destructive/10 text-destructive"
|
||||||
|
: "border-border bg-muted/50 text-foreground/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">None declared.</p>
|
||||||
|
)}
|
||||||
|
{inspectionUnavailable ? (
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">Configured root was not inspected.</p>
|
||||||
|
) : missingItems.length > 0 ? (
|
||||||
|
<p className="text-xs text-destructive">{missingLabel}: {missingItems.join(", ")}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyAbsolutePath(pathValue: string) {
|
||||||
|
return (
|
||||||
|
pathValue.startsWith("/") ||
|
||||||
|
/^[A-Za-z]:[\\/]/.test(pathValue) ||
|
||||||
|
pathValue.startsWith("\\\\")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
// PluginConfigForm — auto-generated form for instanceConfigSchema
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
187
ui/src/pages/ProjectDetail.test.tsx
Normal file
187
ui/src/pages/ProjectDetail.test.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Project } from "@paperclipai/shared";
|
||||||
|
import { act, type ReactNode } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ProjectDetail } from "./ProjectDetail";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
const mockProjectsApi = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}));
|
||||||
|
const mockIssuesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
}));
|
||||||
|
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
|
||||||
|
const mockBudgetsApi = vi.hoisted(() => ({ overview: vi.fn(), upsertPolicy: vi.fn() }));
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
|
||||||
|
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
|
||||||
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||||
|
const mockIssuesList = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||||
|
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||||
|
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||||
|
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
|
||||||
|
vi.mock("../api/budgets", () => ({ budgetsApi: mockBudgetsApi }));
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
|
||||||
|
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||||
|
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||||
|
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
|
||||||
|
useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }),
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
useParams: () => ({ projectId: "project-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
companies: [{ id: "company-1", issuePrefix: "PAP" }],
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
setSelectedCompanyId: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn() }) }));
|
||||||
|
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
|
||||||
|
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
|
||||||
|
vi.mock("@/plugins/slots", () => ({
|
||||||
|
PluginSlotMount: () => null,
|
||||||
|
PluginSlotOutlet: () => null,
|
||||||
|
usePluginSlots: () => ({ slots: [], isLoading: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null }));
|
||||||
|
vi.mock("../components/ProjectProperties", () => ({
|
||||||
|
ProjectProperties: () => <div data-testid="project-properties" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/BudgetPolicyCard", () => ({
|
||||||
|
BudgetPolicyCard: () => <div data-testid="budget-policy-card" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/InlineEditor", () => ({
|
||||||
|
InlineEditor: ({ value, placeholder }: { value?: string; placeholder?: string }) => (
|
||||||
|
<span>{value || placeholder || null}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/ProjectWorkspacesContent", () => ({
|
||||||
|
ProjectWorkspacesContent: () => <div data-testid="project-workspaces" />,
|
||||||
|
}));
|
||||||
|
vi.mock("../components/PageTabBar", () => ({
|
||||||
|
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
|
||||||
|
<div>{items.map((item) => <button key={item.value}>{item.label}</button>)}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../components/IssuesList", () => ({
|
||||||
|
IssuesList: (props: unknown) => {
|
||||||
|
mockIssuesList(props);
|
||||||
|
return <div data-testid="issues-list" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function project(overrides: Partial<Project> = {}): Project {
|
||||||
|
const now = new Date("2026-05-01T00:00:00Z");
|
||||||
|
return {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Managed Project",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: "#14b8a6",
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/project-1",
|
||||||
|
effectiveLocalFolder: "/tmp/project-1",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
managedByPlugin: {
|
||||||
|
id: "managed-1",
|
||||||
|
pluginId: "plugin-1",
|
||||||
|
pluginKey: "paperclip.missions",
|
||||||
|
pluginDisplayName: "Missions",
|
||||||
|
resourceKind: "project",
|
||||||
|
resourceKey: "operations",
|
||||||
|
defaultsJson: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
archivedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectDetail", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockProjectsApi.get.mockResolvedValue(project());
|
||||||
|
mockProjectsApi.list.mockResolvedValue([project()]);
|
||||||
|
mockIssuesApi.list.mockResolvedValue([]);
|
||||||
|
mockAgentsApi.list.mockResolvedValue([]);
|
||||||
|
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||||
|
mockBudgetsApi.overview.mockResolvedValue({ policies: [] });
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
container.remove();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ProjectDetail />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Managed by Missions");
|
||||||
|
expect(container.textContent).toContain("Plugin operations");
|
||||||
|
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
|
||||||
|
projectId: "project-1",
|
||||||
|
originKindPrefix: "plugin:paperclip.missions",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -33,7 +33,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
|
||||||
|
|
||||||
/* ── Top-level tab types ── */
|
/* ── Top-level tab types ── */
|
||||||
|
|
||||||
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
|
type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
|
||||||
type ProjectPluginTab = `plugin:${string}`;
|
type ProjectPluginTab = `plugin:${string}`;
|
||||||
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
|
||||||
|
|
||||||
|
|
@ -50,6 +50,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
|
||||||
if (tab === "configuration") return "configuration";
|
if (tab === "configuration") return "configuration";
|
||||||
if (tab === "budget") return "budget";
|
if (tab === "budget") return "budget";
|
||||||
if (tab === "issues") return "list";
|
if (tab === "issues") return "list";
|
||||||
|
if (tab === "plugin-operations") return "plugin-operations";
|
||||||
if (tab === "workspaces") return "workspaces";
|
if (tab === "workspaces") return "workspaces";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +209,67 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectPluginOperationsList({
|
||||||
|
projectId,
|
||||||
|
companyId,
|
||||||
|
pluginKey,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
companyId: string;
|
||||||
|
pluginKey: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const originKindPrefix = `plugin:${pluginKey}`;
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(companyId),
|
||||||
|
queryFn: () => agentsApi.list(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(companyId),
|
||||||
|
queryFn: () => projectsApi.list(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
const { data: liveRuns } = useQuery({
|
||||||
|
queryKey: queryKeys.liveRuns(companyId),
|
||||||
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
|
||||||
|
enabled: !!companyId,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||||
|
|
||||||
|
const { data: issues, isLoading, error } = useQuery({
|
||||||
|
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
|
||||||
|
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
|
||||||
|
enabled: !!companyId && !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateIssue = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
issuesApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IssuesList
|
||||||
|
issues={issues ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error as Error | null}
|
||||||
|
agents={agents}
|
||||||
|
projects={projects}
|
||||||
|
liveIssueIds={liveIssueIds}
|
||||||
|
projectId={projectId}
|
||||||
|
viewStateKey={`paperclip:project-plugin-operations-view:${pluginKey}`}
|
||||||
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main project page ── */
|
/* ── Main project page ── */
|
||||||
|
|
||||||
export function ProjectDetail() {
|
export function ProjectDetail() {
|
||||||
|
|
@ -390,6 +452,10 @@ export function ProjectDetail() {
|
||||||
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (activeTab === "plugin-operations") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/plugin-operations`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (activeTab === "workspaces") {
|
if (activeTab === "workspaces") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
|
||||||
return;
|
return;
|
||||||
|
|
@ -523,6 +589,9 @@ export function ProjectDetail() {
|
||||||
if (cachedTab === "budget") {
|
if (cachedTab === "budget") {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
|
||||||
}
|
}
|
||||||
|
if (cachedTab === "plugin-operations" && project?.managedByPlugin) {
|
||||||
|
return <Navigate to={`/projects/${canonicalProjectRef}/plugin-operations`} replace />;
|
||||||
|
}
|
||||||
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
|
||||||
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
|
||||||
}
|
}
|
||||||
|
|
@ -554,6 +623,8 @@ export function ProjectDetail() {
|
||||||
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
navigate(`/projects/${canonicalProjectRef}/workspaces`);
|
||||||
} else if (tab === "budget") {
|
} else if (tab === "budget") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/budget`);
|
navigate(`/projects/${canonicalProjectRef}/budget`);
|
||||||
|
} else if (tab === "plugin-operations") {
|
||||||
|
navigate(`/projects/${canonicalProjectRef}/plugin-operations`);
|
||||||
} else if (tab === "configuration") {
|
} else if (tab === "configuration") {
|
||||||
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
navigate(`/projects/${canonicalProjectRef}/configuration`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -583,6 +654,12 @@ export function ProjectDetail() {
|
||||||
Paused by budget hard stop
|
Paused by budget hard stop
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{project.managedByPlugin ? (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: project.color ?? "#6366f1" }} />
|
||||||
|
Managed by {project.managedByPlugin.pluginDisplayName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -622,6 +699,7 @@ export function ProjectDetail() {
|
||||||
items={[
|
items={[
|
||||||
{ value: "list", label: "Issues" },
|
{ value: "list", label: "Issues" },
|
||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
|
...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []),
|
||||||
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "budget", label: "Budget" },
|
{ value: "budget", label: "Budget" },
|
||||||
|
|
@ -651,6 +729,14 @@ export function ProjectDetail() {
|
||||||
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "plugin-operations" && project?.id && resolvedCompanyId && project.managedByPlugin && (
|
||||||
|
<ProjectPluginOperationsList
|
||||||
|
projectId={project.id}
|
||||||
|
companyId={resolvedCompanyId}
|
||||||
|
pluginKey={project.managedByPlugin.pluginKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "workspaces" ? (
|
{activeTab === "workspaces" ? (
|
||||||
workspaceTabDecisionLoaded ? (
|
workspaceTabDecisionLoaded ? (
|
||||||
workspaceTabError ? (
|
workspaceTabError ? (
|
||||||
|
|
|
||||||
|
|
@ -702,36 +702,44 @@ export function RoutineDetail() {
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
{/* Header: editable title + actions */}
|
{/* Header: editable title + actions */}
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<textarea
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
ref={titleInputRef}
|
<textarea
|
||||||
className="flex-1 min-w-0 resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
|
ref={titleInputRef}
|
||||||
placeholder="Routine title"
|
className="w-full resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
|
||||||
rows={1}
|
placeholder="Routine title"
|
||||||
value={editDraft.title}
|
rows={1}
|
||||||
onChange={(event) => {
|
value={editDraft.title}
|
||||||
setEditDraft((current) => ({ ...current, title: event.target.value }));
|
onChange={(event) => {
|
||||||
autoResizeTextarea(event.target);
|
setEditDraft((current) => ({ ...current, title: event.target.value }));
|
||||||
}}
|
autoResizeTextarea(event.target);
|
||||||
onKeyDown={(event) => {
|
}}
|
||||||
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
onKeyDown={(event) => {
|
||||||
event.preventDefault();
|
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
|
||||||
descriptionEditorRef.current?.focus();
|
event.preventDefault();
|
||||||
return;
|
descriptionEditorRef.current?.focus();
|
||||||
}
|
return;
|
||||||
if (event.key === "Tab" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (editDraft.assigneeAgentId) {
|
|
||||||
if (editDraft.projectId) {
|
|
||||||
descriptionEditorRef.current?.focus();
|
|
||||||
} else {
|
|
||||||
projectSelectorRef.current?.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
assigneeSelectorRef.current?.focus();
|
|
||||||
}
|
}
|
||||||
}
|
if (event.key === "Tab" && !event.shiftKey) {
|
||||||
}}
|
event.preventDefault();
|
||||||
/>
|
if (editDraft.assigneeAgentId) {
|
||||||
|
if (editDraft.projectId) {
|
||||||
|
descriptionEditorRef.current?.focus();
|
||||||
|
} else {
|
||||||
|
projectSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assigneeSelectorRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{routine.managedByPlugin ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
|
||||||
|
Managed by {routine.managedByPlugin.pluginDisplayName}
|
||||||
|
<span className="font-mono text-[10px]">{routine.managedByPlugin.resourceKey}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3 pt-1">
|
<div className="flex shrink-0 items-center gap-3 pt-1">
|
||||||
<RunButton
|
<RunButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
|
||||||
import { routinesApi } from "../api/routines";
|
import { routinesApi } from "../api/routines";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
|
@ -18,7 +18,6 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
|
@ -26,6 +25,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
|
||||||
|
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
|
||||||
import {
|
import {
|
||||||
RoutineRunVariablesDialog,
|
RoutineRunVariablesDialog,
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
|
|
@ -35,13 +35,6 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -71,16 +64,6 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
|
||||||
element.style.height = `${element.scrollHeight}px`;
|
element.style.height = `${element.scrollHeight}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLastRunTimestamp(value: Date | string | null | undefined) {
|
|
||||||
if (!value) return "Never";
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
|
||||||
if (currentStatus === "archived" && enabled) return "active";
|
|
||||||
return enabled ? "active" : "paused";
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoutinesTab = "routines" | "runs";
|
type RoutinesTab = "routines" | "runs";
|
||||||
type RoutineGroupBy = "none" | "project" | "assignee";
|
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||||
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
||||||
|
|
@ -130,11 +113,6 @@ function compareNullableText(left: string | null | undefined, right: string | nu
|
||||||
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
|
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRoutineRunStatus(value: string | null | undefined) {
|
|
||||||
if (!value) return null;
|
|
||||||
return value.replaceAll("_", " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRoutineMutationPayload(input: {
|
function buildRoutineMutationPayload(input: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -221,117 +199,6 @@ function buildRoutinesTabHref(tab: RoutinesTab) {
|
||||||
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoutineListRow({
|
|
||||||
routine,
|
|
||||||
projectById,
|
|
||||||
agentById,
|
|
||||||
runningRoutineId,
|
|
||||||
statusMutationRoutineId,
|
|
||||||
href,
|
|
||||||
onRunNow,
|
|
||||||
onToggleEnabled,
|
|
||||||
onToggleArchived,
|
|
||||||
}: {
|
|
||||||
routine: RoutineListItem;
|
|
||||||
projectById: Map<string, { name: string; color?: string | null }>;
|
|
||||||
agentById: Map<string, { name: string; icon?: string | null }>;
|
|
||||||
runningRoutineId: string | null;
|
|
||||||
statusMutationRoutineId: string | null;
|
|
||||||
href: string;
|
|
||||||
onRunNow: (routine: RoutineListItem) => void;
|
|
||||||
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
|
|
||||||
onToggleArchived: (routine: RoutineListItem) => void;
|
|
||||||
}) {
|
|
||||||
const enabled = routine.status === "active";
|
|
||||||
const isArchived = routine.status === "archived";
|
|
||||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
|
||||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
|
||||||
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
|
||||||
const isDraft = !isArchived && !routine.assigneeAgentId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={href}
|
|
||||||
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
|
||||||
{(isArchived || routine.status === "paused" || isDraft) ? (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
|
||||||
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
|
||||||
/>
|
|
||||||
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
|
||||||
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
|
||||||
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ToggleSwitch
|
|
||||||
size="lg"
|
|
||||||
checked={enabled}
|
|
||||||
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
|
||||||
/>
|
|
||||||
<span className="w-12 text-xs text-muted-foreground">
|
|
||||||
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to={href}>Edit</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={runningRoutineId === routine.id || isArchived}
|
|
||||||
onClick={() => onRunNow(routine)}
|
|
||||||
>
|
|
||||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onToggleEnabled(routine, enabled)}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
>
|
|
||||||
{enabled ? "Pause" : "Enable"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onToggleArchived(routine)}
|
|
||||||
disabled={isStatusPending}
|
|
||||||
>
|
|
||||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Routines() {
|
export function Routines() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,47 @@ import {
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
useHostLocation,
|
||||||
|
useHostNavigation,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
usePluginToast,
|
usePluginToast,
|
||||||
} from "./bridge.js";
|
} from "./bridge.js";
|
||||||
|
import { createElement, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { User } from "lucide-react";
|
||||||
|
import {
|
||||||
|
FileTree,
|
||||||
|
type FileTreeProps as HostFileTreeProps,
|
||||||
|
} from "@/components/FileTree";
|
||||||
|
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||||
|
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||||
|
import { IssuesList as HostIssuesList } from "@/components/IssuesList";
|
||||||
|
import { ManagedRoutinesList as HostManagedRoutinesList } from "@/components/ManagedRoutinesList";
|
||||||
|
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||||
|
import { accessApi } from "@/api/access";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
|
import { authApi } from "@/api/auth";
|
||||||
|
import { heartbeatsApi } from "@/api/heartbeats";
|
||||||
|
import { issuesApi } from "@/api/issues";
|
||||||
|
import { projectsApi } from "@/api/projects";
|
||||||
|
import {
|
||||||
|
buildCompanyUserInlineOptions,
|
||||||
|
} from "@/lib/company-members";
|
||||||
|
import { collectLiveIssueIds } from "@/lib/liveIssueIds";
|
||||||
|
import { useProjectOrder } from "@/hooks/useProjectOrder";
|
||||||
|
import {
|
||||||
|
assigneeValueFromSelection,
|
||||||
|
currentUserAssigneeOption,
|
||||||
|
parseAssigneeValue,
|
||||||
|
} from "@/lib/assignees";
|
||||||
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import {
|
||||||
|
getRecentAssigneeSelectionIds,
|
||||||
|
sortAgentsByRecency,
|
||||||
|
trackRecentAssignee,
|
||||||
|
trackRecentAssigneeUser,
|
||||||
|
} from "@/lib/recent-assignees";
|
||||||
|
import { getRecentProjectIds, trackRecentProject } from "@/lib/recent-projects";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Global bridge registry
|
// Global bridge registry
|
||||||
|
|
@ -41,6 +79,451 @@ declare global {
|
||||||
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
|
var __paperclipPluginBridge__: PluginBridgeRegistry | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PluginFileTreePathCollection = ReadonlySet<string> | readonly string[];
|
||||||
|
|
||||||
|
type PluginFileTreeProps = Omit<
|
||||||
|
HostFileTreeProps,
|
||||||
|
| "expandedDirs"
|
||||||
|
| "checkedFiles"
|
||||||
|
| "renderFileExtra"
|
||||||
|
| "fileRowClassName"
|
||||||
|
| "selectedFile"
|
||||||
|
| "showCheckboxes"
|
||||||
|
| "onToggleDir"
|
||||||
|
| "onSelectFile"
|
||||||
|
> & {
|
||||||
|
selectedFile?: string | null;
|
||||||
|
expandedPaths?: PluginFileTreePathCollection;
|
||||||
|
checkedPaths?: PluginFileTreePathCollection;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
onToggleDir?: (path: string) => void;
|
||||||
|
onSelectFile?: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toPathSet(paths?: PluginFileTreePathCollection | null): Set<string> {
|
||||||
|
return new Set(paths ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSdkFileTree({
|
||||||
|
expandedPaths,
|
||||||
|
checkedPaths,
|
||||||
|
selectedFile = null,
|
||||||
|
showCheckboxes = false,
|
||||||
|
onToggleDir,
|
||||||
|
onSelectFile,
|
||||||
|
...props
|
||||||
|
}: PluginFileTreeProps) {
|
||||||
|
return createElement(FileTree, {
|
||||||
|
...props,
|
||||||
|
selectedFile,
|
||||||
|
expandedDirs: toPathSet(expandedPaths),
|
||||||
|
checkedFiles: checkedPaths ? toPathSet(checkedPaths) : undefined,
|
||||||
|
showCheckboxes,
|
||||||
|
onToggleDir: onToggleDir ?? (() => undefined),
|
||||||
|
onSelectFile: onSelectFile ?? (() => undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginMarkdownBlockProps = {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
enableWikiLinks?: boolean;
|
||||||
|
wikiLinkRoot?: string;
|
||||||
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginMarkdownEditorProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
onBlur?: () => void;
|
||||||
|
bordered?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginIssuesListFilters = {
|
||||||
|
status?: string;
|
||||||
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
assigneeAgentId?: string;
|
||||||
|
participantAgentId?: string;
|
||||||
|
assigneeUserId?: string;
|
||||||
|
labelId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
executionWorkspaceId?: string;
|
||||||
|
originKind?: string;
|
||||||
|
originKindPrefix?: string;
|
||||||
|
originId?: string;
|
||||||
|
descendantOf?: string;
|
||||||
|
includeRoutineExecutions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginIssuesListProps = {
|
||||||
|
companyId: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
filters?: PluginIssuesListFilters;
|
||||||
|
viewStateKey?: string;
|
||||||
|
initialSearch?: string;
|
||||||
|
createIssueLabel?: string;
|
||||||
|
searchWithinLoadedIssues?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginAssigneePickerSelection = {
|
||||||
|
assigneeAgentId: string | null;
|
||||||
|
assigneeUserId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginAssigneePickerProps = {
|
||||||
|
companyId?: string | null;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string, selection: PluginAssigneePickerSelection) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
noneLabel?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
includeUsers?: boolean;
|
||||||
|
includeTerminatedAgents?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginProjectPickerProps = {
|
||||||
|
companyId?: string | null;
|
||||||
|
value: string;
|
||||||
|
onChange: (projectId: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
noneLabel?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
includeArchived?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PluginSdkMarkdownEditor(props: PluginMarkdownEditorProps) {
|
||||||
|
const [Editor, setEditor] = useState<ComponentType<PluginMarkdownEditorProps> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
import("@/components/MarkdownEditor").then((module) => {
|
||||||
|
if (!cancelled) setEditor(() => module.MarkdownEditor as ComponentType<PluginMarkdownEditorProps>);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (Editor) return createElement(Editor, props);
|
||||||
|
|
||||||
|
return createElement("textarea", {
|
||||||
|
className: props.className,
|
||||||
|
value: props.value,
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
readOnly: props.readOnly,
|
||||||
|
onBlur: props.onBlur,
|
||||||
|
onChange: (event) => props.onChange((event.currentTarget as HTMLTextAreaElement).value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactIssueFilters(filters: PluginIssuesListFilters): PluginIssuesListFilters {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(filters).filter(([, value]) =>
|
||||||
|
value !== undefined && value !== null && value !== "" && value !== false,
|
||||||
|
),
|
||||||
|
) as PluginIssuesListFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSdkIssuesList({
|
||||||
|
companyId,
|
||||||
|
projectId = null,
|
||||||
|
filters,
|
||||||
|
viewStateKey = "paperclip:plugin-issues-view",
|
||||||
|
initialSearch,
|
||||||
|
createIssueLabel,
|
||||||
|
searchWithinLoadedIssues = true,
|
||||||
|
}: PluginIssuesListProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const issueFilters = useMemo(
|
||||||
|
() => compactIssueFilters({
|
||||||
|
...(filters ?? {}),
|
||||||
|
projectId: filters?.projectId ?? projectId ?? undefined,
|
||||||
|
}),
|
||||||
|
[filters, projectId],
|
||||||
|
);
|
||||||
|
const originKindPrefix = issueFilters.originKindPrefix ?? null;
|
||||||
|
const resolvedProjectId = issueFilters.projectId ?? projectId ?? null;
|
||||||
|
const issuesQueryKey = useMemo(
|
||||||
|
() => ["plugins", "sdk-ui", "issues-list", companyId ?? "__no-company__", issueFilters] as const,
|
||||||
|
[companyId, issueFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(companyId ?? "__no-company__"),
|
||||||
|
queryFn: () => agentsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(companyId ?? "__no-company__"),
|
||||||
|
queryFn: () => projectsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
const { data: liveRuns } = useQuery({
|
||||||
|
queryKey: queryKeys.liveRuns(companyId ?? "__no-company__"),
|
||||||
|
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
|
||||||
|
|
||||||
|
const { data: issues, isLoading, error } = useQuery({
|
||||||
|
queryKey: issuesQueryKey,
|
||||||
|
queryFn: () => issuesApi.list(companyId!, issueFilters),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateIssue = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
issuesApi.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (!companyId) return;
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["plugins", "sdk-ui", "issues-list", companyId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
if (resolvedProjectId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, resolvedProjectId) });
|
||||||
|
if (originKindPrefix) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, resolvedProjectId, originKindPrefix),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return createElement("div", { className: "text-sm text-muted-foreground" }, "Select a company to view issues.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createElement(HostIssuesList, {
|
||||||
|
issues: issues ?? [],
|
||||||
|
isLoading,
|
||||||
|
error: error as Error | null,
|
||||||
|
agents,
|
||||||
|
projects,
|
||||||
|
liveIssueIds,
|
||||||
|
projectId: resolvedProjectId ?? undefined,
|
||||||
|
viewStateKey,
|
||||||
|
initialSearch,
|
||||||
|
createIssueLabel,
|
||||||
|
searchWithinLoadedIssues,
|
||||||
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => updateIssue.mutate({ id, data }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSdkAssigneePicker({
|
||||||
|
companyId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Assignee",
|
||||||
|
noneLabel = "No assignee",
|
||||||
|
searchPlaceholder = "Search assignees...",
|
||||||
|
emptyMessage = "No assignees found.",
|
||||||
|
includeUsers = true,
|
||||||
|
includeTerminatedAgents = false,
|
||||||
|
className,
|
||||||
|
onConfirm,
|
||||||
|
}: PluginAssigneePickerProps) {
|
||||||
|
const hostContext = useHostContext();
|
||||||
|
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
enabled: includeUsers,
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(resolvedCompanyId ?? "__no-company__"),
|
||||||
|
queryFn: () => agentsApi.list(resolvedCompanyId!),
|
||||||
|
enabled: !!resolvedCompanyId,
|
||||||
|
});
|
||||||
|
const { data: companyMembers } = useQuery({
|
||||||
|
queryKey: queryKeys.access.companyUserDirectory(resolvedCompanyId ?? "__no-company__"),
|
||||||
|
queryFn: () => accessApi.listUserDirectory(resolvedCompanyId!),
|
||||||
|
enabled: !!resolvedCompanyId && includeUsers,
|
||||||
|
});
|
||||||
|
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), []);
|
||||||
|
const recentAssigneeIds = useMemo(
|
||||||
|
() => recentAssigneeSelectionIds
|
||||||
|
.map((id) => id.startsWith("agent:") ? id.slice("agent:".length) : null)
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
[recentAssigneeSelectionIds],
|
||||||
|
);
|
||||||
|
const sortedAgents = useMemo(
|
||||||
|
() => sortAgentsByRecency(
|
||||||
|
(agents ?? []).filter((agent) => includeTerminatedAgents || agent.status !== "terminated"),
|
||||||
|
recentAssigneeIds,
|
||||||
|
),
|
||||||
|
[agents, includeTerminatedAgents, recentAssigneeIds],
|
||||||
|
);
|
||||||
|
const options = useMemo<InlineEntityOption[]>(
|
||||||
|
() => [
|
||||||
|
...(includeUsers ? currentUserAssigneeOption(currentUserId) : []),
|
||||||
|
...(includeUsers
|
||||||
|
? buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] })
|
||||||
|
: []),
|
||||||
|
...sortedAgents.map((agent) => ({
|
||||||
|
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||||
|
label: agent.name,
|
||||||
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[companyMembers?.users, currentUserId, includeUsers, sortedAgents],
|
||||||
|
);
|
||||||
|
const selectedAssignee = parseAssigneeValue(value);
|
||||||
|
const selectedAgent = selectedAssignee.assigneeAgentId
|
||||||
|
? sortedAgents.find((agent) => agent.id === selectedAssignee.assigneeAgentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return createElement(InlineEntitySelector, {
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
recentOptionIds: recentAssigneeSelectionIds,
|
||||||
|
placeholder,
|
||||||
|
noneLabel,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
|
className,
|
||||||
|
onConfirm,
|
||||||
|
onChange: (nextValue: string) => {
|
||||||
|
const selection = parseAssigneeValue(nextValue);
|
||||||
|
if (selection.assigneeAgentId) trackRecentAssignee(selection.assigneeAgentId);
|
||||||
|
if (selection.assigneeUserId) trackRecentAssigneeUser(selection.assigneeUserId);
|
||||||
|
onChange(nextValue, selection);
|
||||||
|
},
|
||||||
|
renderTriggerValue: (option: InlineEntityOption | null) => {
|
||||||
|
if (!option) return createElement("span", { className: "text-muted-foreground" }, placeholder);
|
||||||
|
if (selectedAgent) {
|
||||||
|
return createElement(
|
||||||
|
FragmentSafe,
|
||||||
|
null,
|
||||||
|
createElement(AgentIcon, { icon: selectedAgent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
|
||||||
|
createElement("span", { className: "truncate" }, option.label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createElement("span", { className: "truncate" }, option.label);
|
||||||
|
},
|
||||||
|
renderOption: (option: InlineEntityOption) => {
|
||||||
|
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
|
||||||
|
const selection = parseAssigneeValue(option.id);
|
||||||
|
const agent = selection.assigneeAgentId
|
||||||
|
? sortedAgents.find((entry) => entry.id === selection.assigneeAgentId)
|
||||||
|
: null;
|
||||||
|
return createElement(
|
||||||
|
FragmentSafe,
|
||||||
|
null,
|
||||||
|
agent
|
||||||
|
? createElement(AgentIcon, { icon: agent.icon, className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" })
|
||||||
|
: createElement(User, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }),
|
||||||
|
createElement("span", { className: "truncate" }, option.label),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSdkProjectPicker({
|
||||||
|
companyId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Project",
|
||||||
|
noneLabel = "No project",
|
||||||
|
searchPlaceholder = "Search projects...",
|
||||||
|
emptyMessage = "No projects found.",
|
||||||
|
includeArchived = false,
|
||||||
|
className,
|
||||||
|
onConfirm,
|
||||||
|
}: PluginProjectPickerProps) {
|
||||||
|
const hostContext = useHostContext();
|
||||||
|
const resolvedCompanyId = companyId ?? hostContext.companyId ?? null;
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const { data: projects } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(resolvedCompanyId ?? "__no-company__"),
|
||||||
|
queryFn: () => projectsApi.list(resolvedCompanyId!),
|
||||||
|
enabled: !!resolvedCompanyId,
|
||||||
|
});
|
||||||
|
const visibleProjects = useMemo(
|
||||||
|
() => (projects ?? []).filter((project) => includeArchived || !project.archivedAt),
|
||||||
|
[includeArchived, projects],
|
||||||
|
);
|
||||||
|
const { orderedProjects } = useProjectOrder({
|
||||||
|
projects: visibleProjects,
|
||||||
|
companyId: resolvedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
const recentProjectIds = useMemo(() => getRecentProjectIds(), []);
|
||||||
|
const options = useMemo<InlineEntityOption[]>(
|
||||||
|
() => orderedProjects.map((project) => ({
|
||||||
|
id: project.id,
|
||||||
|
label: project.name,
|
||||||
|
searchText: project.description ?? "",
|
||||||
|
})),
|
||||||
|
[orderedProjects],
|
||||||
|
);
|
||||||
|
const selectedProject = orderedProjects.find((project) => project.id === value) ?? null;
|
||||||
|
|
||||||
|
return createElement(InlineEntitySelector, {
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
recentOptionIds: recentProjectIds,
|
||||||
|
placeholder,
|
||||||
|
noneLabel,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
|
className,
|
||||||
|
onConfirm,
|
||||||
|
onChange: (nextProjectId: string) => {
|
||||||
|
if (nextProjectId) trackRecentProject(nextProjectId);
|
||||||
|
onChange(nextProjectId);
|
||||||
|
},
|
||||||
|
renderTriggerValue: (option: InlineEntityOption | null) => {
|
||||||
|
if (!option || !selectedProject) {
|
||||||
|
return createElement("span", { className: "text-muted-foreground" }, placeholder);
|
||||||
|
}
|
||||||
|
return createElement(
|
||||||
|
FragmentSafe,
|
||||||
|
null,
|
||||||
|
createElement("span", {
|
||||||
|
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
|
||||||
|
style: { backgroundColor: selectedProject.color ?? "#6366f1" },
|
||||||
|
}),
|
||||||
|
createElement("span", { className: "truncate" }, option.label),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
renderOption: (option: InlineEntityOption) => {
|
||||||
|
if (!option.id) return createElement("span", { className: "truncate" }, option.label);
|
||||||
|
const project = orderedProjects.find((entry) => entry.id === option.id);
|
||||||
|
return createElement(
|
||||||
|
FragmentSafe,
|
||||||
|
null,
|
||||||
|
createElement("span", {
|
||||||
|
className: "h-3.5 w-3.5 shrink-0 rounded-sm",
|
||||||
|
style: { backgroundColor: project?.color ?? "#6366f1" },
|
||||||
|
}),
|
||||||
|
createElement("span", { className: "truncate" }, option.label),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function FragmentSafe({ children }: { children?: ReactNode }) {
|
||||||
|
return createElement("span", { className: "contents" }, children);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the plugin bridge global registry.
|
* Initialize the plugin bridge global registry.
|
||||||
*
|
*
|
||||||
|
|
@ -62,8 +545,31 @@ export function initPluginBridge(
|
||||||
usePluginData,
|
usePluginData,
|
||||||
usePluginAction,
|
usePluginAction,
|
||||||
useHostContext,
|
useHostContext,
|
||||||
|
useHostLocation,
|
||||||
|
useHostNavigation,
|
||||||
usePluginStream,
|
usePluginStream,
|
||||||
usePluginToast,
|
usePluginToast,
|
||||||
|
MarkdownBlock: ({
|
||||||
|
content,
|
||||||
|
className,
|
||||||
|
enableWikiLinks,
|
||||||
|
wikiLinkRoot,
|
||||||
|
resolveWikiLinkHref,
|
||||||
|
}: PluginMarkdownBlockProps) =>
|
||||||
|
createElement(MarkdownBody, {
|
||||||
|
className,
|
||||||
|
softBreaks: false,
|
||||||
|
enableWikiLinks,
|
||||||
|
wikiLinkRoot,
|
||||||
|
resolveWikiLinkHref,
|
||||||
|
children: content,
|
||||||
|
}),
|
||||||
|
MarkdownEditor: PluginSdkMarkdownEditor,
|
||||||
|
FileTree: PluginSdkFileTree,
|
||||||
|
IssuesList: PluginSdkIssuesList,
|
||||||
|
AssigneePicker: PluginSdkAssigneePicker,
|
||||||
|
ProjectPicker: PluginSdkProjectPicker,
|
||||||
|
ManagedRoutinesList: HostManagedRoutinesList,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
306
ui/src/plugins/bridge.test.ts
Normal file
306
ui/src/plugins/bridge.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
FileTree as SdkFileTree,
|
||||||
|
ManagedRoutinesList as SdkManagedRoutinesList,
|
||||||
|
MarkdownBlock as SdkMarkdownBlock,
|
||||||
|
MarkdownEditor as SdkMarkdownEditor,
|
||||||
|
type FileTreeNode as SdkFileTreeNode,
|
||||||
|
} from "../../../packages/plugins/sdk/src/ui/components";
|
||||||
|
import { SidebarProvider, useSidebar } from "@/context/SidebarContext";
|
||||||
|
import {
|
||||||
|
PluginBridgeContext,
|
||||||
|
resolveHostNavigationHref,
|
||||||
|
shouldHandleHostNavigationClick,
|
||||||
|
useHostNavigation,
|
||||||
|
type PluginBridgeContextValue,
|
||||||
|
} from "./bridge";
|
||||||
|
import { initPluginBridge } from "./bridge-init";
|
||||||
|
|
||||||
|
function clickEvent(
|
||||||
|
overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {},
|
||||||
|
): ReactMouseEvent<HTMLAnchorElement> {
|
||||||
|
return {
|
||||||
|
defaultPrevented: false,
|
||||||
|
button: 0,
|
||||||
|
metaKey: false,
|
||||||
|
altKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
currentTarget: {
|
||||||
|
hasAttribute: () => false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as ReactMouseEvent<HTMLAnchorElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.__paperclipPluginBridge__;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plugin host navigation", () => {
|
||||||
|
it("resolves plugin page routes into the active company prefix", () => {
|
||||||
|
expect(resolveHostNavigationHref("/wiki", "PAP")).toBe("/PAP/wiki");
|
||||||
|
expect(resolveHostNavigationHref("/wiki?tab=browse#page", "pap")).toBe(
|
||||||
|
"/PAP/wiki?tab=browse#page",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-prefix active company paths or global host paths", () => {
|
||||||
|
expect(resolveHostNavigationHref("/PAP/wiki", "PAP")).toBe("/PAP/wiki");
|
||||||
|
expect(resolveHostNavigationHref("/pap/wiki", "PAP")).toBe("/pap/wiki");
|
||||||
|
expect(resolveHostNavigationHref("/instance/settings/plugins", "PAP")).toBe(
|
||||||
|
"/instance/settings/plugins",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("intercepts only same-origin plain left-click navigation", () => {
|
||||||
|
expect(shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki")).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldHandleHostNavigationClick(clickEvent({ ctrlKey: true }), "/PAP/wiki"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldHandleHostNavigationClick(clickEvent(), "/PAP/wiki", "_blank"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldHandleHostNavigationClick(clickEvent(), "https://example.com/wiki"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHostNavigation mobile drawer behavior", () => {
|
||||||
|
// React 19's `act` requires the env flag and React DOM client.
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function makeBridgeValue(): PluginBridgeContextValue {
|
||||||
|
return {
|
||||||
|
pluginId: "test-plugin",
|
||||||
|
hostContext: {
|
||||||
|
companyId: "co",
|
||||||
|
companyPrefix: "PAP",
|
||||||
|
projectId: null,
|
||||||
|
entityId: null,
|
||||||
|
entityType: null,
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: width,
|
||||||
|
});
|
||||||
|
if (typeof window.matchMedia !== "function") {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: /max-width:\s*767px/.test(query) ? width < 768 : false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("closes the sidebar drawer on mobile after a same-origin navigate()", () => {
|
||||||
|
setViewport(390);
|
||||||
|
|
||||||
|
let nav: ReturnType<typeof useHostNavigation> | null = null;
|
||||||
|
let sidebar: ReturnType<typeof useSidebar> | null = null;
|
||||||
|
function Probe() {
|
||||||
|
nav = useHostNavigation();
|
||||||
|
sidebar = useSidebar();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries: ["/PAP/wiki"] },
|
||||||
|
React.createElement(
|
||||||
|
SidebarProvider,
|
||||||
|
null,
|
||||||
|
React.createElement(
|
||||||
|
PluginBridgeContext.Provider,
|
||||||
|
{ value: makeBridgeValue() },
|
||||||
|
React.createElement(Probe),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebar!.isMobile).toBe(true);
|
||||||
|
act(() => sidebar!.setSidebarOpen(true));
|
||||||
|
expect(sidebar!.sidebarOpen).toBe(true);
|
||||||
|
|
||||||
|
act(() => nav!.navigate("/wiki?section=ingest"));
|
||||||
|
expect(sidebar!.sidebarOpen).toBe(false);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the sidebar open on desktop after navigate()", () => {
|
||||||
|
setViewport(1280);
|
||||||
|
|
||||||
|
let nav: ReturnType<typeof useHostNavigation> | null = null;
|
||||||
|
let sidebar: ReturnType<typeof useSidebar> | null = null;
|
||||||
|
function Probe() {
|
||||||
|
nav = useHostNavigation();
|
||||||
|
sidebar = useSidebar();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
MemoryRouter,
|
||||||
|
{ initialEntries: ["/PAP/wiki"] },
|
||||||
|
React.createElement(
|
||||||
|
SidebarProvider,
|
||||||
|
null,
|
||||||
|
React.createElement(
|
||||||
|
PluginBridgeContext.Provider,
|
||||||
|
{ value: makeBridgeValue() },
|
||||||
|
React.createElement(Probe),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebar!.isMobile).toBe(false);
|
||||||
|
expect(sidebar!.sidebarOpen).toBe(true);
|
||||||
|
|
||||||
|
act(() => nav!.navigate("/wiki?section=ingest"));
|
||||||
|
expect(sidebar!.sidebarOpen).toBe(true);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plugin SDK FileTree bridge", () => {
|
||||||
|
const nodes: SdkFileTreeNode[] = [
|
||||||
|
{
|
||||||
|
name: "wiki",
|
||||||
|
path: "wiki",
|
||||||
|
kind: "dir",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "index.md",
|
||||||
|
path: "wiki/index.md",
|
||||||
|
kind: "file",
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("injects the host FileTree implementation through the bridge runtime", () => {
|
||||||
|
initPluginBridge(React, ReactDOM);
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
React.createElement(SdkFileTree, {
|
||||||
|
nodes,
|
||||||
|
expandedPaths: ["wiki"],
|
||||||
|
selectedFile: "wiki/index.md",
|
||||||
|
onToggleDir: () => undefined,
|
||||||
|
onSelectFile: () => undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('role="tree"');
|
||||||
|
expect(html).toContain("wiki");
|
||||||
|
expect(html).toContain("index.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a clear error when the host FileTree implementation is missing", () => {
|
||||||
|
globalThis.__paperclipPluginBridge__ = {
|
||||||
|
react: React,
|
||||||
|
reactDom: ReactDOM,
|
||||||
|
sdkUi: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(SdkFileTree, {
|
||||||
|
nodes,
|
||||||
|
expandedPaths: ["wiki"],
|
||||||
|
onToggleDir: () => undefined,
|
||||||
|
onSelectFile: () => undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toThrow('Paperclip plugin UI runtime is not initialized for "FileTree"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plugin SDK markdown component bridge", () => {
|
||||||
|
it("injects markdown display and editor components through the bridge runtime", () => {
|
||||||
|
initPluginBridge(React, ReactDOM);
|
||||||
|
|
||||||
|
const registry = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||||
|
expect(registry.MarkdownBlock).toBeTypeOf("function");
|
||||||
|
expect(registry.MarkdownEditor).toBeTypeOf("function");
|
||||||
|
expect(registry.IssuesList).toBeTypeOf("function");
|
||||||
|
expect(registry.AssigneePicker).toBeTypeOf("function");
|
||||||
|
expect(registry.ProjectPicker).toBeTypeOf("function");
|
||||||
|
expect(registry.ManagedRoutinesList).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders plugin-provided markdown components when registered by the host", () => {
|
||||||
|
globalThis.__paperclipPluginBridge__ = {
|
||||||
|
react: React,
|
||||||
|
reactDom: ReactDOM,
|
||||||
|
sdkUi: {
|
||||||
|
MarkdownBlock: ({ content, enableWikiLinks, wikiLinkRoot }: { content: string; enableWikiLinks?: boolean; wikiLinkRoot?: string }) =>
|
||||||
|
React.createElement("article", {
|
||||||
|
"data-wiki-links": enableWikiLinks ? "true" : "false",
|
||||||
|
"data-wiki-root": wikiLinkRoot,
|
||||||
|
}, content),
|
||||||
|
MarkdownEditor: ({ value }: { value: string }) =>
|
||||||
|
React.createElement("textarea", { value, readOnly: true }),
|
||||||
|
ManagedRoutinesList: ({ routines }: { routines: Array<{ title: string }> }) =>
|
||||||
|
React.createElement("section", null, routines.map((routine) => routine.title).join(", ")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownHtml = renderToStaticMarkup(React.createElement(SdkMarkdownBlock, {
|
||||||
|
content: "# Wiki",
|
||||||
|
enableWikiLinks: true,
|
||||||
|
wikiLinkRoot: "/wiki/page",
|
||||||
|
}));
|
||||||
|
expect(markdownHtml).toContain("# Wiki");
|
||||||
|
expect(markdownHtml).toContain('data-wiki-links="true"');
|
||||||
|
expect(markdownHtml).toContain('data-wiki-root="/wiki/page"');
|
||||||
|
expect(renderToStaticMarkup(React.createElement(SdkMarkdownEditor, { value: "# Wiki", onChange: () => undefined }))).toContain("# Wiki");
|
||||||
|
expect(renderToStaticMarkup(React.createElement(SdkManagedRoutinesList, {
|
||||||
|
routines: [{ key: "lint", title: "Run lint", status: "active" }],
|
||||||
|
}))).toContain("Run lint");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,7 +25,8 @@
|
||||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useRef, useState, useEffect } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import { useLocation as useRouterLocation, useNavigate as useRouterNavigate, type NavigateOptions } from "react-router-dom";
|
||||||
import type {
|
import type {
|
||||||
PluginBridgeErrorCode,
|
PluginBridgeErrorCode,
|
||||||
PluginLauncherBounds,
|
PluginLauncherBounds,
|
||||||
|
|
@ -35,6 +36,8 @@ import type {
|
||||||
import { pluginsApi } from "@/api/plugins";
|
import { pluginsApi } from "@/api/plugins";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useToastActions, type ToastInput } from "@/context/ToastContext";
|
import { useToastActions, type ToastInput } from "@/context/ToastContext";
|
||||||
|
import { useSidebar } from "@/context/SidebarContext";
|
||||||
|
import { isGlobalPath, normalizeCompanyPrefix } from "@/lib/company-routes";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bridge error type (mirrors the SDK's PluginBridgeError)
|
// Bridge error type (mirrors the SDK's PluginBridgeError)
|
||||||
|
|
@ -63,6 +66,36 @@ export interface PluginDataResult<T = unknown> {
|
||||||
export type PluginToastInput = ToastInput;
|
export type PluginToastInput = ToastInput;
|
||||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||||
|
|
||||||
|
export interface HostNavigationOptions {
|
||||||
|
replace?: boolean;
|
||||||
|
state?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostNavigationLinkOptions extends HostNavigationOptions {
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostNavigationLinkProps {
|
||||||
|
href: string;
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
|
onClick(event: ReactMouseEvent<HTMLAnchorElement>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostNavigation {
|
||||||
|
resolveHref(to: string): string;
|
||||||
|
navigate(to: string, options?: HostNavigationOptions): void;
|
||||||
|
linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostLocation {
|
||||||
|
pathname: string;
|
||||||
|
search: string;
|
||||||
|
hash: string;
|
||||||
|
state?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Host context type (mirrors the SDK's PluginHostContext)
|
// Host context type (mirrors the SDK's PluginHostContext)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -220,6 +253,81 @@ function serializeRenderEnvironmentSnapshot(
|
||||||
return snapshot ? JSON.stringify(snapshot) : "";
|
return snapshot ? JSON.stringify(snapshot) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitPath(path: string): { pathname: string; search: string; hash: string } {
|
||||||
|
const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
||||||
|
return {
|
||||||
|
pathname: match?.[1] ?? path,
|
||||||
|
search: match?.[2] ?? "",
|
||||||
|
hash: match?.[3] ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOriginPathFromHref(href: string): string | null {
|
||||||
|
if (!/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin);
|
||||||
|
if (url.origin !== window.location.origin) return null;
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompanyPrefix(pathname: string, companyPrefix: string): boolean {
|
||||||
|
const [firstSegment] = pathname.split("/").filter(Boolean);
|
||||||
|
return firstSegment?.toUpperCase() === normalizeCompanyPrefix(companyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a plugin-provided Paperclip path to the active company scope.
|
||||||
|
*
|
||||||
|
* This intentionally handles plugin page roots such as `/wiki`, which cannot
|
||||||
|
* be listed in the host router's static board-route table ahead of time.
|
||||||
|
*/
|
||||||
|
export function resolveHostNavigationHref(
|
||||||
|
to: string,
|
||||||
|
companyPrefix: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const sameOriginPath = sameOriginPathFromHref(to);
|
||||||
|
if (sameOriginPath === null) return to;
|
||||||
|
|
||||||
|
const { pathname, search, hash } = splitPath(sameOriginPath);
|
||||||
|
if (!pathname.startsWith("/") || isGlobalPath(pathname) || !companyPrefix) {
|
||||||
|
return sameOriginPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCompanyPrefix(pathname, companyPrefix)) {
|
||||||
|
return sameOriginPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${normalizeCompanyPrefix(companyPrefix)}${pathname}${search}${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainLeftClick(event: ReactMouseEvent<HTMLAnchorElement>): boolean {
|
||||||
|
return (
|
||||||
|
!event.defaultPrevented &&
|
||||||
|
event.button === 0 &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.shiftKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHandleHostNavigationClick(
|
||||||
|
event: ReactMouseEvent<HTMLAnchorElement>,
|
||||||
|
href: string,
|
||||||
|
target?: string,
|
||||||
|
): boolean {
|
||||||
|
if (!isPlainLeftClick(event)) return false;
|
||||||
|
if (target && target !== "_self") return false;
|
||||||
|
if (event.currentTarget.hasAttribute("download")) return false;
|
||||||
|
return sameOriginPathFromHref(href) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concrete implementation of `usePluginData<T>(key, params)`.
|
* Concrete implementation of `usePluginData<T>(key, params)`.
|
||||||
*
|
*
|
||||||
|
|
@ -364,6 +472,81 @@ export function useHostContext(): PluginHostContext {
|
||||||
return hostContext;
|
return hostContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useHostNavigation — concrete implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useHostNavigation(): HostNavigation {
|
||||||
|
const { hostContext } = usePluginBridgeContext();
|
||||||
|
const routerNavigate = useRouterNavigate();
|
||||||
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
|
const companyPrefix = hostContext.companyPrefix;
|
||||||
|
|
||||||
|
const resolveHref = useCallback(
|
||||||
|
(to: string) => resolveHostNavigationHref(to, companyPrefix),
|
||||||
|
[companyPrefix],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(to: string, options?: HostNavigationOptions) => {
|
||||||
|
const href = resolveHref(to);
|
||||||
|
const sameOriginPath = sameOriginPathFromHref(href);
|
||||||
|
if (sameOriginPath === null) {
|
||||||
|
window.location.assign(href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
routerNavigate(sameOriginPath, options as NavigateOptions | undefined);
|
||||||
|
// Mirror host sidebar behavior: tapping a link inside the mobile drawer
|
||||||
|
// dismisses the drawer so the user can see the destination page.
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
},
|
||||||
|
[isMobile, resolveHref, routerNavigate, setSidebarOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkProps = useCallback(
|
||||||
|
(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps => {
|
||||||
|
const href = resolveHref(to);
|
||||||
|
return {
|
||||||
|
href,
|
||||||
|
target: options?.target,
|
||||||
|
rel: options?.rel,
|
||||||
|
onClick: (event) => {
|
||||||
|
if (!shouldHandleHostNavigationClick(event, href, options?.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(href, options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[navigate, resolveHref],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
resolveHref,
|
||||||
|
navigate,
|
||||||
|
linkProps,
|
||||||
|
}),
|
||||||
|
[linkProps, navigate, resolveHref],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// useHostLocation — concrete implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useHostLocation(): HostLocation {
|
||||||
|
const location = useRouterLocation();
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search,
|
||||||
|
hash: location.hash,
|
||||||
|
state: location.state,
|
||||||
|
}),
|
||||||
|
[location.hash, location.pathname, location.search, location.state],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// usePluginToast — concrete implementation
|
// usePluginToast — concrete implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,33 @@ export type ResolvedPluginSlot = PluginUiSlotDeclaration & {
|
||||||
pluginVersion: string;
|
pluginVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique `routeSidebar` slot that pairs with a single `page` slot
|
||||||
|
* for the given route, or `null` if no unambiguous pairing exists.
|
||||||
|
*
|
||||||
|
* Used to detect when a route is taken over by a plugin's full-page sidebar so
|
||||||
|
* host chrome (breadcrumb, in-page Back) can be suppressed.
|
||||||
|
*/
|
||||||
|
export function resolveRouteSidebarSlot(
|
||||||
|
slots: ResolvedPluginSlot[],
|
||||||
|
routePath: string | null,
|
||||||
|
): ResolvedPluginSlot | null {
|
||||||
|
if (!routePath) return null;
|
||||||
|
|
||||||
|
const pageMatches = slots.filter((slot) => slot.type === "page" && slot.routePath === routePath);
|
||||||
|
if (pageMatches.length !== 1) return null;
|
||||||
|
|
||||||
|
const [pageSlot] = pageMatches;
|
||||||
|
const sidebarMatches = slots.filter((slot) =>
|
||||||
|
slot.type === "routeSidebar"
|
||||||
|
&& slot.routePath === routePath
|
||||||
|
&& slot.pluginId === pageSlot.pluginId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sidebarMatches.length !== 1) return null;
|
||||||
|
return sidebarMatches[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
type PluginSlotComponentProps = {
|
type PluginSlotComponentProps = {
|
||||||
slot: ResolvedPluginSlot;
|
slot: ResolvedPluginSlot;
|
||||||
context: PluginSlotContext;
|
context: PluginSlotContext;
|
||||||
|
|
@ -257,8 +284,30 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
|
||||||
case "sdk-ui":
|
case "sdk-ui":
|
||||||
source = `
|
source = `
|
||||||
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
const SDK = globalThis.__paperclipPluginBridge__?.sdkUi ?? {};
|
||||||
const { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast } = SDK;
|
function missing(name) {
|
||||||
export { usePluginData, usePluginAction, useHostContext, usePluginStream, usePluginToast };
|
return function MissingPaperclipSdkUiComponent() {
|
||||||
|
throw new Error('Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast } = SDK;
|
||||||
|
const MetricCard = SDK.MetricCard ?? missing("MetricCard");
|
||||||
|
const StatusBadge = SDK.StatusBadge ?? missing("StatusBadge");
|
||||||
|
const DataTable = SDK.DataTable ?? missing("DataTable");
|
||||||
|
const TimeseriesChart = SDK.TimeseriesChart ?? missing("TimeseriesChart");
|
||||||
|
const MarkdownBlock = SDK.MarkdownBlock ?? missing("MarkdownBlock");
|
||||||
|
const MarkdownEditor = SDK.MarkdownEditor ?? missing("MarkdownEditor");
|
||||||
|
const KeyValueList = SDK.KeyValueList ?? missing("KeyValueList");
|
||||||
|
const ActionBar = SDK.ActionBar ?? missing("ActionBar");
|
||||||
|
const LogView = SDK.LogView ?? missing("LogView");
|
||||||
|
const JsonTree = SDK.JsonTree ?? missing("JsonTree");
|
||||||
|
const Spinner = SDK.Spinner ?? missing("Spinner");
|
||||||
|
const ErrorBoundary = SDK.ErrorBoundary ?? missing("ErrorBoundary");
|
||||||
|
const FileTree = SDK.FileTree ?? missing("FileTree");
|
||||||
|
const IssuesList = SDK.IssuesList ?? missing("IssuesList");
|
||||||
|
const AssigneePicker = SDK.AssigneePicker ?? missing("AssigneePicker");
|
||||||
|
const ProjectPicker = SDK.ProjectPicker ?? missing("ProjectPicker");
|
||||||
|
const ManagedRoutinesList = SDK.ManagedRoutinesList ?? missing("ManagedRoutinesList");
|
||||||
|
export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue