Compare commits

...

5 Commits

Author SHA1 Message Date
Dotta
bab5136645 Keep handoff resolution logging non-fatal 2026-05-05 12:16:30 -05:00
Dotta
09ed4e54cb Add operator QoL screenshots 2026-05-05 12:06:21 -05:00
Dotta
783f4d2f28 Address operator QoL PR feedback 2026-05-05 11:59:50 -05:00
Dotta
433326ffcb Improve operator workflow QoL
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-05 11:34:01 -05:00
Dotta
3c73ed26b5 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>
2026-05-05 07:42:57 -05:00
131 changed files with 29500 additions and 1177 deletions

View File

@@ -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-owned JSON API routes must be declared in the manifest and are mounted
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.
## Scaffold a plugin
@@ -168,6 +170,187 @@ Mount surfaces currently wired in the host include:
- `commentAnnotation`
- `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
Plugins may declare a `page` slot with `routePath` to own a company route like:

View File

@@ -27,7 +27,7 @@ Current limitations to keep in mind:
- 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.
- 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`.
They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core
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:
- **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
- **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc.
- **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.
`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
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 |
| `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection |
| `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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -533,6 +533,13 @@
"when": 1777572332006,
"tag": "0075_cultured_sebastian_shaw",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1777675301279,
"tag": "0076_useful_elektra",
"breakpoints": true
}
]
}
}

View File

@@ -65,6 +65,7 @@ export { companySkills } from "./company_skills.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
export { pluginManagedResources } from "./plugin_managed_resources.js";
export { pluginState } from "./plugin_state.js";
export { pluginEntities } from "./plugin_entities.js";
export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js";

View 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,
),
}),
);

View File

@@ -27,7 +27,7 @@ Generates:
- `esbuild` and `rollup` config files using SDK bundler presets
- 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:*`.

View File

