mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Add Paperclip Triage plugin scaffold
This commit is contained in:
1 parent
988689947a
commit
daf314e372
11 files changed
+913
No files matched your search
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
.paperclip-sdk
|
||||
@@ -0,0 +1,51 @@
|
||||
# Paperclip Triage
|
||||
|
||||
Queue workbench for triaging arbitrary items with a teachable assistant.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`pnpm dev` rebuilds the worker, manifest, and UI bundles into `dist/`.
|
||||
When this package is installed from a local path, Paperclip watches that rebuilt
|
||||
output and reloads the plugin worker. Local installs run trusted code from this
|
||||
folder on your machine.
|
||||
|
||||
## Phase 2 Scope
|
||||
|
||||
This package scaffold declares:
|
||||
|
||||
- plugin id `paperclipai.plugin-triage`
|
||||
- page, sidebar, route sidebar, and settings page slots
|
||||
- managed `Triage Assistant` agent
|
||||
- managed `Triage` project
|
||||
- managed triage skills
|
||||
- worker health data plus a settings-page reconcile action
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
paperclipai plugin install /Users/dotta/paperclip/.paperclip/worktrees/PAP-9815-triage-plugin/packages/plugins/plugin-triage
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-triage typecheck
|
||||
pnpm --filter @paperclipai/plugin-triage test
|
||||
pnpm --filter @paperclipai/plugin-triage build
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-triage",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Queue workbench for triaging arbitrary items with a teachable assistant.",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"automation"
|
||||
],
|
||||
"author": "Paperclip",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclipai.plugin-triage";
|
||||
export const TRIAGE_ASSISTANT_AGENT_KEY = "triage-assistant";
|
||||
export const TRIAGE_PROJECT_KEY = "triage";
|
||||
export const TRIAGE_ASSISTANT_SKILL_KEY = "triage-assistant";
|
||||
export const TRIAGE_REFLECTION_SKILL_KEY = "triage-reflection";
|
||||
export const TRIAGE_WORKFLOW_SKILL_KEY = "triage-workflow";
|
||||
export const TRIAGE_MANAGED_SKILL_KEYS = [
|
||||
TRIAGE_ASSISTANT_SKILL_KEY,
|
||||
TRIAGE_REFLECTION_SKILL_KEY,
|
||||
TRIAGE_WORKFLOW_SKILL_KEY,
|
||||
] as const;
|
||||
export const TRIAGE_MANAGED_SKILL_CANONICAL_KEYS = TRIAGE_MANAGED_SKILL_KEYS.map(managedSkillCanonicalKey);
|
||||
|
||||
function managedSkillCanonicalKey(skillKey: string): string {
|
||||
return `plugin/paperclipai-plugin-triage/${skillKey}`;
|
||||
}
|
||||
|
||||
function skillMarkdown(skillKey: (typeof TRIAGE_MANAGED_SKILL_KEYS)[number]): string {
|
||||
if (skillKey === TRIAGE_REFLECTION_SKILL_KEY) {
|
||||
return `# Triage Reflection
|
||||
|
||||
Use when proposing queue guidance updates after a user and assistant finish processing a queue item.
|
||||
|
||||
- Compare the original item, final item, chat outcome, transition decision, and current queue guidance.
|
||||
- Propose guidance changes only when the next item would benefit from them.
|
||||
- Never apply guidance directly; return a proposal for board/user review.
|
||||
- Keep source-specific behavior out of the queue unless it is already represented in item properties or queue guidance.
|
||||
`;
|
||||
}
|
||||
|
||||
if (skillKey === TRIAGE_WORKFLOW_SKILL_KEY) {
|
||||
return `# Triage Workflow
|
||||
|
||||
Use when reasoning about Paperclip Triage queue states, transitions, and work issue actions.
|
||||
|
||||
- Treat queue items as company-scoped records with opaque content and properties.
|
||||
- Use only configured workflow transitions from the item's current state.
|
||||
- Respect reflection gates before approving or rejecting an item.
|
||||
- Limit downstream effects to create-or-update Paperclip issue actions.
|
||||
- Do not call external systems or mutate upstream sources.
|
||||
`;
|
||||
}
|
||||
|
||||
return `# Triage Assistant
|
||||
|
||||
Use when helping a user process items in a Paperclip Triage queue.
|
||||
|
||||
- Work from the queue purpose, current item content, item properties, workflow state, and guidance documents.
|
||||
- Keep chat-assisted edits and direct edits focused on the same queue item record.
|
||||
- Ask for user approval before guidance changes or irreversible workflow decisions.
|
||||
- Preserve company boundaries and keep all queue work scoped to the active company.
|
||||
- Do not add source connector behavior; ingestion starts when an item is posted into a queue.
|
||||
`;
|
||||
}
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Paperclip Triage",
|
||||
description: "Queue workbench for triaging arbitrary items with a teachable assistant.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation", "ui"],
|
||||
capabilities: [
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"skills.managed",
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
agentKey: TRIAGE_ASSISTANT_AGENT_KEY,
|
||||
displayName: "Triage Assistant",
|
||||
role: "triage-assistant",
|
||||
title: "Triage Assistant",
|
||||
icon: "inbox",
|
||||
capabilities: "Helps process queue items, apply queue guidance, propose guidance updates, and prepare Paperclip work issue handoffs.",
|
||||
adapterType: "claude_local",
|
||||
adapterPreference: ["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor", "pi_local"],
|
||||
adapterConfig: {
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslyBypassApprovalsAndSandbox: false,
|
||||
sandbox: true,
|
||||
paperclipSkillSync: {
|
||||
desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
modelProfiles: {
|
||||
cheap: {
|
||||
purpose: "queue classification, transition suggestions, and reflection draft review",
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
pluginTools: [PLUGIN_ID],
|
||||
},
|
||||
status: "paused",
|
||||
budgetMonthlyCents: 0,
|
||||
instructions: {
|
||||
entryFile: "AGENTS.md",
|
||||
content: [
|
||||
"# Triage Assistant",
|
||||
"",
|
||||
"You are the managed assistant for Paperclip Triage queues.",
|
||||
"",
|
||||
"Work inside the active company only. Use queue guidance, current item content, item properties, and workflow state as your source of truth.",
|
||||
"",
|
||||
"Do not build source connectors or call upstream systems. Do not apply guidance changes without explicit user acceptance. Downstream actions are limited to Paperclip issue create-or-update templates.",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
projectKey: TRIAGE_PROJECT_KEY,
|
||||
displayName: "Triage",
|
||||
description: "Plugin-managed project for Paperclip Triage assistant work, queue chat issues, and generated work issue defaults.",
|
||||
status: "in_progress",
|
||||
color: "#0f766e",
|
||||
settings: {
|
||||
defaultWorkflow: "new -> approved/rejected -> done",
|
||||
},
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
skillKey: TRIAGE_ASSISTANT_SKILL_KEY,
|
||||
displayName: "Triage Assistant",
|
||||
slug: TRIAGE_ASSISTANT_SKILL_KEY,
|
||||
description: "Process queue items with user-visible guidance and company-scoped queue context.",
|
||||
markdown: skillMarkdown(TRIAGE_ASSISTANT_SKILL_KEY),
|
||||
},
|
||||
{
|
||||
skillKey: TRIAGE_REFLECTION_SKILL_KEY,
|
||||
displayName: "Triage Reflection",
|
||||
slug: TRIAGE_REFLECTION_SKILL_KEY,
|
||||
description: "Propose queue guidance updates after item processing without applying them automatically.",
|
||||
markdown: skillMarkdown(TRIAGE_REFLECTION_SKILL_KEY),
|
||||
},
|
||||
{
|
||||
skillKey: TRIAGE_WORKFLOW_SKILL_KEY,
|
||||
displayName: "Triage Workflow",
|
||||
slug: TRIAGE_WORKFLOW_SKILL_KEY,
|
||||
description: "Reason about queue states, reflection gates, and create-or-update issue actions.",
|
||||
markdown: skillMarkdown(TRIAGE_WORKFLOW_SKILL_KEY),
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "sidebar",
|
||||
id: "triage-sidebar",
|
||||
displayName: "Triage",
|
||||
exportName: "SidebarLink",
|
||||
order: 38,
|
||||
},
|
||||
{
|
||||
type: "page",
|
||||
id: "triage-page",
|
||||
displayName: "Triage",
|
||||
exportName: "TriagePage",
|
||||
routePath: "triage",
|
||||
},
|
||||
{
|
||||
type: "routeSidebar",
|
||||
id: "triage-route-sidebar",
|
||||
displayName: "Triage",
|
||||
exportName: "TriageRouteSidebar",
|
||||
routePath: "triage",
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: "triage-settings",
|
||||
displayName: "Triage",
|
||||
exportName: "SettingsPage",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
useHostNavigation,
|
||||
usePluginAction,
|
||||
usePluginData,
|
||||
usePluginToast,
|
||||
type PluginPageProps,
|
||||
type PluginRouteSidebarProps,
|
||||
type PluginSettingsPageProps,
|
||||
type PluginSidebarProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
type ManagedResourceHealth = {
|
||||
status: "needs_company" | "missing" | "ready";
|
||||
checkedAt: string;
|
||||
agent: {
|
||||
resourceKey: string;
|
||||
status: string;
|
||||
agentId: string | null;
|
||||
name: string | null;
|
||||
agentStatus: string | null;
|
||||
adapterType: string | null;
|
||||
} | null;
|
||||
project: {
|
||||
resourceKey: string;
|
||||
status: string;
|
||||
projectId: string | null;
|
||||
name: string | null;
|
||||
projectStatus: string | null;
|
||||
} | null;
|
||||
skills: Array<{
|
||||
resourceKey: string;
|
||||
status: string;
|
||||
skillId: string | null;
|
||||
name: string | null;
|
||||
key: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const tokens = {
|
||||
border: "var(--border, oklch(0.86 0 0))",
|
||||
bg: "var(--background, #ffffff)",
|
||||
fg: "var(--foreground, #18181b)",
|
||||
muted: "var(--muted-foreground, #71717a)",
|
||||
accent: "var(--accent, #f4f4f5)",
|
||||
accentFg: "var(--accent-foreground, #18181b)",
|
||||
primary: "var(--primary, #18181b)",
|
||||
primaryFg: "var(--primary-foreground, #ffffff)",
|
||||
success: "oklch(0.55 0.13 155)",
|
||||
warning: "oklch(0.64 0.14 75)",
|
||||
};
|
||||
|
||||
const pageShell: CSSProperties = {
|
||||
display: "grid",
|
||||
gap: 20,
|
||||
padding: 24,
|
||||
color: tokens.fg,
|
||||
background: tokens.bg,
|
||||
minHeight: "100%",
|
||||
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
|
||||
};
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
border: `1px solid ${tokens.border}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const mutedStyle: CSSProperties = {
|
||||
color: tokens.muted,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
function useManagedResourceHealth(companyId: string | null) {
|
||||
const params = useMemo(() => ({ companyId: companyId ?? "" }), [companyId]);
|
||||
return usePluginData<ManagedResourceHealth>("managed-resource-health", params);
|
||||
}
|
||||
|
||||
function statusTone(status: string): CSSProperties {
|
||||
if (status === "ready" || status === "resolved" || status === "created") {
|
||||
return { color: tokens.success };
|
||||
}
|
||||
if (status === "missing" || status === "needs_company") {
|
||||
return { color: tokens.warning };
|
||||
}
|
||||
return { color: tokens.muted };
|
||||
}
|
||||
|
||||
function StatusText({ status }: { status: string }) {
|
||||
return (
|
||||
<span style={{ ...statusTone(status), fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
label,
|
||||
status,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
status: string;
|
||||
detail?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(120px, 1fr) auto",
|
||||
gap: 12,
|
||||
alignItems: "start",
|
||||
padding: "8px 0",
|
||||
borderTop: `1px solid ${tokens.border}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 3 }}>
|
||||
<strong style={{ fontSize: 13 }}>{label}</strong>
|
||||
{detail ? <span style={mutedStyle}>{detail}</span> : null}
|
||||
</div>
|
||||
<StatusText status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedResourcePanel({
|
||||
companyId,
|
||||
allowReconcile,
|
||||
}: {
|
||||
companyId: string | null;
|
||||
allowReconcile: boolean;
|
||||
}) {
|
||||
const { data, loading, error, refresh } = useManagedResourceHealth(companyId);
|
||||
const reconcile = usePluginAction("reconcile-managed-resources");
|
||||
const toast = usePluginToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
async function handleReconcile() {
|
||||
if (!companyId) return;
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await reconcile({ companyId });
|
||||
refresh();
|
||||
toast({ title: "Triage resources reconciled", tone: "success" });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to reconcile resources";
|
||||
setActionError(message);
|
||||
toast({ title: "Reconcile failed", body: message, tone: "error" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={panelStyle}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 16 }}>Managed Resources</h2>
|
||||
<div style={mutedStyle}>Last checked: {data?.checkedAt ?? "not checked"}</div>
|
||||
</div>
|
||||
{allowReconcile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleReconcile()}
|
||||
disabled={!companyId || busy}
|
||||
style={{
|
||||
border: `1px solid ${tokens.primary}`,
|
||||
borderRadius: 6,
|
||||
background: tokens.primary,
|
||||
color: tokens.primaryFg,
|
||||
padding: "7px 10px",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: companyId && !busy ? "pointer" : "not-allowed",
|
||||
opacity: companyId && !busy ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
{busy ? "Reconciling" : "Reconcile"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? <div style={mutedStyle}>Loading resources...</div> : null}
|
||||
{error ? <div style={{ color: tokens.warning, fontSize: 13 }}>{error.message}</div> : null}
|
||||
{actionError ? <div style={{ color: tokens.warning, fontSize: 13 }}>{actionError}</div> : null}
|
||||
|
||||
{data ? (
|
||||
<div>
|
||||
<ResourceRow label="Package Health" status={data.status} />
|
||||
<ResourceRow
|
||||
label="Triage Project"
|
||||
status={data.project?.status ?? "missing"}
|
||||
detail={data.project?.name ?? "Triage"}
|
||||
/>
|
||||
<ResourceRow
|
||||
label="Triage Assistant"
|
||||
status={data.agent?.status ?? "missing"}
|
||||
detail={
|
||||
data.agent
|
||||
? `${data.agent.name ?? "Triage Assistant"} (${data.agent.agentStatus ?? "unknown"})`
|
||||
: "Triage Assistant"
|
||||
}
|
||||
/>
|
||||
{data.skills.map((skill) => (
|
||||
<ResourceRow
|
||||
key={skill.resourceKey}
|
||||
label={skill.name ?? skill.resourceKey}
|
||||
status={skill.status}
|
||||
detail={skill.key ?? skill.resourceKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarLink({ context }: PluginSidebarProps) {
|
||||
const nav = useHostNavigation();
|
||||
return (
|
||||
<a
|
||||
{...nav.linkProps("/triage")}
|
||||
style={{
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
display: "block",
|
||||
padding: "6px 8px",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
aria-label={`Open Triage for ${context.companyPrefix ?? "company"}`}
|
||||
>
|
||||
Triage
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriageRouteSidebar({ context }: PluginRouteSidebarProps) {
|
||||
const nav = useHostNavigation();
|
||||
const { data } = useManagedResourceHealth(context.companyId);
|
||||
|
||||
return (
|
||||
<aside style={{ padding: 12, display: "grid", gap: 14, color: tokens.fg }}>
|
||||
<a
|
||||
{...nav.linkProps("/triage")}
|
||||
style={{ color: "inherit", textDecoration: "none", fontWeight: 700, fontSize: 14 }}
|
||||
>
|
||||
Triage
|
||||
</a>
|
||||
<nav style={{ display: "grid", gap: 6, fontSize: 13 }}>
|
||||
<a {...nav.linkProps("/triage")} style={{ color: "inherit", textDecoration: "none" }}>Queues</a>
|
||||
<a {...nav.linkProps("/triage#resources")} style={{ color: "inherit", textDecoration: "none" }}>Resources</a>
|
||||
</nav>
|
||||
<div style={{ ...mutedStyle, borderTop: `1px solid ${tokens.border}`, paddingTop: 10 }}>
|
||||
<StatusText status={data?.status ?? "missing"} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriagePage({ context }: PluginPageProps) {
|
||||
return (
|
||||
<main style={pageShell}>
|
||||
<header style={{ display: "grid", gap: 4 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 24 }}>Triage</h1>
|
||||
<div style={mutedStyle}>Queue workbench scaffold</div>
|
||||
</header>
|
||||
|
||||
<section style={{ ...panelStyle, minHeight: 220 }}>
|
||||
<div style={{ display: "grid", gap: 4 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16 }}>Queues</h2>
|
||||
<div style={mutedStyle}>Queue data model is implemented in the next phase.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="resources">
|
||||
<ManagedResourcePanel companyId={context.companyId} allowReconcile={false} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({ context }: PluginSettingsPageProps) {
|
||||
return (
|
||||
<main style={pageShell}>
|
||||
<header style={{ display: "grid", gap: 4 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>Triage Settings</h1>
|
||||
<div style={mutedStyle}>Managed project, assistant, and skills</div>
|
||||
</header>
|
||||
<ManagedResourcePanel companyId={context.companyId} allowReconcile={true} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
definePlugin,
|
||||
runWorker,
|
||||
type PluginContext,
|
||||
type PluginManagedAgentResolution,
|
||||
type PluginManagedProjectResolution,
|
||||
type PluginManagedSkillResolution,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
TRIAGE_ASSISTANT_AGENT_KEY,
|
||||
TRIAGE_MANAGED_SKILL_KEYS,
|
||||
TRIAGE_PROJECT_KEY,
|
||||
} from "./manifest.js";
|
||||
|
||||
type ManagedResourceHealth = {
|
||||
status: "needs_company" | "missing" | "ready";
|
||||
checkedAt: string;
|
||||
agent: ManagedAgentHealth | null;
|
||||
project: ManagedProjectHealth | null;
|
||||
skills: ManagedSkillHealth[];
|
||||
};
|
||||
|
||||
type ManagedAgentHealth = {
|
||||
resourceKey: string;
|
||||
status: PluginManagedAgentResolution["status"];
|
||||
agentId: string | null;
|
||||
name: string | null;
|
||||
agentStatus: string | null;
|
||||
adapterType: string | null;
|
||||
};
|
||||
|
||||
type ManagedProjectHealth = {
|
||||
resourceKey: string;
|
||||
status: PluginManagedProjectResolution["status"];
|
||||
projectId: string | null;
|
||||
name: string | null;
|
||||
projectStatus: string | null;
|
||||
};
|
||||
|
||||
type ManagedSkillHealth = {
|
||||
resourceKey: string;
|
||||
status: PluginManagedSkillResolution["status"];
|
||||
skillId: string | null;
|
||||
name: string | null;
|
||||
key: string | null;
|
||||
};
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function requireCompanyId(params: Record<string, unknown>): string {
|
||||
const companyId = stringField(params.companyId);
|
||||
if (!companyId) {
|
||||
throw new Error("companyId is required");
|
||||
}
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function summarizeAgent(resolution: PluginManagedAgentResolution): ManagedAgentHealth {
|
||||
return {
|
||||
resourceKey: resolution.resourceKey,
|
||||
status: resolution.status,
|
||||
agentId: resolution.agentId,
|
||||
name: resolution.agent?.name ?? null,
|
||||
agentStatus: resolution.agent?.status ?? null,
|
||||
adapterType: resolution.agent?.adapterType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeProject(resolution: PluginManagedProjectResolution): ManagedProjectHealth {
|
||||
return {
|
||||
resourceKey: resolution.resourceKey,
|
||||
status: resolution.status,
|
||||
projectId: resolution.projectId,
|
||||
name: resolution.project?.name ?? null,
|
||||
projectStatus: resolution.project?.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSkill(resolution: PluginManagedSkillResolution): ManagedSkillHealth {
|
||||
return {
|
||||
resourceKey: resolution.resourceKey,
|
||||
status: resolution.status,
|
||||
skillId: resolution.skillId,
|
||||
name: resolution.skill?.name ?? null,
|
||||
key: resolution.skill?.key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function managedResourceHealth(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
mode: "inspect" | "reconcile",
|
||||
): Promise<ManagedResourceHealth> {
|
||||
const projectResolution = mode === "reconcile"
|
||||
? await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId)
|
||||
: await ctx.projects.managed.get(TRIAGE_PROJECT_KEY, companyId);
|
||||
const skillResolutions = await Promise.all(
|
||||
TRIAGE_MANAGED_SKILL_KEYS.map((skillKey) =>
|
||||
mode === "reconcile"
|
||||
? ctx.skills.managed.reconcile(skillKey, companyId)
|
||||
: ctx.skills.managed.get(skillKey, companyId),
|
||||
),
|
||||
);
|
||||
const agentResolution = mode === "reconcile"
|
||||
? await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId)
|
||||
: await ctx.agents.managed.get(TRIAGE_ASSISTANT_AGENT_KEY, companyId);
|
||||
|
||||
const agent = summarizeAgent(agentResolution);
|
||||
const project = summarizeProject(projectResolution);
|
||||
const skills = skillResolutions.map(summarizeSkill);
|
||||
const missing = [
|
||||
agent.status === "missing",
|
||||
project.status === "missing",
|
||||
...skills.map((skill) => skill.status === "missing"),
|
||||
].some(Boolean);
|
||||
|
||||
return {
|
||||
status: missing ? "missing" : "ready",
|
||||
checkedAt: new Date().toISOString(),
|
||||
agent,
|
||||
project,
|
||||
skills,
|
||||
};
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.data.register("managed-resource-health", async (params: Record<string, unknown>) => {
|
||||
const companyId = stringField(params.companyId);
|
||||
if (!companyId) {
|
||||
return {
|
||||
status: "needs_company",
|
||||
checkedAt: new Date().toISOString(),
|
||||
agent: null,
|
||||
project: null,
|
||||
skills: [],
|
||||
} satisfies ManagedResourceHealth;
|
||||
}
|
||||
|
||||
return managedResourceHealth(ctx, companyId, "inspect");
|
||||
});
|
||||
|
||||
ctx.actions.register("reconcile-managed-resources", async (params: Record<string, unknown>) => {
|
||||
const companyId = requireCompanyId(params);
|
||||
const result = await managedResourceHealth(ctx, companyId, "reconcile");
|
||||
ctx.logger.info("Reconciled Paperclip Triage managed resources", {
|
||||
companyId,
|
||||
agentStatus: result.agent?.status,
|
||||
projectStatus: result.project?.status,
|
||||
skillStatuses: result.skills.map((skill) => `${skill.resourceKey}:${skill.status}`),
|
||||
});
|
||||
return result;
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Paperclip Triage worker is running" };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest, {
|
||||
PLUGIN_ID,
|
||||
TRIAGE_ASSISTANT_AGENT_KEY,
|
||||
TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
|
||||
TRIAGE_MANAGED_SKILL_KEYS,
|
||||
TRIAGE_PROJECT_KEY,
|
||||
} from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
describe("Paperclip Triage scaffold", () => {
|
||||
it("declares the package manifest, UI slots, and managed resources", () => {
|
||||
expect(manifest.id).toBe(PLUGIN_ID);
|
||||
expect(manifest.capabilities).toEqual(expect.arrayContaining([
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"skills.managed",
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
]));
|
||||
expect(manifest.ui?.slots).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ type: "sidebar", exportName: "SidebarLink" }),
|
||||
expect.objectContaining({ type: "page", exportName: "TriagePage", routePath: "triage" }),
|
||||
expect.objectContaining({ type: "routeSidebar", exportName: "TriageRouteSidebar", routePath: "triage" }),
|
||||
expect.objectContaining({ type: "settingsPage", exportName: "SettingsPage" }),
|
||||
]));
|
||||
expect(manifest.agents?.[0]).toEqual(expect.objectContaining({
|
||||
agentKey: TRIAGE_ASSISTANT_AGENT_KEY,
|
||||
displayName: "Triage Assistant",
|
||||
status: "paused",
|
||||
permissions: { pluginTools: [PLUGIN_ID] },
|
||||
}));
|
||||
expect(manifest.agents?.[0]?.adapterConfig?.paperclipSkillSync).toEqual({
|
||||
desiredSkills: TRIAGE_MANAGED_SKILL_CANONICAL_KEYS,
|
||||
});
|
||||
expect(manifest.projects?.[0]).toEqual(expect.objectContaining({
|
||||
projectKey: TRIAGE_PROJECT_KEY,
|
||||
displayName: "Triage",
|
||||
}));
|
||||
expect(manifest.skills?.map((skill) => skill.skillKey)).toEqual(TRIAGE_MANAGED_SKILL_KEYS);
|
||||
});
|
||||
|
||||
it("reports missing managed resources before reconcile", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const health = await harness.getData<{ status: string; skills: Array<{ status: string }> }>(
|
||||
"managed-resource-health",
|
||||
{ companyId: COMPANY_ID },
|
||||
);
|
||||
|
||||
expect(health.status).toBe("missing");
|
||||
expect(health.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
|
||||
expect(health.skills.every((skill) => skill.status === "missing")).toBe(true);
|
||||
});
|
||||
|
||||
it("reconciles managed project, assistant, and skills", async () => {
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
const result = await harness.performAction<{
|
||||
status: string;
|
||||
agent: { status: string; agentId: string | null };
|
||||
project: { status: string; projectId: string | null };
|
||||
skills: Array<{ status: string; skillId: string | null; key: string | null }>;
|
||||
}>("reconcile-managed-resources", { companyId: COMPANY_ID });
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.agent).toEqual(expect.objectContaining({ status: "created" }));
|
||||
expect(result.agent.agentId).toBeTruthy();
|
||||
expect(result.project).toEqual(expect.objectContaining({ status: "created" }));
|
||||
expect(result.project.projectId).toBeTruthy();
|
||||
expect(result.skills).toHaveLength(TRIAGE_MANAGED_SKILL_KEYS.length);
|
||||
expect(result.skills.every((skill) => skill.status === "created")).toBe(true);
|
||||
expect(result.skills.map((skill) => skill.key)).toEqual(TRIAGE_MANAGED_SKILL_CANONICAL_KEYS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user