@@ -1,11 +1,12 @@
import type {
FileTreeNode,
PluginProjectSidebarItemProps,
PluginDetailTabProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,
} from "@paperclipai/plugin-sdk/ui";
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
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 FileEntry = { name: string; path: string; isDirectory: boolean };
type FileTreeNodeProps = {
entry: FileEntry;
companyId: string | null;
projectId: string;
workspaceId: string;
selectedPath: string | null;
onSelect: (path: string) => void;
depth?: number;
};
function entryToFileTreeNode(entry: FileEntry): FileTreeNode {
return {
name: entry.name,
path: entry.path,
kind: entry.isDirectory ? "dir" : "file",
children: [],
};
}
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 WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
@@ -235,109 +252,6 @@ function useAvailableHeight(
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.
*/
@@ -430,11 +344,60 @@ export function FilesTab({ context }: PluginDetailTabProps) {
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
[companyId, projectId, selectedWorkspace],
);
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>(
"fileList",
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).
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
@@ -610,28 +573,23 @@ export function FilesTab({ context }: PluginDetailTabProps) {
</div>
<div className="min-h-0 flex-1 overflow-auto p-2">
{selectedWorkspace ? (
fileListLoading ? (
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
) : entries.length > 0 ? (
<ul className="space-y-0.5">
{entries.map((entry) => (
<FileTreeNode
key={entry.path}
entry={entry}
companyId={companyId}
projectId={projectId}
workspaceId={selectedWorkspace.id}
selectedPath={selectedPath}
onSelect={(path) => {
setSelectedPath(path);
setMobileView("editor");
}}
/>
))}
</ul>
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
)
<FileTree
nodes={nodes}
selectedFile={selectedPath}
expandedPaths={expandedPaths}
onToggleDir={handleToggleDir}
onSelectFile={(path: string) => {
setSelectedPath(path);
setMobileView("editor");
}}
loading={fileListLoading}
error={fileListError ? { message: fileListError.message } : null}
empty={{
title: "No files",
description: "No files found in this workspace.",
}}
ariaLabel="Workspace files"
/>
) : (
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
)}

View File

@@ -106,43 +106,46 @@ const plugin = definePlugin({
}));
});
ctx.data.register(
"fileList",
async (params: Record<string, unknown>) => {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
if (!projectId || !companyId || !workspaceId) return { entries: [] };
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { entries: [] };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { entries: [] };
const dirPath = resolveWorkspace(workspacePath, directoryPath);
if (!dirPath) {
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 full = path.join(dirPath, name);
const stat = fs.lstatSync(full);
const relativePath = path.relative(workspacePath, full);
return {
name,
path: relativePath,
isDirectory: stat.isDirectory(),
};
}).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
},
);
async function readFileList(params: Record<string, unknown>) {
const projectId = params.projectId as string;
const companyId = typeof params.companyId === "string" ? params.companyId : "";
const workspaceId = params.workspaceId as string;
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
if (!projectId || !companyId || !workspaceId) return { entries: [] };
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((w) => w.id === workspaceId);
if (!workspace) return { entries: [] };
const workspacePath = sanitizeWorkspacePath(workspace.path);
if (!workspacePath) return { entries: [] };
const dirPath = resolveWorkspace(workspacePath, directoryPath);
if (!dirPath) {
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 full = path.join(dirPath, name);
const stat = fs.lstatSync(full);
const relativePath = path.relative(workspacePath, full);
return {
name,
path: relativePath,
isDirectory: stat.isDirectory(),
};
}).sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
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(
"fileContent",

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react";
import {
AssigneePicker,
ProjectPicker,
useHostContext,
useHostNavigation,
usePluginAction,
usePluginData,
usePluginStream,
@@ -248,14 +251,6 @@ const mutedTextStyle: CSSProperties = {
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 {
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"] }) {
const overview = usePluginOverview(context.companyId);
const toast = usePluginToast();
const hostNavigation = useHostNavigation();
const emitDemoEvent = usePluginAction("emit-demo-event");
const startProgressStream = usePluginAction("start-progress-stream");
const writeMetric = usePluginAction("write-metric");
@@ -591,7 +587,7 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context
tone: "info",
action: {
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"] }) {
const hostNavigation = useHostNavigation();
return (
<div
style={{
@@ -1098,8 +1095,8 @@ function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] })
<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.
</div>
<a href={pluginPagePath(context.companyPrefix)} style={{ fontSize: "12px" }}>
{pluginPagePath(context.companyPrefix)}
<a {...hostNavigation.linkProps(`/${PAGE_ROUTE}`)} style={{ fontSize: "12px" }}>
{hostNavigation.resolveHref(`/${PAGE_ROUTE}`)}
</a>
</Section>
<Section title="Paperclip Animation">
@@ -1193,6 +1190,7 @@ function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context
}
function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) {
const hostNavigation = useHostNavigation();
const [liveRuns, setLiveRuns] = useState<HostLiveRunRecord[]>([]);
const [recentRuns, setRecentRuns] = useState<HostHeartbeatRunRecord[]>([]);
const [loading, setLoading] = useState(false);
@@ -1228,7 +1226,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>Company Route</strong>
<Pill label={pluginPagePath(context.companyPrefix)} />
<Pill label={hostNavigation.resolveHref(`/${PAGE_ROUTE}`)} />
</div>
<div style={mutedTextStyle}>
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>{run.id}</div>
{run.agentId ? (
<a href={hostPath(context.companyPrefix, `/agents/${run.agentId}/runs/${run.id}`)}>
<a {...hostNavigation.linkProps(`/agents/${run.agentId}/runs/${run.id}`)}>
Open run
</a>
) : 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"] }) {
return (
<div style={{ display: "grid", gap: "14px" }}>
@@ -1301,12 +1337,14 @@ function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context
<KitchenSinkStorageDemo context={context} />
<KitchenSinkIssueCrudDemo context={context} />
<KitchenSinkCompanyCrudDemo context={context} />
<KitchenSinkSharedPickerDemo context={context} />
<KitchenSinkHostIntegrationDemo context={context} />
</div>
);
}
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 overview = usePluginOverview(companyId);
const [companiesLimit, setCompaniesLimit] = useState(20);
@@ -1531,10 +1569,10 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
<Section title="UI Surfaces">
<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 ? (
<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" }}
>
Open project tab
@@ -1542,7 +1580,7 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
) : null}
{selectedIssueId ? (
<a
href={hostPath(context.companyPrefix, `/issues/${selectedIssueId}`)}
{...hostNavigation.linkProps(`/issues/${selectedIssueId}`)}
style={{ fontSize: "12px" }}
>
Open selected issue
@@ -2199,6 +2237,7 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
}
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
const writeMetric = usePluginAction("write-metric");
@@ -2217,7 +2256,7 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
<div>Issues: {overview.data?.counts.issues ?? 0}</div>
</div>
<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
type="button"
style={buttonStyle}
@@ -2234,13 +2273,14 @@ export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
}
export function KitchenSinkSidebarLink({ context }: PluginSidebarProps) {
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
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;
return (
<a
href={href}
{...hostNavigation.linkProps(`/${PAGE_ROUTE}`)}
aria-current={isActive ? "page" : undefined}
className={[
"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() {
const context = useHostContext();
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
const overview = usePluginOverview(context.companyId);
if (config.data && config.data.showSidebarPanel === false) return null;
@@ -2274,17 +2315,18 @@ export function KitchenSinkSidebarPanel() {
<div style={{ ...layoutStack, ...subtleCardStyle, fontSize: "12px" }}>
<strong>Kitchen Sink Panel</strong>
<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>
);
}
export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) {
const hostNavigation = useHostNavigation();
const config = usePluginConfigData();
if (config.data && config.data.showProjectSidebarItem === false) return null;
return (
<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" }}
>
Kitchen Sink

View File

@@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
| Import | Purpose |
|--------|--------|
| `@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/types` | UI types and slot prop interfaces |
| `@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.
- 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.
- 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.
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. |
| `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.
**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
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
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) |
|----------------------------|-------|---------------------------------------|
| `page` | Global | — |
| `sidebar` | Global | — |
| `routeSidebar` | Global | — |
| `sidebarPanel` | Global | — |
| `settingsPage` | 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.
#### `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`
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` |
| | `secrets.read-ref` |
| | `environment.drivers.register` |
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `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
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
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?)`
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
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
@@ -639,6 +807,7 @@ Each slot type receives a typed props object with `context: PluginHostContext`.
|-----------|----------------|------------------|
| `page` | `PluginPageProps` | — |
| `sidebar` | `PluginSidebarProps` | — |
| `routeSidebar` | `PluginRouteSidebarProps` | — |
| `settingsPage` | `PluginSettingsPageProps` | — |
| `dashboardWidget` | `PluginWidgetProps` | — |
| `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 projects plugin tab (see project detail tabs in the spec):
```tsx
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
import {
useHostNavigation,
type PluginProjectSidebarItemProps,
} from "@paperclipai/plugin-sdk/ui";
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
const hostNavigation = useHostNavigation();
const projectId = context.entityId;
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
const projectRef = projectId; // or resolve from host; entityId is project id
return (
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
<a {...hostNavigation.linkProps(`/projects/${projectRef}?tab=plugin:your-plugin:files`)}>
Files
</a>
);

View File

@@ -89,11 +89,12 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {})
const esbuildManifest: EsbuildLikeOptions = {
entryPoints: [manifestEntry],
outdir,
bundle: false,
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap,
external: ["@paperclipai/plugin-sdk"],
};
const esbuildUi = uiEntry

View File

@@ -90,6 +90,16 @@ export interface HostServices {
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`. */
state: {
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]>;
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][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. */
@@ -202,6 +224,9 @@ export interface HostServices {
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][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`. */
@@ -281,6 +306,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Config — always allowed
"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.get": "plugin.state.read",
"state.set": "plugin.state.write",
@@ -326,6 +359,14 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "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.list": "issues.read",
@@ -357,6 +398,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"agents.pause": "agents.pause",
"agents.resume": "agents.resume",
"agents.invoke": "agents.invoke",
"agents.managed.get": "agents.managed",
"agents.managed.reconcile": "agents.managed",
"agents.managed.reset": "agents.managed",
// Agent Sessions
"agents.sessions.create": "agent.sessions.create",
@@ -439,6 +483,25 @@ export function createHostClientHandlers(
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.get": gated("state.get", async (params) => {
return services.state.get(params);
@@ -530,6 +593,32 @@ export function createHostClientHandlers(
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (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.list": gated("issues.list", async (params) => {
@@ -611,6 +700,15 @@ export function createHostClientHandlers(
"agents.invoke": gated("agents.invoke", async (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
"agents.sessions.create": gated("agents.sessions.create", async (params) => {

View File

@@ -180,6 +180,13 @@ export type {
export type {
PluginContext,
PluginConfigClient,
PluginLocalFolderProblem,
PluginLocalFolderStatus,
PluginLocalFolderConfigureInput,
PluginLocalFolderListOptions,
PluginLocalFolderEntry,
PluginLocalFolderListing,
PluginLocalFoldersClient,
PluginEventsClient,
PluginJobsClient,
PluginLaunchersClient,
@@ -255,6 +262,14 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectDeclaration,
PluginManagedProjectResolution,
PluginManagedRoutineDeclaration,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
@@ -264,6 +279,8 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
PluginLocalFolderDeclaration,
PluginCompanySettings,
PluginRecord,
PluginDatabaseNamespaceRecord,
PluginMigrationRecord,

View File

@@ -29,8 +29,14 @@ import type {
IssueDocumentSummary,
IssueThreadInteraction,
CreateIssueThreadInteraction,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
Agent,
Goal,
PluginLocalFolderDeclaration,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -46,6 +52,8 @@ import type {
PluginWorkspace,
ToolRunContext,
ToolResult,
PluginLocalFolderListing,
PluginLocalFolderStatus,
} from "./types.js";
import type {
PluginHealthDiagnostics,
@@ -566,6 +574,44 @@ export interface WorkerToHostMethods {
// Config
"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.get": [
params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string },
@@ -724,6 +770,57 @@ export interface WorkerToHostMethods {
params: { issueId: string; companyId: string },
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.list": [
@@ -732,8 +829,10 @@ export interface WorkerToHostMethods {
projectId?: string;
assigneeAgentId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
status?: string;
includePluginOperations?: boolean;
limit?: number;
offset?: number;
},
@@ -758,6 +857,7 @@ export interface WorkerToHostMethods {
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
surfaceVisibility?: string | null;
originKind?: string | null;
originId?: string | null;
originRunId?: string | null;
@@ -940,6 +1040,18 @@ export interface WorkerToHostMethods {
params: { agentId: string; companyId: string; prompt: string; reason?: 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
"agents.sessions.create": [

View File

@@ -1,11 +1,16 @@
import { randomUUID } from "node:crypto";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import type {
PaperclipPluginManifestV1,
PluginCapability,
PluginEventType,
PluginIssueOriginKind,
PluginManagedAgentResolution,
PluginManagedRoutineResolution,
Company,
Project,
Routine,
RoutineRun,
Issue,
IssueComment,
IssueThreadInteraction,
@@ -419,6 +424,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const entityExternalIndex = new Map<string, string>();
const companies = new Map<string, Company>();
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 blockedByIssueIds = new Map<string, string[]>();
const issueComments = new Map<string, IssueComment[]>();
@@ -465,6 +472,53 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
}
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 {
if (originKind == null || originKind === "") return defaultPluginOriginKind;
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 };
},
},
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: {
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
requireCapability(manifest, capabilitySet, "events.subscribe");
@@ -647,6 +776,314 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const workspaces = projectWorkspaces.get(projectId) ?? [];
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: {
async list(input) {
@@ -673,6 +1110,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(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?.status) out = out.filter((issue) => issue.status === input.status);
if (input?.offset) out = out.slice(input.offset);
@@ -687,6 +1130,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async create(input) {
requireCapability(manifest, capabilitySet, "issues.create");
const now = new Date();
const originKind = normalizePluginOriginKind(
input.surfaceVisibility === "plugin_operation" && !input.originKind
? pluginOperationIssueOriginKind(manifest.id)
: input.originKind,
);
const record: Issue = {
id: randomUUID(),
companyId: input.companyId,
@@ -708,7 +1156,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
createdByUserId: null,
issueNumber: null,
identifier: null,
originKind: normalizePluginOriginKind(input.originKind),
originKind,
originId: input.originId ?? null,
originRunId: input.originRunId ?? null,
requestDepth: input.requestDepth ?? 0,
@@ -1064,6 +1512,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
}
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: {
async create(agentId, companyId, opts) {
requireCapability(manifest, capabilitySet, "agent.sessions.create");

View File

@@ -28,6 +28,12 @@ import type {
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
Agent,
Goal,
} from "@paperclipai/shared";
@@ -42,6 +48,18 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectDeclaration,
PluginManagedProjectResolution,
PluginManagedRoutineDeclaration,
PluginManagedRoutineResolution,
Routine,
RoutineRun,
PluginLocalFolderDeclaration,
PluginCompanySettings,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginUiDeclaration,
PluginLauncherActionDeclaration,
@@ -92,6 +110,7 @@ export type {
RequestConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
Agent,
Goal,
} from "@paperclipai/shared";
@@ -349,6 +368,90 @@ export interface PluginConfigClient {
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.
*
@@ -697,6 +800,44 @@ export interface PluginProjectsClient {
* @see PLUGIN_SPEC.md §20 — Local Tooling
*/
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;
assigneeAgentId?: string;
originKind?: PluginIssueOriginKind;
originKindPrefix?: string;
originId?: string;
status?: Issue["status"];
includePluginOperations?: boolean;
limit?: number;
offset?: number;
}): Promise<Issue[]>;
@@ -1119,6 +1262,7 @@ export interface PluginIssuesClient {
assigneeUserId?: string | null;
requestDepth?: number;
billingCode?: string | null;
surfaceVisibility?: IssueSurfaceVisibility;
originKind?: PluginIssueOriginKind;
originId?: string | null;
originRunId?: string | null;
@@ -1241,6 +1385,12 @@ export interface PluginAgentsClient {
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(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. */
sessions: PluginAgentSessionsClient;
}
@@ -1436,6 +1586,9 @@ export interface PluginContext {
/** Read resolved operator configuration. */
config: PluginConfigClient;
/** Configure and safely access trusted company-scoped local folders. */
localFolders: PluginLocalFoldersClient;
/** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */
events: PluginEventsClient;
@@ -1466,6 +1619,9 @@ export interface PluginContext {
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
projects: PluginProjectsClient;
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
routines: PluginRoutinesClient;
/** Read company metadata. Requires `companies.read`. */
companies: PluginCompaniesClient;

View File

@@ -125,6 +125,36 @@ export interface TimeseriesChartProps {
export interface MarkdownBlockProps {
/** Markdown content to render. */
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`. */
@@ -217,6 +247,211 @@ export interface ErrorBoundaryProps {
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)
// ---------------------------------------------------------------------------
@@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("Times
*/
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.
*
@@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
*/
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");

View File

@@ -1,6 +1,8 @@
import type {
PluginDataResult,
PluginActionFn,
HostLocation,
HostNavigation,
PluginHostContext,
PluginStreamResult,
PluginToastFn,
@@ -115,6 +117,57 @@ export function useHostContext(): PluginHostContext {
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
// ---------------------------------------------------------------------------

View File

@@ -43,20 +43,89 @@
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
* - `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
*/
export {
usePluginData,
usePluginAction,
useHostContext,
useHostNavigation,
useHostLocation,
usePluginStream,
usePluginToast,
} 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
export type {
PluginBridgeError,
PluginBridgeErrorCode,
HostNavigation,
HostNavigationOptions,
HostNavigationLinkOptions,
HostNavigationLinkProps,
HostLocation,
PluginHostContext,
PluginModalBoundsRequest,
PluginRenderCloseEvent,
@@ -80,6 +149,7 @@ export type {
PluginWidgetProps,
PluginDetailTabProps,
PluginSidebarProps,
PluginRouteSidebarProps,
PluginProjectSidebarItemProps,
PluginCommentAnnotationProps,
PluginCommentContextMenuItemProps,

View File

@@ -14,6 +14,10 @@
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
*/
import type {
AnchorHTMLAttributes,
MouseEvent as ReactMouseEvent,
} from "react";
import type {
PluginBridgeErrorCode,
PluginLauncherBounds,
@@ -131,6 +135,83 @@ export interface PluginRenderEnvironmentContext
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
// ---------------------------------------------------------------------------
@@ -188,6 +269,19 @@ export interface PluginSidebarProps {
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.
*

View File

@@ -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: {
on(
name: string,
@@ -580,6 +625,50 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
async getWorkspaceForIssue(issueId: string, companyId: string) {
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: {
@@ -602,8 +691,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
projectId: input.projectId,
assigneeAgentId: input.assigneeAgentId,
originKind: input.originKind,
originKindPrefix: input.originKindPrefix,
originId: input.originId,
status: input.status,
includePluginOperations: input.includePluginOperations,
limit: input.limit,
offset: input.offset,
});
@@ -628,6 +719,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
assigneeUserId: input.assigneeUserId,
requestDepth: input.requestDepth,
billingCode: input.billingCode,
surfaceVisibility: input.surfaceVisibility,
originKind: input.originKind,
originId: input.originId,
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 });
},
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: {
async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) {
return callHost("agents.sessions.create", {

View File

@@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
return `plugin:${pluginKey}:operation`;
}
export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean {
return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind);
}
export const ISSUE_RELATION_TYPES = ["blocks"] as const;
export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number];
@@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [
"issue.comments.create",
"issue.interactions.create",
"issue.documents.write",
"projects.managed",
"routines.managed",
"agents.pause",
"agents.resume",
"agents.invoke",
"agents.managed",
"agent.sessions.create",
"agent.sessions.list",
"agent.sessions.send",
@@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [
"http.outbound",
"secrets.read-ref",
"environment.drivers.register",
"local.folders",
// Agent Tools
"agent.tools.register",
// UI
@@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"taskDetailView",
"dashboardWidget",
"sidebar",
"routeSidebar",
"sidebarPanel",
"projectSidebarItem",
"globalToolbarButton",

View File

@@ -25,6 +25,9 @@ export {
ISSUE_THREAD_INTERACTION_STATUSES,
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
ISSUE_SURFACE_VISIBILITIES,
pluginOperationIssueOriginKind,
isPluginOperationIssueOriginKind,
ISSUE_RELATION_TYPES,
ISSUE_TREE_CONTROL_MODES,
ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES,
@@ -133,6 +136,7 @@ export {
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
type IssueSurfaceVisibility,
type IssueRelationType,
type IssueTreeControlMode,
type IssueTreeHoldReleasePolicyStrategy,
@@ -303,6 +307,7 @@ export type {
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectWorkspace,
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@@ -347,6 +352,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -493,6 +500,7 @@ export type {
CompanySecret,
SecretProviderDescriptor,
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
@@ -507,6 +515,15 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@@ -523,6 +540,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,

View File

@@ -36,6 +36,11 @@ export interface IssueCostSummary {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
/** number of distinct heartbeat runs aggregated across the issue tree */
runCount: number;
/** sum of wall-clock duration of each run in the tree (ms);
* still-running runs contribute (now - startedAt) so this ticks up live */
runtimeMs: number;
}
export interface CostByAgent {

View File

@@ -89,7 +89,7 @@ export type {
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
@@ -140,6 +140,8 @@ export type {
IssueBlockerAttentionState,
IssueProductivityReview,
IssueProductivityReviewTrigger,
SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
@@ -221,6 +223,7 @@ export type {
} from "./secrets.js";
export type {
Routine,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
RoutineTrigger,
@@ -315,6 +318,15 @@ export type {
PluginWebhookDeclaration,
PluginToolDeclaration,
PluginEnvironmentDriverDeclaration,
PluginManagedAgentDeclaration,
PluginManagedProjectDeclaration,
PluginManagedRoutineDeclaration,
PluginLocalFolderDeclaration,
PluginManagedAgentResolution,
PluginManagedProjectResolution,
PluginManagedRoutineResolution,
PluginManagedResourceKind,
PluginManagedResourceRef,
PluginUiSlotDeclaration,
PluginLauncherActionDeclaration,
PluginLauncherRenderDeclaration,
@@ -331,6 +343,7 @@ export type {
PluginMigrationRecord,
PluginStateRecord,
PluginConfig,
PluginCompanySettings,
PluginEntityRecord,
PluginEntityQuery,
PluginJobRecord,

View File

@@ -162,6 +162,18 @@ export interface IssueProductivityReview {
updatedAt: Date;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState {
state: SuccessfulRunHandoffStateKind;
required: boolean;
sourceRunId: string | null;
correctiveRunId: string | null;
assigneeAgentId: string | null;
detectedProgressSummary: string | null;
createdAt: Date | string | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -324,6 +336,7 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;

View File

@@ -16,7 +16,19 @@ import type {
PluginDatabaseMigrationStatus,
PluginDatabaseNamespaceMode,
PluginDatabaseNamespaceStatus,
AgentAdapterType,
AgentRole,
AgentStatus,
IssuePriority,
ProjectStatus,
RoutineCatchUpPolicy,
RoutineConcurrencyPolicy,
RoutineStatus,
IssueSurfaceVisibility,
} from "../constants.js";
import type { Agent } from "./agent.js";
import type { Project } from "./project.js";
import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder plugins declare config schemas as JSON Schema
@@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration {
configSchema: JsonSchema;
}
/**
* Declares a normal Paperclip agent that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedAgentDeclaration {
/** Stable identifier for this managed agent, unique within the plugin. */
agentKey: string;
/** Suggested visible agent name. */
displayName: string;
/** Optional suggested role. Defaults to `general`. */
role?: AgentRole | string;
/** Optional suggested title shown in agent surfaces. */
title?: string | null;
/** Optional icon for agent list/detail surfaces. */
icon?: string | null;
/** Suggested capability summary for the agent. */
capabilities?: string | null;
/** Suggested adapter type. Defaults to `process`. */
adapterType?: AgentAdapterType | string;
/**
* Optional ordered list of compatible adapter types. When present, the host
* prefers the most-used compatible adapter already configured in the company,
* falling back to `adapterType`.
*/
adapterPreference?: Array<AgentAdapterType | string>;
/** Suggested adapter configuration. */
adapterConfig?: Record<string, unknown>;
/** Suggested Paperclip runtime configuration. */
runtimeConfig?: Record<string, unknown>;
/** Suggested permissions object. Normalized by the host on create/reset. */
permissions?: Record<string, unknown>;
/** Suggested starting status when no board approval is required. */
status?: Extract<AgentStatus, "idle" | "paused">;
/** Suggested monthly budget in cents. */
budgetMonthlyCents?: number;
/** Optional managed instructions content or pointer metadata for plugin UI. */
instructions?: {
entryFile?: string;
content?: string;
assetPath?: string;
};
}
/**
* Declares a company-scoped local folder a trusted plugin wants the operator
* to configure. The host treats this as a generic filesystem root: plugin
* code may request required relative folders/files, then use SDK helpers for
* path-safe reads and atomic writes under that root.
*/
export interface PluginLocalFolderDeclaration {
/** Stable identifier for this folder, unique within the plugin. */
folderKey: string;
/** Human-readable name shown in plugin settings. */
displayName: string;
/** Optional operator-facing description. */
description?: string;
/** Access level requested by the plugin. Defaults to `readWrite`. */
access?: "read" | "readWrite";
/** Relative directories expected to exist under the configured root. */
requiredDirectories?: string[];
/** Relative files expected to exist under the configured root. */
requiredFiles?: string[];
}
/**
* Declares a normal Paperclip project that a plugin can provision and later
* resolve by stable key within each company.
*/
export interface PluginManagedProjectDeclaration {
/** Stable identifier for this managed project, unique within the plugin. */
projectKey: string;
/** Suggested visible project name. */
displayName: string;
/** Suggested project description. */
description?: string | null;
/** Suggested starting status. Defaults to `in_progress`. */
status?: ProjectStatus;
/** Suggested project color. Defaults to the normal project palette. */
color?: string | null;
/** Optional plugin-specific defaults retained for reset/reconcile UI. */
settings?: Record<string, unknown>;
}
export type PluginManagedResourceKind = "agent" | "project" | "routine";
export interface PluginManagedResourceRef {
pluginKey?: string;
resourceKind: PluginManagedResourceKind;
resourceKey: string;
}
export interface PluginManagedRoutineDeclaration {
/** Stable identifier for this managed routine, unique within the plugin. */
routineKey: string;
/** Suggested routine title template. */
title: string;
/** Suggested routine description template. */
description?: string | null;
/** Stable managed agent reference for the default assignee. */
assigneeRef?: PluginManagedResourceRef | null;
/** Stable managed project reference for routine-created issues. */
projectRef?: PluginManagedResourceRef | null;
/** Optional goal id to set on the routine in this company. */
goalId?: string | null;
/** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */
status?: RoutineStatus;
/** Suggested issue priority. Defaults to `medium`. */
priority?: IssuePriority;
/** Suggested concurrency behavior. Defaults to core routine default. */
concurrencyPolicy?: RoutineConcurrencyPolicy;
/** Suggested missed-trigger behavior. Defaults to core routine default. */
catchUpPolicy?: RoutineCatchUpPolicy;
/** Suggested routine variables. */
variables?: RoutineVariable[];
/** Suggested triggers created when the routine is first reconciled. */
triggers?: Array<Pick<RoutineTrigger, "kind" | "label" | "enabled" | "cronExpression" | "timezone" | "signingMode" | "replayWindowSec">>;
/** Defaults for issues created by this routine. */
issueTemplate?: {
surfaceVisibility?: IssueSurfaceVisibility;
originId?: string | null;
billingCode?: string | null;
};
}
export interface PluginManagedAgentResolution {
pluginKey: string;
resourceKind: "agent";
resourceKey: string;
companyId: string;
agentId: string | null;
agent: Agent | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
approvalId?: string | null;
}
export interface PluginManagedProjectResolution {
pluginKey: string;
resourceKind: "project";
resourceKey: string;
companyId: string;
projectId: string | null;
project: Project | null;
status: "missing" | "resolved" | "created" | "relinked" | "reset";
}
export interface PluginManagedRoutineResolution {
pluginKey: string;
resourceKind: "routine";
resourceKey: string;
companyId: string;
routineId: string | null;
routine: Routine | null;
status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset";
missingRefs?: PluginManagedResourceRef[];
}
/**
* Declares a UI extension slot the plugin fills with a React component.
*
@@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration {
*/
entityTypes?: PluginUiSlotEntityType[];
/**
* Optional company-scoped route segment for page slots.
* Optional company-scoped route segment for page and routeSidebar slots.
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
*/
routePath?: string;
@@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 {
apiRoutes?: PluginApiRouteDeclaration[];
/** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */
environmentDrivers?: PluginEnvironmentDriverDeclaration[];
/** Suggested company-scoped agents this plugin can provision and resolve by stable key. */
agents?: PluginManagedAgentDeclaration[];
/** Suggested company-scoped projects this plugin can provision and resolve by stable key. */
projects?: PluginManagedProjectDeclaration[];
/** Suggested company-scoped routines this plugin can provision and resolve by stable key. */
routines?: PluginManagedRoutineDeclaration[];
/** Trusted local folders this plugin can configure and access by stable key. */
localFolders?: PluginLocalFolderDeclaration[];
/**
* Legacy top-level launcher declarations.
* Prefer `ui.launchers` for new manifests.
@@ -455,6 +631,22 @@ export interface PluginConfig {
updatedAt: Date;
}
/**
* Company-scoped plugin settings row. This is intentionally generic; plugin
* features such as local folders live inside `settingsJson` under namespaced
* keys instead of requiring feature-specific database columns.
*/
export interface PluginCompanySettings {
id: string;
companyId: string;
pluginId: string;
enabled: boolean;
settingsJson: Record<string, unknown>;
lastError: string | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Query filter for `ctx.entities.list`.
*/

View File

@@ -52,6 +52,18 @@ export interface ProjectCodebase {
origin: ProjectCodebaseOrigin;
}
export interface ProjectManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "project";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface Project {
id: string;
companyId: string;
@@ -73,6 +85,7 @@ export interface Project {
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
managedByPlugin?: ProjectManagedByPlugin | null;
archivedAt: Date | null;
createdAt: Date;
updatedAt: Date;

View File

@@ -58,6 +58,19 @@ export interface Routine {
lastEnqueuedAt: Date | null;
createdAt: Date;
updatedAt: Date;
managedByPlugin?: RoutineManagedByPlugin | null;
}
export interface RoutineManagedByPlugin {
id: string;
pluginId: string;
pluginKey: string;
pluginDisplayName: string;
resourceKind: "routine";
resourceKey: string;
defaultsJson: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface RoutineTrigger {

View 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);
});
});

View File

@@ -15,7 +15,15 @@ import {
PLUGIN_API_ROUTE_AUTH_MODES,
PLUGIN_API_ROUTE_CHECKOUT_POLICIES,
PLUGIN_API_ROUTE_METHODS,
ISSUE_PRIORITIES,
ROUTINE_CATCH_UP_POLICIES,
ROUTINE_CONCURRENCY_POLICIES,
ROUTINE_STATUSES,
ROUTINE_TRIGGER_KINDS,
ROUTINE_TRIGGER_SIGNING_MODES,
ISSUE_SURFACE_VISIBILITIES,
} from "../constants.js";
import { routineVariableSchema } from "./routine.js";
// ---------------------------------------------------------------------------
// JSON Schema placeholder a permissive validator for JSON Schema objects
@@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer<
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
export const pluginManagedAgentDeclarationSchema = z.object({
agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
role: z.string().min(1).max(100).optional(),
title: z.string().max(200).nullable().optional(),
icon: z.string().max(100).nullable().optional(),
capabilities: z.string().max(2000).nullable().optional(),
adapterType: z.string().min(1).max(100).optional(),
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
adapterConfig: z.record(z.unknown()).optional(),
runtimeConfig: z.record(z.unknown()).optional(),
permissions: z.record(z.unknown()).optional(),
status: z.enum(["idle", "paused"]).optional(),
budgetMonthlyCents: z.number().int().min(0).optional(),
instructions: z.object({
entryFile: z.string().min(1).max(200).optional(),
content: z.string().max(200_000).optional(),
assetPath: z.string().min(1).max(500).optional(),
}).optional(),
});
export type PluginManagedAgentDeclarationInput = z.infer<typeof pluginManagedAgentDeclarationSchema>;
export const pluginManagedProjectDeclarationSchema = z.object({
projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(120),
description: z.string().max(2000).nullable().optional(),
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
color: z.string().max(32).nullable().optional(),
settings: z.record(z.unknown()).optional(),
});
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
const pluginManagedResourceRefSchema = z.object({
pluginKey: z.string().min(1).max(100).optional(),
resourceKind: z.enum(["agent", "project", "routine"]),
resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
});
export const pluginManagedRoutineDeclarationSchema = z.object({
routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
title: z.string().trim().min(1).max(200),
description: z.string().max(10_000).nullable().optional(),
assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(),
projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(),
goalId: z.string().uuid().nullable().optional(),
status: z.enum(ROUTINE_STATUSES).optional(),
priority: z.enum(ISSUE_PRIORITIES).optional(),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(),
variables: z.array(routineVariableSchema).optional(),
triggers: z.array(z.object({
kind: z.enum(ROUTINE_TRIGGER_KINDS),
label: z.string().trim().max(120).nullable().optional(),
enabled: z.boolean().optional(),
cronExpression: z.string().trim().min(1).optional().nullable(),
timezone: z.string().trim().min(1).optional().nullable(),
signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(),
replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(),
})).max(20).optional(),
issueTemplate: z.object({
surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(),
originId: z.string().trim().max(255).nullable().optional(),
billingCode: z.string().trim().max(200).nullable().optional(),
}).optional(),
});
export type PluginManagedRoutineDeclarationInput = z.infer<typeof pluginManagedRoutineDeclarationSchema>;
const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine(
(value) =>
!value.startsWith("/") &&
!value.includes("..") &&
!value.includes("\\") &&
!value.split("/").some((segment) => segment === "" || segment === "."),
{ message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" },
);
export const pluginLocalFolderDeclarationSchema = z.object({
folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, {
message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens",
}),
displayName: z.string().min(1).max(100),
description: z.string().max(500).optional(),
access: z.enum(["read", "readWrite"]).optional(),
requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(),
requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(),
});
export type PluginLocalFolderDeclarationInput = z.infer<typeof pluginLocalFolderDeclarationSchema>;
/**
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
* fills with a React component. Includes `superRefine` checks for slot-specific
@@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({
path: ["entityTypes"],
});
}
if (value.routePath && value.type !== "page") {
if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routePath is only supported for page slots",
message: "routePath is only supported for page and routeSidebar slots",
path: ["routePath"],
});
}
if (value.type === "routeSidebar" && !value.routePath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "routeSidebar slots require routePath",
path: ["routePath"],
});
}
@@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({
database: pluginDatabaseDeclarationSchema.optional(),
apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(),
environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(),
agents: z.array(pluginManagedAgentDeclarationSchema).optional(),
projects: z.array(pluginManagedProjectDeclarationSchema).optional(),
routines: z.array(pluginManagedRoutineDeclarationSchema).optional(),
localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(),
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
ui: z.object({
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
@@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.agents && manifest.agents.length > 0) {
if (!manifest.capabilities.includes("agents.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'agents.managed' is required when managed agents are declared",
path: ["capabilities"],
});
}
}
if (manifest.projects && manifest.projects.length > 0) {
if (!manifest.capabilities.includes("projects.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'projects.managed' is required when managed projects are declared",
path: ["capabilities"],
});
}
}
if (manifest.routines && manifest.routines.length > 0) {
if (!manifest.capabilities.includes("routines.managed")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'routines.managed' is required when managed routines are declared",
path: ["capabilities"],
});
}
}
if (manifest.localFolders && manifest.localFolders.length > 0) {
if (!manifest.capabilities.includes("local.folders")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Capability 'local.folders' is required when local folders are declared",
path: ["capabilities"],
});
}
}
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
if (manifest.jobs && manifest.jobs.length > 0) {
if (!manifest.capabilities.includes("jobs.schedule")) {
@@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({
}
}
if (manifest.localFolders) {
const folderKeys = manifest.localFolders.map((folder) => folder.folderKey);
const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["localFolders"],
});
}
}
if (manifest.agents) {
const agentKeys = manifest.agents.map((agent) => agent.agentKey);
const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["agents"],
});
}
}
if (manifest.projects) {
const projectKeys = manifest.projects.map((project) => project.projectKey);
const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["projects"],
});
}
}
if (manifest.routines) {
const routineKeys = manifest.routines.map((routine) => routine.routineKey);
const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i);
if (duplicates.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`,
path: ["routines"],
});
}
}
// UI slot ids must be unique within the plugin (namespaced at runtime)
if (manifest.ui) {
if (manifest.ui.slots) {

View File

@@ -3,7 +3,17 @@ import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { randomUUID } from "node:crypto";
import { createDb, companies, agents, costEvents, financeEvents, issues, projects } from "@paperclipai/db";
import {
createDb,
companies,
agents,
activityLog,
costEvents,
financeEvents,
heartbeatRuns,
issues,
projects,
} from "@paperclipai/db";
import { costService } from "../services/costs.ts";
import { financeService } from "../services/finance.ts";
import {
@@ -69,6 +79,8 @@ const mockCostService = vi.hoisted(() => ({
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
}),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
@@ -231,7 +243,9 @@ describe("cost routes", () => {
expect(res.status).toBe(200);
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1");
expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1", {
excludeRoot: false,
});
expect(res.body).toEqual({
issueId: "issue-1",
issueCount: 1,
@@ -240,6 +254,8 @@ describe("cost routes", () => {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
runCount: 0,
runtimeMs: 0,
});
});
@@ -393,6 +409,8 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
afterEach(async () => {
await db.delete(financeEvents);
await db.delete(costEvents);
await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(projects);
await db.delete(agents);
@@ -612,9 +630,173 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => {
inputTokens: 60,
cachedInputTokens: 6,
outputTokens: 12,
runCount: 0,
runtimeMs: 0,
});
});
it("aggregates run wall-clock duration across the recursive issue tree", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const siblingIssueId = 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: "Run Agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Root",
status: "in_progress",
priority: "medium",
issueNumber: 1,
identifier: "TST-1",
},
{
id: childIssueId,
companyId,
parentId: rootIssueId,
title: "Child",
status: "in_progress",
priority: "medium",
issueNumber: 2,
identifier: "TST-2",
},
{
id: grandchildIssueId,
companyId,
parentId: childIssueId,
title: "Grandchild",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "TST-3",
},
{
id: siblingIssueId,
companyId,
title: "Sibling",
status: "done",
priority: "medium",
issueNumber: 4,
identifier: "TST-4",
},
]);
const linkedViaContextRunId = randomUUID();
const linkedViaActivityRunId = randomUUID();
const grandchildRunId = randomUUID();
const siblingRunId = randomUUID();
const livePartialRunId = randomUUID();
await db.insert(heartbeatRuns).values([
// 60s run linked to root via contextSnapshot.issueId
{
id: linkedViaContextRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:00:00.000Z"),
finishedAt: new Date("2026-04-10T00:01:00.000Z"),
contextSnapshot: { issueId: rootIssueId },
},
// 120s run linked to child via activity_log
{
id: linkedViaActivityRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:05:00.000Z"),
finishedAt: new Date("2026-04-10T00:07:00.000Z"),
},
// 30s run linked to grandchild
{
id: grandchildRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:10:00.000Z"),
finishedAt: new Date("2026-04-10T00:10:30.000Z"),
contextSnapshot: { issueId: grandchildIssueId },
},
// sibling run NOT under root should be excluded
{
id: siblingRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "completed",
startedAt: new Date("2026-04-10T00:20:00.000Z"),
finishedAt: new Date("2026-04-10T00:21:00.000Z"),
contextSnapshot: { issueId: siblingIssueId },
},
// Still-running run on child (no finishedAt) should contribute (now - startedAt)
{
id: livePartialRunId,
companyId,
agentId,
invocationSource: "on_demand",
status: "running",
startedAt: new Date(Date.now() - 5_000),
contextSnapshot: { issueId: childIssueId },
},
]);
await db.insert(activityLog).values({
companyId,
runId: linkedViaActivityRunId,
actorType: "agent",
actorId: agentId,
agentId,
action: "issue.checked_out",
entityType: "issue",
entityId: childIssueId,
details: {},
});
const summary = await costs.issueTreeSummary(companyId, rootIssueId);
expect(summary.issueCount).toBe(3);
// 3 finished runs in tree (root, child via activity, grandchild) + 1 live run
expect(summary.runCount).toBe(4);
// 60s + 120s + 30s = 210s = 210_000ms from finished runs.
// Live run adds ~5_000ms; allow some slack so the assertion isn't flaky.
expect(summary.runtimeMs).toBeGreaterThanOrEqual(210_000 + 4_000);
expect(summary.runtimeMs).toBeLessThan(210_000 + 60_000);
// excludeRoot drops the root issue's own runs (the 60s contextSnapshot run)
// while keeping the child + grandchild runs and any live child run.
const descendantsOnly = await costs.issueTreeSummary(companyId, rootIssueId, {
excludeRoot: true,
});
expect(descendantsOnly.issueCount).toBe(2);
expect(descendantsOnly.runCount).toBe(3);
// 120s + 30s = 150s + ~5s live run
expect(descendantsOnly.runtimeMs).toBeGreaterThanOrEqual(150_000 + 4_000);
expect(descendantsOnly.runtimeMs).toBeLessThan(150_000 + 60_000);
});
it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => {
const companyId = randomUUID();

View File

@@ -109,7 +109,7 @@ function registerModuleMocks() {
}));
}
async function createApp() {
async function createApp(db: unknown = {}) {
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -126,7 +126,7 @@ async function createApp() {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(db as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -266,6 +266,88 @@ describe("issue activity event routes", () => {
});
}, 15_000);
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const handoffActivityRow = {
entityId: issue.id,
action: "issue.successful_run_handoff_required",
agentId: issue.assigneeAgentId,
runId: "run-1",
details: {
sourceRunId: "run-1",
correctiveRunId: "run-2",
},
createdAt: new Date("2026-05-01T00:00:00.000Z"),
};
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [handoffActivityRow],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ status: "done" });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.successful_run_handoff_resolved",
entityId: issue.id,
details: expect.objectContaining({
identifier: "PAP-580",
sourceRunId: "run-1",
correctiveRunId: "run-2",
resolvedByStatus: "done",
}),
}),
);
});
});
it("does not log successful_run_handoff_resolved when status stays in_progress", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const dbMock = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => [],
}),
}),
}),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ title: "Updated title" });
expect(res.status).toBe(200);
expect(mockLogActivity).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "issue.successful_run_handoff_resolved" }),
);
});
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
const existingPolicy = normalizeIssueExecutionPolicy({
stages: [

View File

@@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort());
});
it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const normalIssueId = randomUUID();
const pluginVisibleIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Plugin Runner",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Plugin operations",
status: "in_progress",
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
},
{
id: pluginVisibleIssueId,
companyId,
title: "Plugin-visible issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:feature",
},
{
id: operationIssueId,
companyId,
projectId,
title: "Plugin operation issue",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
originKind: "plugin:paperclip.missions:operation",
originId: "mission-alpha:operation-1",
},
]);
const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id);
expect(defaultIssueIds).toContain(normalIssueId);
expect(defaultIssueIds).toContain(pluginVisibleIssueId);
expect(defaultIssueIds).not.toContain(operationIssueId);
const inboxIssueIds = (await svc.list(companyId, {
assigneeAgentId: agentId,
status: "todo,in_progress,blocked",
includeRoutineExecutions: true,
})).map((issue) => issue.id);
expect(inboxIssueIds).toContain(normalIssueId);
expect(inboxIssueIds).not.toContain(operationIssueId);
await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" }))
.resolves.toEqual([expect.objectContaining({ id: operationIssueId })]);
const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id);
expect(projectIssueIds).toContain(operationIssueId);
const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id);
expect(advancedIssueIds).toContain(operationIssueId);
});
it("excludes plugin operation issues from unread inbox counts", async () => {
const companyId = randomUUID();
const userId = "board-user";
const otherUserId = "other-user";
const normalIssueId = randomUUID();
const operationIssueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values([
{
id: normalIssueId,
companyId,
title: "Normal touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
},
{
id: operationIssueId,
companyId,
title: "Plugin operation touched issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
originKind: "plugin:paperclip.missions:operation",
},
]);
await db.insert(issueComments).values([
{
companyId,
issueId: normalIssueId,
authorUserId: otherUserId,
body: "Unread normal update.",
},
{
companyId,
issueId: operationIssueId,
authorUserId: otherUserId,
body: "Unread operation update.",
},
]);
await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";

View File

@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
createDb,
@@ -25,9 +25,11 @@ import {
validatePluginRuntimeExecute,
validatePluginRuntimeQuery,
} from "../services/plugin-database.js";
import { pluginLoader } from "../services/plugin-loader.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const multiMigrationPluginKey = "paperclip.dbfixture";
if (!embeddedPostgresSupport.supported) {
console.warn(
@@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
}, 20_000);
afterEach(async () => {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) {
for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) {
const namespace = derivePluginDatabaseNamespace(pluginKey);
await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`));
}
@@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
return packageRoot;
}
async function createInstallablePluginPackage(
pluginManifest: PaperclipPluginManifestV1,
migrationSql: string,
) {
const packageRoot = await createPluginPackage(pluginManifest, migrationSql);
await writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: pluginManifest.id,
version: pluginManifest.version,
type: "module",
paperclipPlugin: { manifest: "./manifest.js" },
}),
"utf8",
);
await writeFile(
path.join(packageRoot, "manifest.js"),
`export default ${JSON.stringify(pluginManifest, null, 2)};\n`,
"utf8",
);
await mkdir(path.join(packageRoot, "dist"), { recursive: true });
await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8");
return packageRoot;
}
async function installPluginRecord(manifest: PaperclipPluginManifestV1) {
const pluginId = randomUUID();
await db.insert(plugins).values({
@@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
};
}
it("applies multi-file plugin migrations through the production validator", async () => {
const pluginManifest = manifest(multiMigrationPluginKey);
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createPluginPackage(
pluginManifest,
`CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`,
);
await writeFile(
path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"),
`CREATE TABLE ${namespace}.derived_rows (
id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id)
);`,
"utf8",
);
const pluginId = await installPluginRecord(pluginManifest);
await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot);
const migrations = await db
.select()
.from(pluginMigrations)
.where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied")));
expect(migrations).toHaveLength(2);
});
it("applies migrations once and allows whitelisted core joins at runtime", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
@@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => {
expect(migration?.status).toBe("failed");
});
it("rolls back plugin install when migration validation fails", async () => {
const pluginManifest = manifest("paperclip.escape");
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);
const packageRoot = await createInstallablePluginPackage(
pluginManifest,
"CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);",
);
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
});
await expect(loader.installPlugin({ localPath: packageRoot }))
.rejects.toThrow(/public\.plugin_escape|public/i);
const installedPlugins = await db
.select()
.from(plugins)
.where(eq(plugins.pluginKey, pluginManifest.id));
const namespaces = await db
.select()
.from(pluginDatabaseNamespaces)
.where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id));
const migrations = await db
.select()
.from(pluginMigrations)
.where(eq(pluginMigrations.pluginKey, pluginManifest.id));
const schemaRows = Array.from(
await db.execute(
sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`,
) as Iterable<{ schema_name: string }>,
);
expect(installedPlugins).toHaveLength(0);
expect(namespaces).toHaveLength(0);
expect(migrations).toHaveLength(0);
expect(schemaRows).toHaveLength(0);
});
it("refreshes persisted manifests from disk before activation", async () => {
const staleManifest = manifest("paperclip.refresh");
const refreshedManifest: PaperclipPluginManifestV1 = {
...staleManifest,
database: {
...staleManifest.database!,
coreReadTables: ["companies"],
},
};
const namespace = derivePluginDatabaseNamespace(refreshedManifest.id);
const packageRoot = await createInstallablePluginPackage(
refreshedManifest,
`
CREATE TABLE ${namespace}.company_refs (
id uuid PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id)
);
`,
);
const pluginId = await installPluginRecord(staleManifest);
await db
.update(plugins)
.set({
packagePath: packageRoot,
status: "ready",
})
.where(eq(plugins.id, pluginId));
const workerManager = {
startWorker: vi.fn().mockResolvedValue(undefined),
stopAll: vi.fn().mockResolvedValue(undefined),
};
const loader = pluginLoader(db, {
enableLocalFilesystem: false,
enableNpmDiscovery: false,
}, {
workerManager,
eventBus: {
forPlugin: vi.fn(() => ({})),
subscriptionCount: vi.fn(() => 0),
},
jobScheduler: {
registerPlugin: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
},
jobStore: {
syncJobDeclarations: vi.fn().mockResolvedValue(undefined),
},
toolDispatcher: {
registerPluginTools: vi.fn(),
},
lifecycleManager: {
markError: vi.fn().mockResolvedValue(undefined),
},
buildHostHandlers: vi.fn(() => ({})),
instanceInfo: {
instanceId: "test-instance",
hostVersion: "1.0.0",
deploymentMode: "authenticated",
deploymentExposure: "public",
},
} as never);
const result = await loader.loadSingle(pluginId);
expect(result.success).toBe(true);
expect(workerManager.startWorker).toHaveBeenCalledWith(
pluginId,
expect.objectContaining({
databaseNamespace: namespace,
env: {
PAPERCLIP_DEPLOYMENT_MODE: "authenticated",
PAPERCLIP_DEPLOYMENT_EXPOSURE: "public",
},
manifest: expect.objectContaining({
database: expect.objectContaining({ coreReadTables: ["companies"] }),
}),
}),
);
const [plugin] = await db
.select()
.from(plugins)
.where(eq(plugins.id, pluginId));
expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]);
});
it("rejects checksum changes for already applied migrations", async () => {
const pluginManifest = manifest();
const namespace = derivePluginDatabaseNamespace(pluginManifest.id);

View 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();
}
});
});

View 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",
});
});
});

View 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",
}));
});
});

View File

@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
@@ -11,6 +14,9 @@ import {
heartbeatRuns,
issueRelations,
issues,
pluginManagedResources,
plugins,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
@@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) {
describeEmbeddedPostgres("plugin orchestration APIs", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-");
@@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
}, 20_000);
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
await db.delete(activityLog);
await db.delete(costEvents);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(pluginManagedResources);
await db.delete(projects);
await db.delete(plugins);
await db.delete(agents);
await db.delete(companies);
});
@@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return { companyId, agentId };
}
async function makeLocalRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-"));
tempRoots.push(root);
return root;
}
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID();
@@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions");
});
it("creates plugin operation issues with the generic operation origin", async () => {
const { companyId } = await seedCompanyAndAgent();
const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub());
const issue = await services.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "mission-alpha:operation-1",
});
expect(issue.originKind).toBe("plugin:paperclip.missions:operation");
expect(issue.originId).toBe("mission-alpha:operation-1");
});
it("lets bootstrap-style actions initialize required local folders from an empty root", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
version: "0.1.0",
manifestJson: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
status: "ready",
});
const root = await makeLocalRoot();
const services = buildHostServices(
db,
pluginId,
"paperclipai.plugin-llm-wiki",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclipai.plugin-llm-wiki",
apiVersion: 1,
version: "0.1.0",
displayName: "LLM Wiki",
description: "Local-file LLM Wiki plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "wiki-root",
displayName: "Wiki root",
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
},
],
},
},
);
const configured = await services.localFolders.configure({
companyId,
folderKey: "wiki-root",
path: root,
access: "readWrite",
requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"],
requiredFiles: ["WIKI.md", "AGENTS.md"],
});
expect(configured.healthy).toBe(false);
expect(configured.missingDirectories).toEqual([]);
expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]);
await fs.rm(path.join(root, "raw"), { recursive: true, force: true });
await fs.rm(path.join(root, "wiki"), { recursive: true, force: true });
await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" }))
.rejects.toThrow("Local folder is not healthy");
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "WIKI.md",
contents: "# Wiki\n",
});
await services.localFolders.writeTextAtomic({
companyId,
folderKey: "wiki-root",
relativePath: "AGENTS.md",
contents: "# Agents\n",
});
const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" });
expect(finalStatus.healthy).toBe(true);
await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({});
await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({});
await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n");
});
it("rejects worker local-folder access for undeclared manifest keys", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.local-folders",
packageName: "@paperclip/plugin-local-folders",
version: "0.1.0",
manifestJson: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
status: "ready",
});
const services = buildHostServices(
db,
pluginId,
"paperclip.local-folders",
createEventBusStub(),
undefined,
{
manifest: {
id: "paperclip.local-folders",
apiVersion: 1,
version: "0.1.0",
displayName: "Local Folders",
description: "Local folder fixture",
author: "Paperclip",
categories: ["automation"],
capabilities: ["local.folders"],
entrypoints: { worker: "./dist/worker.js" },
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
},
],
},
},
);
await expect(services.localFolders.configure({
companyId,
folderKey: "ssh",
path: "/tmp",
access: "read",
})).rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.status({ companyId, folderKey: "ssh" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" }))
.rejects.toThrow("Local folder key is not declared");
await expect(services.localFolders.writeTextAtomic({
companyId,
folderKey: "ssh",
relativePath: "id_rsa",
contents: "secret",
})).rejects.toThrow("Local folder key is not declared");
});
it("resolves plugin-managed projects by stable key without overwriting user edits", async () => {
const { companyId } = await seedCompanyAndAgent();
const pluginId = randomUUID();
await db.insert(plugins).values({
id: pluginId,
pluginKey: "paperclip.missions",
packageName: "@paperclip/plugin-missions",
version: "0.1.0",
apiVersion: 1,
categories: ["automation"],
status: "ready",
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.1.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Mission Operations",
description: "Plugin operation inspection area",
status: "in_progress",
color: "#14b8a6",
settings: { surface: "operations" },
}],
},
});
const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub());
const missing = await services.projects.getManaged({ companyId, projectKey: "operations" });
expect(missing.status).toBe("missing");
expect(missing.projectId).toBeNull();
await expect(
db
.select()
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, pluginId),
eq(pluginManagedResources.resourceKind, "project"),
eq(pluginManagedResources.resourceKey, "operations"),
)),
).resolves.toHaveLength(0);
const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(created.status).toBe("created");
expect(created.projectId).toEqual(expect.any(String));
expect(created.project?.managedByPlugin).toMatchObject({
pluginId,
pluginKey: "paperclip.missions",
pluginDisplayName: "Missions",
resourceKind: "project",
resourceKey: "operations",
});
await db
.update(projects)
.set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() })
.where(eq(projects.id, created.projectId!));
await db
.update(plugins)
.set({
manifestJson: {
id: "paperclip.missions",
apiVersion: 1,
version: "0.2.0",
displayName: "Missions",
description: "Mission orchestration",
author: "Paperclip",
categories: ["automation"],
capabilities: ["projects.managed"],
entrypoints: { worker: "./dist/worker.js" },
projects: [{
projectKey: "operations",
displayName: "Upgraded Default Name",
description: "Upgraded default description",
status: "planned",
color: "#f97316",
settings: { surface: "operations", upgraded: true },
}],
},
updatedAt: new Date(),
})
.where(eq(plugins.id, pluginId));
const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" });
expect(resolved.status).toBe("resolved");
expect(resolved.projectId).toBe(created.projectId);
expect(resolved.project?.name).toBe("Renamed by operator");
expect(resolved.project?.description).toBe("User-owned text");
expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({
displayName: "Upgraded Default Name",
settings: { upgraded: true },
});
});
it("asserts checkout ownership for run-scoped plugin actions", async () => {
const { companyId, agentId } = await seedCompanyAndAgent();
const issueId = randomUUID();

View File

@@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
upsertConfig: vi.fn(),
getCompanySettings: vi.fn(),
upsertCompanySettings: vi.fn(),
}));
const mockLifecycle = vi.hoisted(() => ({
@@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => {
}, 20_000);
});
describe.sequential("plugin local folder routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegistry.getCompanySettings.mockResolvedValue(null);
});
function readyLocalFolderPlugin() {
mockRegistry.getById.mockResolvedValue({
id: pluginId,
pluginKey: "paperclip.example",
version: "1.0.0",
status: "ready",
manifestJson: {
id: "paperclip.example",
capabilities: ["local.folders"],
localFolders: [
{
folderKey: "content-root",
displayName: "Content root",
access: "readWrite",
requiredDirectories: ["docs"],
requiredFiles: ["README.md"],
},
],
},
});
}
it("rejects validation for undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
it("rejects saving undeclared local folder keys", async () => {
readyLocalFolderPlugin();
const { app } = await createApp(boardActor());
const res = await request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`)
.send({ path: "/tmp" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("Local folder key is not declared");
expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled();
});
});
describe.sequential("plugin tool and bridge authz", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => {
const pluginId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const agentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "33333333-3333-4333-8333-333333333334";
const runId = "44444444-4444-4444-8444-444444444444";
const issueId = "55555555-5555-4555-8555-555555555555";
@@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => {
}));
});
it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => {
const apiRoutes = manifest([
{
routeKey: "issue.advance",
method: "POST",
path: "/issues/:issueId/advance",
auth: "agent",
capability: "api.routes.register",
checkoutPolicy: "required-for-agent-in-progress",
companyResolution: { from: "issue", param: "issueId" },
},
]);
mockIssueService.getById.mockResolvedValue({
id: issueId,
companyId,
status: "in_progress",
assigneeAgentId: agentId,
});
const { app, workerManager } = await createApp({
actor: {
type: "agent",
agentId: peerAgentId,
companyId,
runId,
source: "agent_key",
},
plugin: {
id: pluginId,
pluginKey: apiRoutes.id,
status: "ready",
manifestJson: apiRoutes,
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`)
.send({ step: "next" });
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({
routeKey: "issue.advance",
params: { issueId },
body: { step: "next" },
actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }),
companyId,
}));
});
it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => {
const apiRoutes = manifest([
{

View File

@@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => {
).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration");
});
it("supports generic plugin operation issue visibility in the test harness", async () => {
const companyId = randomUUID();
const harness = createTestHarness({
manifest: manifest(["issues.create"]),
});
const created = await harness.ctx.issues.create({
companyId,
title: "Background operation",
surfaceVisibility: "plugin_operation",
originId: "operation-1",
});
expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation");
expect(created.originId).toBe("operation-1");
});
it("enforces checkout and wakeup capabilities in the test harness", async () => {
const companyId = randomUUID();
const agentId = randomUUID();

View File

@@ -3134,6 +3134,130 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
expect(persisted?.healthStatus).toBe("unknown");
expect(persisted?.stoppedAt).toBeTruthy();
});
it("restarts a stopped auto-port service on the same port when it is available", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-port-reuse-"));
const companyId = randomUUID();
const agentId = randomUUID();
const projectId = randomUUID();
const executionWorkspaceId = 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: "Codex Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime port reuse test",
status: "active",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Execution workspace port reuse test",
status: "active",
cwd: workspaceRoot,
providerType: "local_fs",
providerRef: workspaceRoot,
});
const actor = {
id: agentId,
name: "Codex Coder",
companyId,
};
const workspace = {
...buildWorkspace(workspaceRoot),
projectId,
workspaceId: null,
};
const config = {
workspaceRuntime: {
services: [
{
name: "web",
command:
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
port: { type: "auto" },
readiness: {
type: "http",
urlTemplate: "http://127.0.0.1:{{port}}",
timeoutSec: 10,
intervalMs: 100,
},
expose: {
type: "url",
urlTemplate: "http://127.0.0.1:{{port}}",
},
lifecycle: "shared",
reuseScope: "execution_workspace",
stopPolicy: {
type: "manual",
},
},
],
},
};
const first = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(first).toHaveLength(1);
expect(first[0]?.port).toBeGreaterThan(0);
await expect(fetch(first[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
await expect(fetch(first[0]!.url!)).rejects.toThrow();
const second = await startRuntimeServicesForWorkspaceControl({
db,
actor,
issue: null,
workspace,
executionWorkspaceId,
config,
adapterEnv: {},
});
expect(second).toHaveLength(1);
expect(second[0]?.id).toBe(first[0]?.id);
expect(second[0]?.port).toBe(first[0]?.port);
expect(second[0]?.url).toBe(first[0]?.url);
await expect(fetch(second[0]!.url!)).resolves.toMatchObject({ ok: true });
await stopRuntimeServicesForExecutionWorkspace({
db,
executionWorkspaceId,
workspaceCwd: workspace.cwd,
});
});
});
describe("normalizeAdapterManagedRuntimeServices", () => {

View File

@@ -253,6 +253,8 @@ export async function createApp(
instanceInfo: {
instanceId: opts.instanceId ?? "default",
hostVersion: opts.hostVersion ?? "0.0.0",
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
},
buildHostHandlers: (pluginId, manifest) => {
const notifyWorker = (method: string, params: unknown) => {
@@ -261,6 +263,7 @@ export async function createApp(
};
const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, {
pluginWorkerManager: workerManager,
manifest,
});
hostServicesDisposers.set(pluginId, () => services.dispose());
return createHostClientHandlers({

View File

@@ -145,7 +145,8 @@ export function costRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
res.json(summary);
});

View File

@@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -32,6 +33,7 @@ import {
isClosedIsolatedExecutionWorkspace,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type ExecutionWorkspace,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
@@ -78,6 +80,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { redactSensitiveText } from "../redaction.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
@@ -113,6 +116,105 @@ type ExecutionStageWakeContext = {
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
type SuccessfulRunHandoffActivityRow = {
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readNonEmptyString(details.detectedProgressSummary)
?? readNonEmptyString(details.detected_progress_summary)
?? null;
return {
state,
required: state === "required",
sourceRunId:
readNonEmptyString(details.sourceRunId)
?? readNonEmptyString(details.source_run_id)
?? readNonEmptyString(details.resumeFromRunId)
?? row.runId
?? null,
correctiveRunId:
readNonEmptyString(details.correctiveRunId)
?? readNonEmptyString(details.corrective_run_id)
?? (state !== "required" ? row.runId : null),
assigneeAgentId:
readNonEmptyString(details.assigneeAgentId)
?? readNonEmptyString(details.agentId)
?? row.agentId
?? null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffStates(
db: Db,
companyId: string,
issueIds: string[],
): Promise<Map<string, SuccessfulRunHandoffState>> {
if (issueIds.length === 0) return new Map();
const rows = await db
.select({
entityId: activityLog.entityId,
action: activityLog.action,
agentId: activityLog.agentId,
runId: activityLog.runId,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIds),
inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]),
))
.orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[];
const states = new Map<string, SuccessfulRunHandoffState>();
for (const row of rows) {
if (states.has(row.entityId)) continue;
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return states;
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
@@ -1020,11 +1122,14 @@ export function issueRoutes(
descendantOf: req.query.descendantOf as string | undefined,
labelId: req.query.labelId as string | undefined,
originKind: req.query.originKind as string | undefined,
originKindPrefix: req.query.originKindPrefix as string | undefined,
originId: req.query.originId as string | undefined,
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
excludeRoutineExecutions:
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
includePluginOperations:
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
q: req.query.q as string | undefined,
limit,
@@ -2432,6 +2537,33 @@ export function issueRoutes(
},
});
if (existing.status === "in_progress" && issue.status !== existing.status && issue.status !== "in_progress") {
await listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id])
.then(async (handoffStates) => {
const handoff = handoffStates.get(issue.id);
if (handoff?.state !== "required") return;
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.successful_run_handoff_resolved",
entityType: "issue",
entityId: issue.id,
details: {
identifier: issue.identifier,
sourceRunId: handoff.sourceRunId,
correctiveRunId: handoff.correctiveRunId,
resolvedByStatus: issue.status,
},
});
})
.catch((err) => {
logger.warn({ err, issueId: issue.id }, "failed to log successful run handoff resolution");
});
}
if (Array.isArray(req.body.blockedByIssueIds)) {
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);

View File

@@ -66,6 +66,13 @@ import {
getActorInfo,
} from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
import {
findLocalFolderDeclaration,
getStoredLocalFolders,
inspectPluginLocalFolder,
requireLocalFolderDeclaration,
setStoredLocalFolder,
} from "../services/plugin-local-folders.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/** UI slot declaration extracted from plugin manifest */
@@ -2379,6 +2386,152 @@ export function pluginRoutes(
}
});
// ===========================================================================
// Company-scoped trusted local folders
// ===========================================================================
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const folderKeys = declarations.map((declaration) => declaration.folderKey);
const statuses = await Promise.all(folderKeys.map((folderKey) =>
inspectPluginLocalFolder({
folderKey,
declaration: findLocalFolderDeclaration(declarations, folderKey),
storedConfig: storedFolders[folderKey] ?? null,
})));
res.json({
pluginId: plugin.id,
companyId,
declarations,
folders: statuses,
});
});
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: storedFolders[folderKey] ?? null,
});
res.json(status);
});
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
overrideConfig: {
path: body.path,
},
});
res.json(status);
});
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const existing = await registry.getCompanySettings(plugin.id, companyId);
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
overrideConfig: {
path: body.path,
},
});
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
path: body.path,
access: status.access,
requiredDirectories: status.requiredDirectories,
requiredFiles: status.requiredFiles,
});
await registry.upsertCompanySettings(plugin.id, companyId, {
enabled: existing?.enabled ?? true,
settingsJson: nextSettings,
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
});
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
companyId,
folderKey,
healthy: status.healthy,
});
res.json(status);
});
// ===========================================================================
// Plugin health dashboard — aggregated diagnostics for the settings page
// ===========================================================================

View File

@@ -1,7 +1,7 @@
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
@@ -135,18 +135,53 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
};
},
issueTreeSummary: async (companyId: string, issueId: string) => {
issueTreeSummary: async (
companyId: string,
issueId: string,
options: { excludeRoot?: boolean } = {},
) => {
// Callers must resolve and authorize a visible root issue before invoking this.
// The route does that so zero counts are not mistaken for a missing root.
const childIssues = alias(issues, "child");
const issueTreeCondition = sql<boolean>`
${issues.id} IN (
WITH RECURSIVE issue_tree(id) AS (
// The seed of the recursive CTE: when excludeRoot is true, start from
// the direct children so the root issue itself is not counted.
const cteSeed = options.excludeRoot
? sql`
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`
: sql`
SELECT ${issues.id}
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`;
const cteSeedText = options.excludeRoot
? sql`
SELECT (${issues.id})::text AS id
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.parentId} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`
: sql`
SELECT (${issues.id})::text AS id
FROM ${issues}
WHERE ${issues.companyId} = ${companyId}
AND ${issues.id} = ${issueId}
AND ${issues.hiddenAt} IS NULL
`;
const issueTreeCondition = sql<boolean>`
${issues.id} IN (
WITH RECURSIVE issue_tree(id) AS (
${cteSeed}
UNION ALL
SELECT ${childIssues.id}
FROM ${issues} ${childIssues}
@@ -158,38 +193,80 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
)
`;
const [row] = await db
.select({
issueCount: sql<number>`count(distinct ${issues.id})::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(issues)
.leftJoin(
costEvents,
and(
eq(costEvents.companyId, companyId),
eq(costEvents.issueId, issues.id),
),
const runSummarySql = sql`
WITH RECURSIVE issue_tree(id) AS (
${cteSeedText}
UNION ALL
SELECT (${childIssues.id})::text
FROM ${issues} ${childIssues}
JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id
WHERE ${childIssues.companyId} = ${companyId}
AND ${childIssues.hiddenAt} IS NULL
)
.where(
and(
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
issueTreeCondition,
SELECT
count(distinct ${heartbeatRuns.id})::int AS "runCount",
coalesce(sum(extract(epoch from (coalesce(${heartbeatRuns.finishedAt}, now()) - ${heartbeatRuns.startedAt})) * 1000), 0)::double precision AS "runtimeMs"
FROM ${heartbeatRuns}
WHERE ${heartbeatRuns.companyId} = ${companyId}
AND ${heartbeatRuns.startedAt} IS NOT NULL
AND (
${heartbeatRuns.contextSnapshot} ->> 'issueId' IN (SELECT id FROM issue_tree)
OR EXISTS (
SELECT 1
FROM ${activityLog}
JOIN issue_tree ON ${activityLog.entityId} = issue_tree.id
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.runId} = ${heartbeatRuns.id}
)
)
`;
// Run cost-event aggregation and run-duration aggregation in parallel.
// They're separate queries because cost_events fan out per-event and
// joining heartbeat_runs through them would double-count run durations.
const [costRowResult, runRowResult] = await Promise.all([
db
.select({
issueCount: sql<number>`count(distinct ${issues.id})::int`,
costCents: sumAsNumber(costEvents.costCents),
inputTokens: sumAsNumber(costEvents.inputTokens),
cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens),
outputTokens: sumAsNumber(costEvents.outputTokens),
})
.from(issues)
.leftJoin(
costEvents,
and(
eq(costEvents.companyId, companyId),
eq(costEvents.issueId, issues.id),
),
)
.where(
and(
eq(issues.companyId, companyId),
isNull(issues.hiddenAt),
issueTreeCondition,
),
),
);
db.execute(runSummarySql),
]);
const costRow = costRowResult[0];
const runRow = Array.isArray(runRowResult)
? (runRowResult[0] as { runCount?: number | string | null; runtimeMs?: number | string | null } | undefined)
: undefined;
return {
issueId,
issueCount: Number(row?.issueCount ?? 0),
issueCount: Number(costRow?.issueCount ?? 0),
includeDescendants: true,
costCents: Number(row?.costCents ?? 0),
inputTokens: Number(row?.inputTokens ?? 0),
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
outputTokens: Number(row?.outputTokens ?? 0),
costCents: Number(costRow?.costCents ?? 0),
inputTokens: Number(costRow?.inputTokens ?? 0),
cachedInputTokens: Number(costRow?.cachedInputTokens ?? 0),
outputTokens: Number(costRow?.outputTokens ?? 0),
runCount: Number(runRow?.runCount ?? 0),
runtimeMs: Number(runRow?.runtimeMs ?? 0),
};
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,6 +108,11 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
processGroupId: number | null;
}
type StoppedRuntimeServiceReuseCandidate = {
id: string;
port: number | null;
};
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
@@ -1815,6 +1820,33 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe
});
}
async function findStoppedRuntimeServiceReuseCandidate(input: {
db?: Db;
companyId: string;
reuseKey: string | null;
}): Promise<StoppedRuntimeServiceReuseCandidate | null> {
if (!input.db || !input.reuseKey) return null;
const row = await input.db
.select({
id: workspaceRuntimeServices.id,
port: workspaceRuntimeServices.port,
})
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, input.companyId),
eq(workspaceRuntimeServices.reuseKey, input.reuseKey),
eq(workspaceRuntimeServices.provider, "local_process"),
eq(workspaceRuntimeServices.status, "stopped"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
return row ?? null;
}
function clearIdleTimer(record: RuntimeServiceRecord) {
if (!record.idleTimer) return;
clearTimeout(record.idleTimer);
@@ -1927,9 +1959,20 @@ async function startLocalRuntimeService(input: {
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
const explicitPort = identity.explicitPort;
const identityPort = identity.identityPort;
const stoppedReuseCandidate = await findStoppedRuntimeServiceReuseCandidate({
db: input.db,
companyId: input.agent.companyId,
reuseKey: input.reuseKey,
});
const reusableStoppedPort =
asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port
? (await readLocalServicePortOwner(stoppedReuseCandidate.port))
? null
: stoppedReuseCandidate.port
: null;
const port =
asString(portConfig.type, "") === "auto"
? await allocatePort()
? (reusableStoppedPort ?? await allocatePort())
: explicitPort > 0
? explicitPort
: null;
@@ -2073,7 +2116,7 @@ async function startLocalRuntimeService(input: {
}
const record: RuntimeServiceRecord = {
id: randomUUID(),
id: stoppedReuseCandidate?.id ?? randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,

View File

@@ -107,6 +107,7 @@ function boardRoutes() {
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
@@ -129,7 +130,7 @@ function boardRoutes() {
<Route path="u/:userSlug" element={<UserProfile />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
<Route path=":pluginRoutePath/*" element={<PluginPage />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
);
@@ -304,6 +305,7 @@ export function App() {
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />

View File

@@ -43,6 +43,7 @@ export const issuesApi = {
workspaceId?: string;
executionWorkspaceId?: string;
originKind?: string;
originKindPrefix?: string;
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
@@ -66,6 +67,7 @@ export const issuesApi = {
if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId);
if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId);
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?.descendantOf) params.set("descendantOf", filters.descendantOf);
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
@@ -172,7 +174,10 @@ export const issuesApi = {
getComment: (id: string, commentId: string) =>
api.get<IssueComment>(`/issues/${id}/comments/${commentId}`),
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
getCostSummary: (id: string) => api.get<IssueCostSummary>(`/issues/${id}/cost-summary`),
getCostSummary: (id: string, options: { excludeRoot?: boolean } = {}) => {
const qs = options.excludeRoot ? "?excludeRoot=true" : "";
return api.get<IssueCostSummary>(`/issues/${id}/cost-summary${qs}`);
},
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {

View 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"],
},
);
});
});

View File

@@ -14,6 +14,7 @@ import type {
PluginLauncherDeclaration,
PluginLauncherRenderContextSnapshot,
PluginUiSlotDeclaration,
PluginLocalFolderDeclaration,
PluginRecord,
PluginConfig,
PluginStatus,
@@ -140,6 +141,54 @@ export interface AvailablePluginExample {
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.
*
@@ -337,6 +386,48 @@ export const pluginsApi = {
testConfig: (pluginId: string, configJson: Record<string, unknown>) =>
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
// ===========================================================================

View File

@@ -11,7 +11,7 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
@@ -55,6 +55,20 @@ async function flushReact() {
});
}
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let index = 0; index < attempts; index += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function createRun(index: number) {
return {
id: `run-${index}`,
@@ -71,6 +85,37 @@ function createRun(index: number) {
};
}
function createIssueRun(index: number, issueId: string) {
return {
...createRun(index),
issueId,
};
}
function createIssue(id: string, identifier: string, title: string) {
return {
id,
companyId: "company-1",
identifier,
title,
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
parentId: null,
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
goalId: null,
labels: [],
blockedByIssueIds: [],
blocksIssueIds: [],
createdAt: "2026-04-24T12:00:00.000Z",
updatedAt: "2026-04-24T12:00:00.000Z",
};
}
describe("ActiveAgentsPanel", () => {
let container: HTMLDivElement;
@@ -78,7 +123,7 @@ describe("ActiveAgentsPanel", () => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.get.mockRejectedValue(new Error("Issue not found"));
});
afterEach(() => {
@@ -149,4 +194,42 @@ describe("ActiveAgentsPanel", () => {
root.unmount();
});
});
it("loads exact visible run issues so task names render even when the issue list page would miss them", async () => {
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
createIssueRun(1, "65274215-0000-4000-8000-000000000000"),
]);
mockIssuesApi.get.mockResolvedValue(createIssue(
"65274215-0000-4000-8000-000000000000",
"PAP-3562",
"Phase 4B: Implement LLM Wiki distillation UI",
));
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
await waitForMicrotaskAssertion(() => {
expect(mockIssuesApi.get).toHaveBeenCalledWith("65274215-0000-4000-8000-000000000000");
const issueLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("Phase 4B"),
);
expect(issueLink?.textContent).toBe("PAP-3562 - Phase 4B: Implement LLM Wiki distillation UI");
expect(issueLink?.getAttribute("href")).toBe("/issues/PAP-3562");
});
await act(async () => {
root.unmount();
});
});
});

View File

@@ -1,6 +1,6 @@
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters";
@@ -56,19 +56,28 @@ export function ActiveAgentsPanel({
const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const { data: issues } = useQuery({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: visibleRuns.length > 0,
const visibleIssueIds = useMemo(
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
[visibleRuns],
);
const issueQueries = useQueries({
queries: visibleIssueIds.map((issueId) => ({
queryKey: queryKeys.issues.detail(issueId),
queryFn: () => issuesApi.get(issueId),
staleTime: 30_000,
retry: false,
})),
});
const issueById = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues ?? []) {
map.set(issue.id, issue);
for (const query of issueQueries) {
const issue = query.data;
if (issue) map.set(issue.id, issue);
}
return map;
}, [issues]);
}, [issueQueries]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: visibleRuns,

View File

@@ -7,7 +7,6 @@ import { queryKeys } from "@/lib/queryKeys";
import { useCompany } from "@/context/CompanyContext";
import { useSidebar } from "@/context/SidebarContext";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function CompanySettingsSidebar() {
const { selectedCompany, selectedCompanyId } = useCompany();
@@ -32,11 +31,8 @@ export function CompanySettingsSidebar() {
});
return (
<aside className="w-60 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">
<SidebarCompanyMenu />
</div>
<div className="flex flex-col gap-1 px-3 pb-3 shrink-0">
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
<Link
to="/dashboard"
onClick={() => {

View 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");
});
});

View 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>
);
}

View File

@@ -5,7 +5,6 @@ import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function InstanceSidebar() {
const { data: plugins } = useQuery({
@@ -14,13 +13,12 @@ export function InstanceSidebar() {
});
return (
<aside className="w-60 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">
<SidebarCompanyMenu />
</div>
<div className="flex items-center gap-2 px-5 pb-3 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate text-sm font-bold text-foreground">Instance Settings</span>
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
<span className="flex-1 text-sm font-bold text-foreground truncate">
Instance Settings
</span>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">

View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
describe("IssueBlockedNotice", () => {
it("renders a successful-run next-step notice without requiring blockers", () => {
const node = render(
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toContain("This issue still needs a next step.");
expect(node.textContent).toContain("Corrective wake queued for CodexCoder");
expect(node.textContent).toContain("Detected progress: Updated the plan");
expect(node.textContent).not.toContain("Work on this issue is blocked until");
expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull();
});
it("does not render when the issue is done even if a stale handoff state is required", () => {
const node = render(
<IssueBlockedNotice
issueStatus="done"
blockers={[]}
agentName="CodexCoder"
successfulRunHandoff={{
state: "required",
required: true,
sourceRunId: "12345678-aaaa-bbbb-cccc-123456789abc",
correctiveRunId: null,
assigneeAgentId: "agent-1",
detectedProgressSummary: "Updated the plan and left follow-up work.",
createdAt: "2026-05-01T00:00:00.000Z",
}}
/>,
);
expect(node.textContent).toBe("");
});
it("does not render when the issue is cancelled even if blockers remain", () => {
const node = render(
<IssueBlockedNotice
issueStatus="cancelled"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
]}
/>,
);
expect(node.textContent).toBe("");
});
});

View File

@@ -1,5 +1,6 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
@@ -7,12 +8,18 @@ export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
successfulRunHandoff,
agentName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
agentName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
if (issueStatus === "done" || issueStatus === "cancelled") return null;
const showSuccessfulRunHandoff = successfulRunHandoff?.required === true;
if (!showSuccessfulRunHandoff && blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -61,39 +68,87 @@ export function IssueBlockedNotice({
return (
<div
data-blocker-attention-state={blockerAttention?.state}
data-successful-run-handoff={showSuccessfulRunHandoff ? "required" : undefined}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff ? (
<>
<p className="font-medium leading-5">This issue still needs a next step.</p>
<p className="leading-5">
A run finished successfully, but this issue is still open in{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-[12px] dark:bg-amber-400/15">
in_progress
</code>{" "}
with no clear owner for the next action.
</p>
<ul className="list-disc space-y-1 pl-5 text-xs leading-5 text-amber-900 dark:text-amber-100">
<li>Mark it done or cancelled.</li>
<li>Send it for review or ask for input.</li>
<li>Mark it blocked with a blocker owner.</li>
<li>Delegate follow-up work or queue a continuation.</li>
</ul>
<div className="flex flex-wrap gap-1.5 text-xs">
{successfulRunHandoff.sourceRunId && successfulRunHandoff.assigneeAgentId ? (
<Link
to={`/agents/${successfulRunHandoff.assigneeAgentId}/runs/${successfulRunHandoff.sourceRunId}`}
className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</Link>
) : successfulRunHandoff.sourceRunId ? (
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-amber-950 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
run {successfulRunHandoff.sourceRunId.slice(0, 8)}
</span>
) : null}
<span className="rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 text-amber-900 dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100">
Corrective wake queued for {agentName ?? "the assignee"}
</span>
</div>
{successfulRunHandoff.detectedProgressSummary ? (
<p className="text-xs leading-5 text-amber-800 dark:text-amber-200">
Detected progress: {successfulRunHandoff.detectedProgressSummary}
</p>
) : null}
</>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
{showSuccessfulRunHandoff && (blockers.length > 0 || issueStatus === "blocked") ? (
<div className="border-t border-amber-300/60 pt-1.5 dark:border-amber-500/30" />
) : null}
{blockers.length > 0 || issueStatus === "blocked" ? (
<>
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
</div>
) : null}
{showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</>
) : null}
</div>
</div>

View File

@@ -476,6 +476,59 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn();
const root = renderProperties(container, {
issue: createIssue({
blockedBy: [
{
id: "issue-2",
identifier: "PAP-2",
title: "Existing blocker",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
{
id: "issue-4",
identifier: "PAP-4",
title: "Keep blocker",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
}),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const removeButton = container.querySelector('button[aria-label="Remove PAP-2 as blocker"]');
expect(removeButton).not.toBeNull();
await act(async () => {
removeButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("Remove PAP-2: Existing blocker as a blocker for this issue.");
const confirmButton = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Remove blocker"));
expect(confirmButton).not.toBeUndefined();
await act(async () => {
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-4"] });
act(() => root.unmount());
});
it("shows a green service link above the workspace row for a live non-main workspace", async () => {
mockProjectsApi.list.mockResolvedValue([createProject()]);
const serviceUrl = "http://127.0.0.1:62475";
@@ -530,7 +583,7 @@ describe("IssueProperties", () => {
(link) => link.textContent?.trim() === "View workspace",
);
expect(tasksLink).not.toBeUndefined();
expect(tasksLink?.getAttribute("href")).toBe("/issues?workspace=workspace-1");
expect(tasksLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1/issues");
expect(workspaceLink).not.toBeUndefined();
expect(workspaceLink?.getAttribute("href")).toBe("/execution-workspaces/workspace-1");

View File

@@ -10,7 +10,6 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters";
import { queryKeys } from "../lib/queryKeys";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
import { useProjectOrder } from "../hooks/useProjectOrder";
@@ -32,9 +31,19 @@ import { Identity } from "./Identity";
import { IssueReferencePill } from "./IssueReferencePill";
import { formatDate, cn, projectUrl } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react";
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
@@ -113,10 +122,8 @@ function runningRuntimeServiceWithUrl(
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
}
function issuesWorkspaceFilterHref(workspaceId: string) {
const params = new URLSearchParams();
params.append("workspace", workspaceId);
return `/issues?${params.toString()}`;
function executionWorkspaceIssuesHref(workspaceId: string) {
return `/execution-workspaces/${workspaceId}/issues`;
}
function toDateTimeLocalValue(value: string | null | undefined) {
@@ -144,6 +151,87 @@ function PropertyRow({ label, children }: { label: string; children: React.React
);
}
function RemovableIssueReferencePill({
issue,
onRemove,
}: {
issue: NonNullable<Issue["blockedBy"]>[number];
onRemove: (issueId: string) => void;
}) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const issueLabel = issue.identifier ?? issue.title;
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
const content = (
<>
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
<span className="truncate">{issueLabel}</span>
</>
);
const removeLabel = `Remove ${issueLabel} as blocker`;
const handleRemove = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsConfirmOpen(true);
};
const confirmRemove = () => {
onRemove(issue.id);
setIsConfirmOpen(false);
};
return (
<>
<span
data-mention-kind="issue"
className={cn(
"paperclip-mention-chip paperclip-mention-chip--issue group",
"inline-flex items-center gap-1 rounded-full border border-border py-0.5 pl-1 pr-2 text-xs",
)}
title={issue.title}
aria-label={`Issue ${issueLabel}: ${issue.title}`}
>
<button
type="button"
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-colors transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-ring group-hover:opacity-100"
aria-label={removeLabel}
title={removeLabel}
onClick={handleRemove}
>
<X className="h-3 w-3" />
</button>
{issue.identifier ? (
<Link
to={`/issues/${issueLabel}`}
className="inline-flex min-w-0 items-center gap-1 no-underline hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
aria-label={`Issue ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
) : (
<span className="inline-flex min-w-0 items-center gap-1">{content}</span>
)}
</span>
<Dialog open={isConfirmOpen} onOpenChange={setIsConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove blocker?</DialogTitle>
<DialogDescription>
Remove {confirmLabel} as a blocker for this issue.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="button" variant="destructive" onClick={confirmRemove}>
Remove blocker
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */
function PropertyPicker({
inline,
@@ -331,10 +419,10 @@ export function IssueProperties({
() => isMainIssueWorkspace({ issue, project: issueProject }),
[issue, issueProject],
);
const workspaceFilterId = useMemo(() => {
const workspaceTasksExecutionWorkspaceId = useMemo(() => {
if (!isolatedWorkspacesEnabled) return null;
if (issueUsesMainWorkspace) return null;
return resolveIssueFilterWorkspaceId(issue);
return issue.executionWorkspaceId ?? issue.currentExecutionWorkspace?.id ?? null;
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
const liveWorkspaceService = useMemo(() => {
@@ -1137,6 +1225,9 @@ export function IssueProperties({
: [...blockedByIds, blockedByIssueId];
onUpdate({ blockedByIssueIds: nextBlockedByIds });
};
const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
};
const blockedByContent = (
<>
@@ -1284,7 +1375,7 @@ export function IssueProperties({
<div>
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
@@ -1297,7 +1388,7 @@ export function IssueProperties({
) : (
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
<Popover
open={blockedByOpen}
@@ -1448,10 +1539,10 @@ export function IssueProperties({
</Link>
</PropertyRow>
)}
{workspaceFilterId && (
{workspaceTasksExecutionWorkspaceId && (
<PropertyRow label="Tasks">
<Link
to={issuesWorkspaceFilterHref(workspaceFilterId)}
to={executionWorkspaceIssuesHref(workspaceTasksExecutionWorkspaceId)}
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
View workspace tasks

View File

@@ -41,7 +41,7 @@ import {
resolveIssueWorkspaceName,
type InboxIssueColumn,
} from "../lib/inbox";
import { cn } from "../lib/utils";
import { cn, formatDurationMs, formatTokens } from "../lib/utils";
import {
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
@@ -113,7 +113,7 @@ export type IssueSortField = "status" | "priority" | "title" | "created" | "upda
export type IssueViewState = IssueFilterState & {
sortField: IssueSortField;
sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
groupBy: "status" | "priority" | "assignee" | "project" | "workspace" | "parent" | "none";
viewMode: "list" | "board";
nestingEnabled: boolean;
collapsedGroups: string[];
@@ -363,6 +363,12 @@ interface IssuesListProps {
createIssueLabel?: string;
defaultSortField?: IssueSortField;
showProgressSummary?: boolean;
/**
* When set together with `showProgressSummary`, the progress strip fetches
* the recursive cost-summary for this parent issue and renders aggregate
* tokens + wall-clock runtime for every run in the tree.
*/
parentIssueIdForCostSummary?: string;
enableRoutineVisibilityFilter?: boolean;
hasMoreIssues?: boolean;
isLoadingMoreIssues?: boolean;
@@ -438,9 +444,11 @@ function IssueSearchInput({
function SubIssueProgressSummaryStrip({
summary,
issueLinkState,
parentIssueIdForCostSummary,
}: {
summary: SubIssueProgressSummary;
issueLinkState?: unknown;
parentIssueIdForCostSummary?: string;
}) {
const target = summary.target;
const targetIssue = target?.issue ?? null;
@@ -450,6 +458,21 @@ function SubIssueProgressSummaryStrip({
.map((status) => ({ status, count: summary.countsByStatus[status] ?? 0 }))
.filter((entry) => entry.count > 0);
// Refresh fast enough that the runtime ticks up while a sub-issue is still
// running, but slow enough not to hammer the recursive CTE on idle trees.
const hasInProgress = summary.inProgressCount > 0;
const { data: costSummary } = useQuery({
queryKey: queryKeys.issues.costSummary(parentIssueIdForCostSummary ?? "pending", { excludeRoot: true }),
queryFn: () => issuesApi.getCostSummary(parentIssueIdForCostSummary!, { excludeRoot: true }),
enabled: !!parentIssueIdForCostSummary,
refetchInterval: hasInProgress ? 5_000 : false,
});
const totalTokens = costSummary
? costSummary.inputTokens + costSummary.cachedInputTokens + costSummary.outputTokens
: 0;
const showCostSummary = !!costSummary && (costSummary.runCount > 0 || totalTokens > 0);
return (
<div className="border border-border bg-background p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@@ -464,6 +487,23 @@ function SubIssueProgressSummaryStrip({
<span className="text-muted-foreground">
{summary.blockedCount} blocked
</span>
{showCostSummary && (
<>
<span
className="text-muted-foreground tabular-nums"
title={`${costSummary.runCount.toLocaleString()} run${
costSummary.runCount === 1 ? "" : "s"
} across ${costSummary.issueCount} sub-issue${
costSummary.issueCount === 1 ? "" : "s"
}`}
>
{formatTokens(totalTokens)} tokens
</span>
<span className="text-muted-foreground tabular-nums">
{formatDurationMs(costSummary.runtimeMs)} runtime
</span>
</>
)}
</div>
<div
role="progressbar"
@@ -535,6 +575,7 @@ export function IssuesList({
createIssueLabel,
defaultSortField,
showProgressSummary = false,
parentIssueIdForCostSummary,
enableRoutineVisibilityFilter = false,
hasMoreIssues = false,
isLoadingMoreIssues = false,
@@ -995,6 +1036,22 @@ export function IssuesList({
items: groups[key]!,
}));
}
if (viewState.groupBy === "project") {
const groups = groupBy(filtered, (issue) => issue.projectId ?? "__no_project");
return Object.keys(groups)
.sort((a, b) => {
if (a === "__no_project") return 1;
if (b === "__no_project") return -1;
const labelA = projectById.get(a)?.name ?? a;
const labelB = projectById.get(b)?.name ?? b;
return labelA.localeCompare(labelB);
})
.map((key) => ({
key,
label: key === "__no_project" ? "No Project" : (projectById.get(key)?.name ?? key.slice(0, 8)),
items: groups[key]!,
}));
}
if (viewState.groupBy === "parent") {
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
return Object.keys(groups)
@@ -1035,6 +1092,7 @@ export function IssuesList({
workspaceNameMap,
issueTitleMap,
companyUserLabelMap,
projectById,
]);
useEffect(() => {
@@ -1130,6 +1188,7 @@ export function IssuesList({
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
else defaults.assigneeAgentId = groupKey;
}
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
const parentIssue = issueById.get(groupKey);
if (parentIssue) Object.assign(defaults, buildSubIssueDefaultsForViewer(parentIssue, currentUserId));
@@ -1174,7 +1233,11 @@ export function IssuesList({
return (
<div ref={rootRef} className="space-y-4">
{progressSummary ? (
<SubIssueProgressSummaryStrip summary={progressSummary} issueLinkState={issueLinkState} />
<SubIssueProgressSummaryStrip
summary={progressSummary}
issueLinkState={issueLinkState}
parentIssueIdForCostSummary={parentIssueIdForCostSummary}
/>
) : null}
{/* Toolbar */}
@@ -1306,6 +1369,7 @@ export function IssuesList({
["status", "Status"],
["priority", "Priority"],
["assignee", "Assignee"],
["project", "Project"],
["workspace", "Workspace"],
["parent", "Parent Issue"],
["none", "None"],

View File

@@ -17,6 +17,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = 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";
vi.mock("@/lib/router", () => ({
@@ -24,7 +34,10 @@ vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }),
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => ({ companyPrefix: "PAP" }),
useParams: () => {
const firstSegment = currentPathname.split("/").filter(Boolean)[0];
return { companyPrefix: firstSegment === "instance" ? undefined : firstSegment ?? "PAP" };
},
}));
vi.mock("./CompanyRail", () => ({
@@ -95,6 +108,33 @@ vi.mock("./SidebarAccountMenu", () => ({
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", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
@@ -114,10 +154,10 @@ vi.mock("../context/PanelContext", () => ({
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
companies: mockCompanyState.companies,
loading: false,
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
selectedCompanyId: "company-1",
selectedCompany: mockCompanyState.selectedCompany,
selectedCompanyId: mockCompanyState.selectedCompanyId,
selectionSource: "manual",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
@@ -179,6 +219,9 @@ describe("Layout", () => {
container = document.createElement("div");
document.body.appendChild(container);
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({
status: "ok",
deploymentMode: "authenticated",
@@ -188,6 +231,8 @@ describe("Layout", () => {
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
});
mockPluginSlots.slots = [];
mockPluginSlotContexts.length = 0;
});
afterEach(() => {
@@ -227,6 +272,30 @@ describe("Layout", () => {
it("renders the company settings sidebar on company settings routes", async () => {
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 queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -245,6 +314,213 @@ describe("Layout", () => {
expect(container.textContent).toContain("Company settings sidebar");
expect(container.textContent).not.toContain("Instance sidebar");
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 () => {
root.unmount();

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
@@ -16,6 +17,7 @@ import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { ResizableSidebarPane } from "./ResizableSidebarPane";
import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { useDialogActions } from "../context/DialogContext";
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
@@ -39,9 +41,18 @@ import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils";
import { NotFoundPage } from "../pages/NotFound";
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
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 {
if (typeof window === "undefined") return DEFAULT_INSTANCE_SETTINGS_PATH;
try {
@@ -83,6 +94,38 @@ export function Layout() {
}, [companies, companyPrefix]);
const hasUnknownCompanyPrefix =
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({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
@@ -335,13 +378,16 @@ export function Layout() {
)}
>
<div className="flex flex-1 min-h-0 overflow-hidden">
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
<Sidebar />
)}
<CompanyRail />
<div className="w-60 shrink-0 overflow-hidden">
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
companySidebar
)}
</div>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
@@ -352,20 +398,16 @@ export function Layout() {
) : (
<div className="flex h-full flex-col shrink-0">
<div className="flex flex-1 min-h-0">
<div
className={cn(
"overflow-hidden transition-[width] duration-100 ease-out",
sidebarOpen ? "w-60" : "w-0"
)}
>
<CompanyRail />
<ResizableSidebarPane open={sidebarOpen} resizable className="h-full shrink-0">
{isInstanceSettingsRoute ? (
<InstanceSidebar />
) : isCompanySettingsRoute ? (
<CompanySettingsSidebar />
) : (
<Sidebar />
companySidebar
)}
</div>
</ResizableSidebarPane>
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
// @vitest-environment node
import type { ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -33,7 +33,11 @@ vi.mock("../api/issues", () => ({
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({
defaultOptions: {
queries: {
@@ -54,7 +58,7 @@ function renderMarkdown(children: string, seededIssues: Array<{ identifier: stri
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody>{children}</MarkdownBody>
<MarkdownBody {...props}>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
@@ -279,6 +283,64 @@ describe("MarkdownBody", () => {
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", () => {
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
@@ -294,6 +356,20 @@ describe("MarkdownBody", () => {
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
});
it("renders markdown tables in a horizontally scrollable region", () => {
const html = renderMarkdown([
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
"| --- | --- | --- | --- | --- |",
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
].join("\n"));
expect(html).toContain('class="paperclip-markdown-table-scroll"');
expect(html).toContain('aria-label="Scrollable table"');
expect(html).toContain('tabindex="0"');
expect(html).toContain("<table>");
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
});
it("opens external links in a new tab with safe rel attributes", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");

View File

@@ -19,6 +19,12 @@ interface MarkdownBodyProps {
style?: React.CSSProperties;
softBreaks?: 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) */
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
@@ -78,6 +84,11 @@ const scrollableBlockStyle: React.CSSProperties = {
overflowX: "auto",
};
const tableCellWrapStyle: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "normal",
};
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...wrapAnywhereStyle,
@@ -85,6 +96,13 @@ function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
};
}
function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...tableCellWrapStyle,
...style,
};
}
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
return {
...scrollableBlockStyle,
@@ -111,6 +129,160 @@ function safeMarkdownUrlTransform(url: string): string {
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 {
if (!href) return false;
try {
@@ -321,11 +493,17 @@ export function MarkdownBody({
style,
softBreaks = true,
linkIssueReferences = true,
enableWikiLinks = false,
wikiLinkRoot,
resolveWikiLinkHref,
resolveImageSrc,
onImageClick,
}: MarkdownBodyProps) {
const { theme } = useTheme();
const remarkPlugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm];
if (enableWikiLinks) {
remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
}
if (linkIssueReferences) {
remarkPlugins.push(remarkLinkIssueReferences);
}
@@ -348,13 +526,20 @@ export function MarkdownBody({
{blockquoteChildren}
</blockquote>
),
table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
<div className="paperclip-markdown-table-scroll" role="region" aria-label="Scrollable table" tabIndex={0}>
<table {...tableProps} style={tableStyle as React.CSSProperties | undefined}>
{tableChildren}
</table>
</div>
),
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
<td {...tableCellProps} style={mergeWrapStyle(tableCellStyle as React.CSSProperties | undefined)}>
<td {...tableCellProps} style={mergeTableCellStyle(tableCellStyle as React.CSSProperties | undefined)}>
{tableCellChildren}
</td>
),
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
<th {...tableHeaderProps} style={mergeWrapStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
<th {...tableHeaderProps} style={mergeTableCellStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
{tableHeaderChildren}
</th>
),
@@ -370,7 +555,22 @@ export function MarkdownBody({
{codeChildren}
</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;
if (issueRef) {
return (

View File

@@ -411,6 +411,84 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("applies project and execution workspace defaults for normal new issues", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
workspaces: [
{
id: "project-workspace-1",
name: "Primary",
isPrimary: true,
},
{
id: "project-workspace-2",
name: "Isolated checkout",
isPrimary: false,
},
],
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-100",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-100",
cwd: "/tmp/workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
title: "Follow-up issue",
projectId: "project-1",
projectWorkspaceId: "project-workspace-2",
executionWorkspaceId: "workspace-1",
};
const { root } = renderDialog(container);
await flush();
expect(container.textContent).toContain("New issue");
expect(container.textContent).not.toContain("New sub-issue");
expect(container.textContent).toContain("Reusing PAP-100");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Issue"));
expect(submitButton).not.toBeUndefined();
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Follow-up issue",
projectId: "project-1",
projectWorkspaceId: "project-workspace-2",
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
}),
);
act(() => root.unmount());
});
it("submits the latest locally typed title and description", async () => {
let resolveProjects: (projects: Array<{
id: string;

View File

@@ -242,6 +242,21 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
return "shared_workspace";
}
function defaultExecutionWorkspaceModeForIssueDefaults(
defaults: {
executionWorkspaceId?: unknown;
executionWorkspaceMode?: unknown;
},
project: { executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null } | null } | null | undefined,
) {
if (typeof defaults.executionWorkspaceId === "string" && defaults.executionWorkspaceId.length > 0) {
return "reuse_existing";
}
return typeof defaults.executionWorkspaceMode === "string" && defaults.executionWorkspaceMode.length > 0
? defaults.executionWorkspaceMode
: defaultExecutionWorkspaceModeForProject(project);
}
const IssueTitleTextarea = memo(function IssueTitleTextarea({
value,
pending,
@@ -686,9 +701,7 @@ export function NewIssueDialog() {
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const defaultProjectWorkspaceId = newIssueDefaults.projectWorkspaceId
?? defaultProjectWorkspaceIdForProject(defaultProject);
const defaultExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceId
? "reuse_existing"
: (newIssueDefaults.executionWorkspaceMode ?? defaultExecutionWorkspaceModeForProject(defaultProject));
const defaultExecutionWorkspaceMode = defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject);
setIssueText(newIssueDefaults.title ?? "", newIssueDefaults.description ?? "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
@@ -710,8 +723,9 @@ export function NewIssueDialog() {
setPriority(newIssueDefaults.priority ?? "");
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
@@ -720,12 +734,17 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
} else if (draft && draft.title.trim()) {
const restoredProjectId = newIssueDefaults.projectId ?? draft.projectId;
const restoredProject = orderedProjects.find((project) => project.id === restoredProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
setIssueText(draft.title, draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
@@ -739,27 +758,40 @@ export function NewIssueDialog() {
setShowReviewerRow(!!(draft.reviewerValue));
setShowApproverRow(!!(draft.approverValue));
setProjectId(restoredProjectId);
setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject));
setProjectWorkspaceId(
hasExplicitProjectWorkspaceId
? (newIssueDefaults.projectWorkspaceId ?? "")
: (draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)),
);
setAssigneeModelLane(draft.assigneeModelLane ?? "primary");
setAssigneeModelOverride(draft.assigneeModelOverride ?? "");
setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? "");
setAssigneeChrome(draft.assigneeChrome ?? false);
setExecutionWorkspaceMode(
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject)),
hasExplicitExecutionWorkspaceId || hasExplicitExecutionWorkspaceMode
? defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, restoredProject)
: (
draft.executionWorkspaceMode
?? (draft.useIsolatedExecutionWorkspace ? "isolated_workspace" : defaultExecutionWorkspaceModeForProject(restoredProject))
),
);
setSelectedExecutionWorkspaceId(draft.selectedExecutionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = draft.projectWorkspaceId || restoredProject
setSelectedExecutionWorkspaceId(
hasExplicitExecutionWorkspaceId
? (newIssueDefaults.executionWorkspaceId ?? "")
: (draft.selectedExecutionWorkspaceId ?? ""),
);
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || hasExplicitExecutionWorkspaceId || draft.projectWorkspaceId || restoredProject
? restoredProjectId || null
: null;
} else {
const defaultProjectId = newIssueDefaults.projectId ?? "";
const defaultProject = orderedProjects.find((project) => project.id === defaultProjectId);
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
setIssueText("", "");
setStatus(newIssueDefaults.status ?? "todo");
setPriority(newIssueDefaults.priority ?? "");
setProjectId(defaultProjectId);
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(defaultProject));
setProjectWorkspaceId(newIssueDefaults.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(defaultProject));
setAssigneeValue(assigneeValueFromSelection(newIssueDefaults));
setReviewerValue("");
setApproverValue("");
@@ -768,9 +800,11 @@ export function NewIssueDialog() {
setAssigneeModelOverride("");
setAssigneeThinkingEffort("");
setAssigneeChrome(false);
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(defaultProject));
setSelectedExecutionWorkspaceId("");
executionWorkspaceDefaultProjectId.current = defaultProject ? defaultProjectId || null : null;
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForIssueDefaults(newIssueDefaults, defaultProject));
setSelectedExecutionWorkspaceId(newIssueDefaults.executionWorkspaceId ?? "");
executionWorkspaceDefaultProjectId.current = hasExplicitProjectWorkspaceId || newIssueDefaults.executionWorkspaceId || defaultProject
? defaultProjectId || null
: null;
}
}, [newIssueOpen, newIssueDefaults, orderedProjects, selectedCompanyId, setIssueText]);

View File

@@ -1,326 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
import {
ChevronDown,
ChevronRight,
FileCode2,
FileText,
Folder,
FolderOpen,
} from "lucide-react";
import { FileTree } from "./FileTree";
import type { FileTreeProps } from "./FileTree";
// ── 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;
};
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 PackageFileTree({ wrapLabels = false, ...props }: FileTreeProps) {
return <FileTree {...props} wrapLabels={wrapLabels} />;
}
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;
}
// ── 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>
);
}
export {
FRONTMATTER_FIELD_LABELS,
buildFileTree,
collectAllPaths,
countFiles,
parseFrontmatter,
} from "./FileTree";
export type {
FileTreeBadge,
FileTreeBadgeVariant,
FileTreeEmptyState,
FileTreeErrorState,
FileTreeNode,
FileTreeProps,
FileTreeTone,
FrontmatterData,
} from "./FileTree";

View 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");
});
});

View 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>
);
}

View 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>
);
}

View File

@@ -224,6 +224,74 @@ describe("RoutineRunVariablesDialog", () => {
});
});
it("keeps the mobile dialog bounded with an internal form scroll region", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<RoutineRunVariablesDialog
open
onOpenChange={() => {}}
companyId="company-1"
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[
{
name: "notes",
label: "notes",
type: "textarea",
defaultValue: null,
required: false,
options: [],
},
]}
isPending={false}
onSubmit={() => {}}
/>
</QueryClientProvider>,
);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
);
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("overflow-hidden");
const notesInput = document.querySelector("textarea");
const formScrollRegion = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("overscroll-contain"),
);
expect(formScrollRegion?.className).toContain("min-h-0");
expect(formScrollRegion?.className).toContain("flex-1");
expect(formScrollRegion?.className).toContain("overflow-y-auto");
expect(formScrollRegion?.contains(notesInput)).toBe(true);
const footer = Array.from(document.body.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("pb-[calc(1rem+env(safe-area-inset-bottom))]"),
);
expect(footer?.className).toContain("shrink-0");
expect(footer?.contains(formScrollRegion ?? null)).toBe(false);
expect(footer?.textContent).toContain("Run routine");
await act(async () => {
root.unmount();
});
});
it("renders workspaceBranch as a read-only selected workspace value", async () => {
issueWorkspaceDraft = {
executionWorkspaceId: "workspace-1",

View File

@@ -335,8 +335,8 @@ export function RoutineRunVariablesDialog({
return (
<Dialog open={open} onOpenChange={(next) => !isPending && onOpenChange(next)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogContent className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] max-w-xl flex-col gap-0 overflow-hidden p-0 sm:h-auto sm:max-h-[min(calc(100dvh-2rem),42rem)]">
<DialogHeader className="shrink-0 border-b border-border/60 px-6 pb-4 pr-12 pt-6">
{routineName && (
<p className="text-muted-foreground text-sm">{routineName}</p>
)}
@@ -346,7 +346,7 @@ export function RoutineRunVariablesDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Agent *</Label>
@@ -520,7 +520,10 @@ export function RoutineRunVariablesDialog({
) : null}
</div>
<DialogFooter showCloseButton={false}>
<DialogFooter
showCloseButton={false}
className="shrink-0 border-t border-border/60 bg-background px-6 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4"
>
{!selection.assigneeAgentId ? (
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
) : missingRequired.length > 0 ? (

View File

@@ -55,7 +55,7 @@ export function Sidebar() {
};
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) */}
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu />

View File

@@ -49,6 +49,13 @@ vi.mock("@/context/CompanyContext", () => ({
brandColor: "#36a269",
status: "active",
},
{
id: "company-3",
issuePrefix: "ANA",
name: "Anachronist Wiki",
brandColor: "#a36a21",
status: "active",
},
],
selectedCompany: {
id: "company-1",
@@ -143,6 +150,7 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Switch workspace");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).toContain("Add company...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronsUpDown, LogOut, Plus, Settings, UserPlus } from "lucide-react";
import type { Company } from "@paperclipai/shared";
@@ -46,7 +46,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
const navigate = useNavigate();
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
@@ -110,7 +113,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
<DropdownMenuLabel className="px-2 py-1.5 text-[11px] font-semibold uppercase text-muted-foreground">
Switch workspace
</DropdownMenuLabel>
<div className="max-h-72 overflow-y-auto">
<div className="max-h-96 overflow-y-auto">
{sidebarCompanies.map((company) => {
const isSelected = company.id === selectedCompany?.id;
return (
@@ -124,6 +127,9 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
>
<WorkspaceIcon company={company} />
<span className="min-w-0 flex-1 truncate">{company.name}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{company.issuePrefix}
</span>
{isSelected ? <Check className="size-4 text-muted-foreground" /> : null}
</DropdownMenuItem>
);

Some files were not shown because too many files have changed in this diff Show More