mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Plugins add tools and issue-thread interactions through `@paperclipai/plugin-sdk` > - The SDK types are the public contract between the host and plugin authors > - Two parts of that contract do not match what the host does at runtime > - First, `ToolRunContext.projectId` says it is always a project UUID. The tool gateway sends an empty string when a run has no project. The direct tool route rejects such runs with 400 > - Second, three interaction helpers ask for the parsed output type, so plugin authors must pass fields that the host fills in with defaults > - This pull request makes the types and both tool paths agree with the runtime > - The benefit is that agents with no project can run plugin tools, and plugin authors can omit the fields that have defaults ## Linked Issues or Issue Description Fixes #3273 Related: #3274 removes the `projectId` requirement from the route. That change alone is not enough today: the route now also looks up the project to check its company, and that lookup needs a value. This pull request also skips that lookup when the run has no project. It gives plugins one value for "no project" on both tool paths. The new test typecheck in #18739 and #18839 also listed both problems as possible product problems. ## What Changed - `server/src/routes/plugins.ts`: `POST /api/plugins/tools/execute` accepts a `runContext.projectId` that is missing, `null`, or an empty string. It passes an empty string to the plugin, the same value that the tool gateway sends. It checks the project's company only when a project ID is present. A `projectId` that is not a string returns 400. - `server/src/routes/openapi.ts`: the request schema marks `runContext.projectId` as optional and nullable. - `packages/plugins/sdk/src/types.ts`: - The `ToolRunContext.projectId` comment says the value is an empty string when the run has no project. - `suggestTasks`, `requestConfirmation` and `requestCheckboxConfirmation` take `CreateIssueThreadInteractionInput`, as `askUserQuestions` already does. The host parses this input with the schema, so the defaults apply. - `packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts`: the example passes `undefined` instead of an empty project ID to `issues.create`. - `doc/plugins/PLUGIN_SPEC.md` (section 13.10): documents the empty project ID. - Tests: - `server/src/__tests__/plugin-routes-authz.test.ts`: tests a run with an empty, missing, or `null` project ID, which skips the project lookup. Also tests that a project ID that is not a string returns 400. - `packages/plugins/sdk/tests/testing-actions.test.ts`: removes the cast that the output type required. ## Verification - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/openapi-routes.test.ts packages/plugins/sdk/tests/testing-actions.test.ts`: 3 files, 81 tests pass. - `cd server && pnpm typecheck`: passes. This includes the source layers and the three test-tree shards. - `tsc --noEmit` passes for `packages/plugins/sdk` (`tsconfig.json` and `tsconfig.test.json`) and for the kitchen-sink example. - Not run locally: the full `pnpm test:run` and the root `pnpm build`. Required CI covers them. ## Risks - Low risk. The direct tool route now accepts calls that it rejected before. It still checks that the agent and the run belong to the company. With no project ID, there is no project to check. - A plugin that reads `runCtx.projectId` can get an empty string on both tool paths. The tool gateway already sent this value before this change. Only the documentation is new. - The SDK parameter types are wider. Existing plugin code still compiles. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits) and extended thinking. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1064 lines
36 KiB
TypeScript
1064 lines
36 KiB
TypeScript
import { Buffer } from "node:buffer";
|
|
import { spawn } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import {
|
|
PLUGIN_STATE_SCOPE_KINDS,
|
|
definePlugin,
|
|
runWorker,
|
|
type PaperclipPlugin,
|
|
type EnvSecretRefBinding,
|
|
type PluginContext,
|
|
type PluginEntityQuery,
|
|
type PluginEvent,
|
|
type PluginHealthDiagnostics,
|
|
type PluginJobContext,
|
|
type PluginLauncherRegistration,
|
|
type PluginWebhookInput,
|
|
type PluginWorkspace,
|
|
type PluginStateScopeKind,
|
|
type ScopeKey,
|
|
type ToolResult,
|
|
type ToolRunContext,
|
|
} from "@paperclipai/plugin-sdk";
|
|
import type { Goal, Issue } from "@paperclipai/shared";
|
|
import {
|
|
DEFAULT_CONFIG,
|
|
JOB_KEYS,
|
|
PLUGIN_ID,
|
|
RUNTIME_LAUNCHER,
|
|
SAFE_COMMANDS,
|
|
STREAM_CHANNELS,
|
|
TOOL_NAMES,
|
|
WEBHOOK_KEYS,
|
|
} from "./constants.js";
|
|
|
|
type KitchenSinkConfig = {
|
|
showSidebarEntry?: boolean;
|
|
showSidebarPanel?: boolean;
|
|
showProjectSidebarItem?: boolean;
|
|
showCommentAnnotation?: boolean;
|
|
showCommentContextMenuItem?: boolean;
|
|
enableWorkspaceDemos?: boolean;
|
|
enableProcessDemos?: boolean;
|
|
secretRefExample?: string | EnvSecretRefBinding;
|
|
httpDemoUrl?: string;
|
|
allowedCommands?: string[];
|
|
workspaceScratchFile?: string;
|
|
};
|
|
|
|
type DemoRecord = {
|
|
id: string;
|
|
level: "info" | "warning" | "error";
|
|
source: string;
|
|
message: string;
|
|
createdAt: string;
|
|
data?: unknown;
|
|
};
|
|
|
|
type ProcessResult = {
|
|
commandKey: string;
|
|
cwd: string;
|
|
code: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
startedAt: string;
|
|
finishedAt: string;
|
|
};
|
|
|
|
const recentRecords: DemoRecord[] = [];
|
|
const runtimeLaunchers = new Map<string, PluginLauncherRegistration>();
|
|
let currentContext: PluginContext | null = null;
|
|
let lastProcessResult: ProcessResult | null = null;
|
|
|
|
function isScopeKind(value: unknown): value is PluginStateScopeKind {
|
|
return typeof value === "string" && PLUGIN_STATE_SCOPE_KINDS.includes(value as PluginStateScopeKind);
|
|
}
|
|
|
|
function summarizeError(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|
|
|
|
function pushRecord(record: Omit<DemoRecord, "id" | "createdAt">): DemoRecord {
|
|
const next: DemoRecord = {
|
|
id: randomUUID(),
|
|
createdAt: new Date().toISOString(),
|
|
...record,
|
|
};
|
|
recentRecords.unshift(next);
|
|
if (recentRecords.length > 50) recentRecords.length = 50;
|
|
return next;
|
|
}
|
|
|
|
async function getConfig(ctx: PluginContext, companyId?: string): Promise<KitchenSinkConfig> {
|
|
const config = await ctx.config.get(companyId);
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
...(config as KitchenSinkConfig),
|
|
};
|
|
}
|
|
|
|
function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
&& (value as { type?: unknown }).type === "secret_ref"
|
|
&& typeof (value as { secretId?: unknown }).secretId === "string";
|
|
}
|
|
|
|
async function writeInstanceState(ctx: PluginContext, stateKey: string, value: unknown): Promise<void> {
|
|
await ctx.state.set({ scopeKind: "instance", stateKey }, value);
|
|
}
|
|
|
|
async function readInstanceState<T = unknown>(ctx: PluginContext, stateKey: string): Promise<T | null> {
|
|
return await ctx.state.get({ scopeKind: "instance", stateKey }) as T | null;
|
|
}
|
|
|
|
async function resolveWorkspace(
|
|
ctx: PluginContext,
|
|
companyId: string,
|
|
projectId: string,
|
|
workspaceId?: string,
|
|
): Promise<PluginWorkspace> {
|
|
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
|
if (workspaces.length === 0) {
|
|
throw new Error("No workspaces configured for this project");
|
|
}
|
|
if (!workspaceId) return workspaces[0]!;
|
|
const workspace = workspaces.find((entry) => entry.id === workspaceId);
|
|
if (!workspace) {
|
|
throw new Error("Workspace not found");
|
|
}
|
|
return workspace;
|
|
}
|
|
|
|
function ensureInsideWorkspace(workspacePath: string, relativePath: string): string {
|
|
const root = path.resolve(workspacePath);
|
|
const resolved = path.resolve(root, relativePath);
|
|
const relative = path.relative(root, resolved);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
throw new Error("Requested path escapes the selected workspace");
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function parseJsonish(value: string): unknown {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) return "";
|
|
try {
|
|
return JSON.parse(trimmed) as unknown;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function parseScopeKey(params: Record<string, unknown>): ScopeKey {
|
|
const scopeKind = isScopeKind(params.scopeKind) ? params.scopeKind : "instance";
|
|
const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined;
|
|
const namespace = typeof params.namespace === "string" && params.namespace.length > 0 ? params.namespace : undefined;
|
|
const stateKey = typeof params.stateKey === "string" && params.stateKey.length > 0
|
|
? params.stateKey
|
|
: "demo";
|
|
return { scopeKind, scopeId, namespace, stateKey };
|
|
}
|
|
|
|
async function runCuratedCommand(
|
|
ctx: PluginContext,
|
|
config: KitchenSinkConfig,
|
|
companyId: string,
|
|
projectId: string,
|
|
workspaceId: string | undefined,
|
|
commandKey: string,
|
|
): Promise<ProcessResult> {
|
|
if (!config.enableProcessDemos) {
|
|
throw new Error("Process demos are disabled in plugin settings");
|
|
}
|
|
const allowedCommands = new Set(config.allowedCommands ?? DEFAULT_CONFIG.allowedCommands);
|
|
if (!allowedCommands.has(commandKey)) {
|
|
throw new Error(`Command "${commandKey}" is not allowed by plugin settings`);
|
|
}
|
|
const definition = SAFE_COMMANDS.find((entry) => entry.key === commandKey);
|
|
if (!definition) {
|
|
throw new Error(`Unknown curated command "${commandKey}"`);
|
|
}
|
|
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
|
|
const cwd = workspace.path;
|
|
const startedAt = new Date().toISOString();
|
|
const child = spawn(definition.command, definition.args, {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
|
|
const code = await new Promise<number | null>((resolve, reject) => {
|
|
child.on("error", reject);
|
|
child.on("close", resolve);
|
|
});
|
|
|
|
const result: ProcessResult = {
|
|
commandKey,
|
|
cwd,
|
|
code,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
startedAt,
|
|
finishedAt: new Date().toISOString(),
|
|
};
|
|
lastProcessResult = result;
|
|
pushRecord({
|
|
level: code === 0 ? "info" : "warning",
|
|
source: "process",
|
|
message: `Ran curated command "${commandKey}"`,
|
|
data: { code, cwd },
|
|
});
|
|
await ctx.metrics.write("process.run", 1, { command: commandKey, exit_code: String(code ?? -1) });
|
|
return result;
|
|
}
|
|
|
|
function getCurrentCompanyId(params: Record<string, unknown>): string {
|
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
|
if (!companyId) {
|
|
throw new Error("companyId is required");
|
|
}
|
|
return companyId;
|
|
}
|
|
|
|
function getListLimit(params: Record<string, unknown>, fallback = 50): number {
|
|
const value = typeof params.limit === "number" ? params.limit : Number(params.limit ?? fallback);
|
|
if (!Number.isFinite(value)) return fallback;
|
|
return Math.max(1, Math.min(200, Math.floor(value)));
|
|
}
|
|
|
|
async function listIssuesForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Issue[]> {
|
|
return await ctx.issues.list({ companyId, limit, offset: 0 });
|
|
}
|
|
|
|
async function listGoalsForCompany(ctx: PluginContext, companyId: string, limit = 50): Promise<Goal[]> {
|
|
return await ctx.goals.list({ companyId, limit, offset: 0 });
|
|
}
|
|
|
|
function recentRecordsSnapshot(): DemoRecord[] {
|
|
return recentRecords.slice(0, 20);
|
|
}
|
|
|
|
function runtimeLaunchersSnapshot(): PluginLauncherRegistration[] {
|
|
return [...runtimeLaunchers.values()];
|
|
}
|
|
|
|
async function registerDataHandlers(ctx: PluginContext): Promise<void> {
|
|
ctx.data.register("plugin-config", async (params) => {
|
|
const companyId = typeof params.companyId === "string" ? params.companyId : undefined;
|
|
return await getConfig(ctx, companyId);
|
|
});
|
|
|
|
ctx.data.register("overview", async (params) => {
|
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
|
const config = companyId ? await getConfig(ctx, companyId) : DEFAULT_CONFIG;
|
|
const companies = await ctx.companies.list({ limit: 200, offset: 0 });
|
|
const projects = companyId ? await ctx.projects.list({ companyId, limit: 200, offset: 0 }) : [];
|
|
const issues = companyId ? await listIssuesForCompany(ctx, companyId, 200) : [];
|
|
const goals = companyId ? await listGoalsForCompany(ctx, companyId, 200) : [];
|
|
const agents = companyId ? await ctx.agents.list({ companyId, limit: 200, offset: 0 }) : [];
|
|
const lastJob = await readInstanceState(ctx, "last-job-run");
|
|
const lastWebhook = await readInstanceState(ctx, "last-webhook");
|
|
const entityRecords = await ctx.entities.list({ limit: 10 } satisfies PluginEntityQuery);
|
|
return {
|
|
pluginId: PLUGIN_ID,
|
|
version: ctx.manifest.version,
|
|
capabilities: ctx.manifest.capabilities,
|
|
config,
|
|
runtimeLaunchers: runtimeLaunchersSnapshot(),
|
|
recentRecords: recentRecordsSnapshot(),
|
|
counts: {
|
|
companies: companies.length,
|
|
projects: projects.length,
|
|
issues: issues.length,
|
|
goals: goals.length,
|
|
agents: agents.length,
|
|
entities: entityRecords.length,
|
|
},
|
|
lastJob,
|
|
lastWebhook,
|
|
lastProcessResult,
|
|
streamChannels: STREAM_CHANNELS,
|
|
safeCommands: SAFE_COMMANDS,
|
|
manifest: {
|
|
jobs: ctx.manifest.jobs ?? [],
|
|
webhooks: ctx.manifest.webhooks ?? [],
|
|
tools: ctx.manifest.tools ?? [],
|
|
},
|
|
};
|
|
});
|
|
|
|
ctx.data.register("companies", async (params) => {
|
|
return await ctx.companies.list({ limit: getListLimit(params), offset: 0 });
|
|
});
|
|
|
|
ctx.data.register("projects", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
return await ctx.projects.list({ companyId, limit: getListLimit(params), offset: 0 });
|
|
});
|
|
|
|
ctx.data.register("issues", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
return await listIssuesForCompany(ctx, companyId, getListLimit(params));
|
|
});
|
|
|
|
ctx.data.register("goals", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
return await listGoalsForCompany(ctx, companyId, getListLimit(params));
|
|
});
|
|
|
|
ctx.data.register("agents", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
return await ctx.agents.list({ companyId, limit: getListLimit(params), offset: 0 });
|
|
});
|
|
|
|
ctx.data.register("workspaces", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
|
if (!projectId) return [];
|
|
return await ctx.projects.listWorkspaces(projectId, companyId);
|
|
});
|
|
|
|
ctx.data.register("state-value", async (params) => {
|
|
const input = parseScopeKey(params);
|
|
const value = await ctx.state.get(input);
|
|
return {
|
|
scope: input,
|
|
value,
|
|
};
|
|
});
|
|
|
|
ctx.data.register("entities", async (params) => {
|
|
const query: PluginEntityQuery = {
|
|
entityType: typeof params.entityType === "string" && params.entityType.length > 0 ? params.entityType : undefined,
|
|
scopeKind: isScopeKind(params.scopeKind) ? params.scopeKind : undefined,
|
|
scopeId: typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined,
|
|
limit: typeof params.limit === "number" ? params.limit : 25,
|
|
offset: 0,
|
|
};
|
|
return await ctx.entities.list(query);
|
|
});
|
|
|
|
ctx.data.register("comment-context", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
|
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
|
if (!issueId || !commentId) return null;
|
|
const comments = await ctx.issues.listComments(issueId, companyId);
|
|
const comment = comments.find((entry) => entry.id === commentId) ?? null;
|
|
if (!comment) return null;
|
|
return {
|
|
commentId: comment.id,
|
|
issueId,
|
|
preview: comment.body.slice(0, 160),
|
|
length: comment.body.length,
|
|
copiedCount: (await ctx.entities.list({
|
|
entityType: "copied-comment",
|
|
scopeKind: "issue",
|
|
scopeId: issueId,
|
|
limit: 100,
|
|
offset: 0,
|
|
})).filter((entry) => entry.externalId === commentId).length,
|
|
};
|
|
});
|
|
|
|
ctx.data.register("entity-context", async (params) => {
|
|
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
|
const entityId = typeof params.entityId === "string" ? params.entityId : "";
|
|
const entityType = typeof params.entityType === "string" ? params.entityType : "";
|
|
if (!companyId || !entityId || !entityType) return null;
|
|
|
|
if (entityType === "project") {
|
|
return await ctx.projects.get(entityId, companyId);
|
|
}
|
|
if (entityType === "issue") {
|
|
return await ctx.issues.get(entityId, companyId);
|
|
}
|
|
if (entityType === "goal") {
|
|
return await ctx.goals.get(entityId, companyId);
|
|
}
|
|
if (entityType === "agent") {
|
|
return await ctx.agents.get(entityId, companyId);
|
|
}
|
|
return { entityId, entityType, companyId };
|
|
});
|
|
}
|
|
|
|
async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|
ctx.actions.register("emit-demo-event", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const message = typeof params.message === "string" && params.message.trim().length > 0
|
|
? params.message.trim()
|
|
: "Kitchen Sink demo event";
|
|
await ctx.events.emit("demo-event", companyId, {
|
|
message,
|
|
source: "kitchen-sink",
|
|
emittedAt: new Date().toISOString(),
|
|
});
|
|
pushRecord({
|
|
level: "info",
|
|
source: "events.emit",
|
|
message,
|
|
data: { companyId },
|
|
});
|
|
await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" });
|
|
await ctx.telemetry.track("demo_event", {
|
|
source: "manual",
|
|
has_company: Boolean(companyId),
|
|
});
|
|
pushRecord({
|
|
level: "info",
|
|
source: "telemetry",
|
|
message: "Tracked plugin telemetry event demo_event",
|
|
data: { companyId },
|
|
});
|
|
return { ok: true, message };
|
|
});
|
|
|
|
ctx.actions.register("write-scoped-state", async (params) => {
|
|
const input = parseScopeKey(params);
|
|
const valueInput = typeof params.value === "string" ? params.value : JSON.stringify(params.value ?? "");
|
|
const value = parseJsonish(valueInput);
|
|
await ctx.state.set(input, value);
|
|
pushRecord({
|
|
level: "info",
|
|
source: "state",
|
|
message: `Wrote state key ${input.stateKey}`,
|
|
data: input,
|
|
});
|
|
await ctx.metrics.write("demo.state.write", 1, { scope: input.scopeKind });
|
|
return { ok: true, scope: input, value };
|
|
});
|
|
|
|
ctx.actions.register("delete-scoped-state", async (params) => {
|
|
const input = parseScopeKey(params);
|
|
await ctx.state.delete(input);
|
|
pushRecord({
|
|
level: "warning",
|
|
source: "state",
|
|
message: `Deleted state key ${input.stateKey}`,
|
|
data: input,
|
|
});
|
|
return { ok: true, scope: input };
|
|
});
|
|
|
|
ctx.actions.register("upsert-entity", async (params) => {
|
|
const title = typeof params.title === "string" && params.title.length > 0 ? params.title : "Kitchen Sink Entity";
|
|
const entityType = typeof params.entityType === "string" && params.entityType.length > 0
|
|
? params.entityType
|
|
: "demo-record";
|
|
const scopeKind = isScopeKind(params.scopeKind)
|
|
? params.scopeKind
|
|
: "instance";
|
|
const scopeId = typeof params.scopeId === "string" && params.scopeId.length > 0 ? params.scopeId : undefined;
|
|
const status = typeof params.status === "string" && params.status.length > 0 ? params.status : "active";
|
|
const data = typeof params.data === "string" ? parseJsonish(params.data) : params.data;
|
|
const record = await ctx.entities.upsert({
|
|
entityType,
|
|
scopeKind,
|
|
scopeId,
|
|
externalId: typeof params.externalId === "string" && params.externalId.length > 0 ? params.externalId : randomUUID(),
|
|
title,
|
|
status,
|
|
data: typeof data === "object" && data !== null ? data as Record<string, unknown> : { value: data },
|
|
});
|
|
pushRecord({
|
|
level: "info",
|
|
source: "entities",
|
|
message: `Upserted entity ${record.entityType}`,
|
|
data: { id: record.id, scopeKind: record.scopeKind },
|
|
});
|
|
return record;
|
|
});
|
|
|
|
ctx.actions.register("create-issue", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const title = typeof params.title === "string" && params.title.trim().length > 0
|
|
? params.title.trim()
|
|
: "Kitchen Sink demo issue";
|
|
const description = typeof params.description === "string" ? params.description : undefined;
|
|
const projectId = typeof params.projectId === "string" && params.projectId.length > 0 ? params.projectId : undefined;
|
|
const issue = await ctx.issues.create({ companyId, projectId, title, description });
|
|
pushRecord({
|
|
level: "info",
|
|
source: "issues.create",
|
|
message: `Created issue ${issue.title}`,
|
|
data: { issueId: issue.id },
|
|
});
|
|
await ctx.activity.log({
|
|
companyId,
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
message: `Kitchen Sink created issue "${issue.title}"`,
|
|
metadata: { plugin: PLUGIN_ID },
|
|
});
|
|
return issue;
|
|
});
|
|
|
|
ctx.actions.register("advance-issue-status", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
|
const status = typeof params.status === "string" ? params.status : "";
|
|
if (!issueId || !status) {
|
|
throw new Error("issueId and status are required");
|
|
}
|
|
const issue = await ctx.issues.update(issueId, { status: status as Issue["status"] }, companyId);
|
|
pushRecord({
|
|
level: "info",
|
|
source: "issues.update",
|
|
message: `Updated issue ${issue.id} to ${issue.status}`,
|
|
});
|
|
return issue;
|
|
});
|
|
|
|
ctx.actions.register("create-goal", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const title = typeof params.title === "string" && params.title.trim().length > 0
|
|
? params.title.trim()
|
|
: "Kitchen Sink demo goal";
|
|
const description = typeof params.description === "string" ? params.description : undefined;
|
|
const goal = await ctx.goals.create({ companyId, title, description, level: "team", status: "planned" });
|
|
pushRecord({
|
|
level: "info",
|
|
source: "goals.create",
|
|
message: `Created goal ${goal.title}`,
|
|
data: { goalId: goal.id },
|
|
});
|
|
return goal;
|
|
});
|
|
|
|
ctx.actions.register("advance-goal-status", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const goalId = typeof params.goalId === "string" ? params.goalId : "";
|
|
const status = typeof params.status === "string" ? params.status : "";
|
|
if (!goalId || !status) {
|
|
throw new Error("goalId and status are required");
|
|
}
|
|
const goal = await ctx.goals.update(goalId, { status: status as Goal["status"] }, companyId);
|
|
pushRecord({
|
|
level: "info",
|
|
source: "goals.update",
|
|
message: `Updated goal ${goal.id} to ${goal.status}`,
|
|
});
|
|
return goal;
|
|
});
|
|
|
|
ctx.actions.register("write-activity", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const entityType = typeof params.entityType === "string" ? params.entityType : undefined;
|
|
const entityId = typeof params.entityId === "string" ? params.entityId : undefined;
|
|
const message = typeof params.message === "string" && params.message.length > 0
|
|
? params.message
|
|
: "Kitchen Sink wrote an activity entry";
|
|
await ctx.activity.log({
|
|
companyId,
|
|
entityType,
|
|
entityId,
|
|
message,
|
|
metadata: { plugin: PLUGIN_ID },
|
|
});
|
|
pushRecord({
|
|
level: "info",
|
|
source: "activity",
|
|
message,
|
|
data: { entityType, entityId },
|
|
});
|
|
return { ok: true };
|
|
});
|
|
|
|
ctx.actions.register("write-metric", async (params) => {
|
|
const value = typeof params.value === "number" ? params.value : Number(params.value ?? 1);
|
|
const name = typeof params.name === "string" && params.name.length > 0 ? params.name : "manual";
|
|
await ctx.metrics.write(`demo.${name}`, Number.isFinite(value) ? value : 1, { source: "manual" });
|
|
pushRecord({
|
|
level: "info",
|
|
source: "metrics",
|
|
message: `Wrote metric demo.${name}`,
|
|
data: { value },
|
|
});
|
|
return { ok: true, value };
|
|
});
|
|
|
|
ctx.actions.register("http-fetch", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const config = await getConfig(ctx, companyId);
|
|
const url = typeof params.url === "string" && params.url.length > 0
|
|
? params.url
|
|
: config.httpDemoUrl || DEFAULT_CONFIG.httpDemoUrl;
|
|
const started = Date.now();
|
|
const response = await ctx.http.fetch(url, { method: "GET" });
|
|
const body = await response.text();
|
|
const result = {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
url,
|
|
durationMs: Date.now() - started,
|
|
body: body.slice(0, 2000),
|
|
};
|
|
pushRecord({
|
|
level: response.ok ? "info" : "warning",
|
|
source: "http",
|
|
message: `Fetched ${url}`,
|
|
data: { status: response.status },
|
|
});
|
|
return result;
|
|
});
|
|
|
|
ctx.actions.register("resolve-secret", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const config = await getConfig(ctx, companyId);
|
|
const secretRef = isSecretRefBinding(params.secretRef)
|
|
? params.secretRef
|
|
: isSecretRefBinding(config.secretRefExample)
|
|
? config.secretRefExample
|
|
: null;
|
|
if (!secretRef) {
|
|
throw new Error("No secret reference configured");
|
|
}
|
|
const resolved = await ctx.secrets.resolve(secretRef, { companyId, configPath: "secretRefExample" });
|
|
pushRecord({
|
|
level: "info",
|
|
source: "secrets",
|
|
message: `Resolved secret reference ${secretRef.secretId}`,
|
|
});
|
|
return {
|
|
secretRef: secretRef.secretId,
|
|
resolvedLength: resolved.length,
|
|
preview: resolved.length > 0 ? `${resolved.slice(0, 2)}***` : "",
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("run-process", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const config = await getConfig(ctx, companyId);
|
|
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
|
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
|
const commandKey = typeof params.commandKey === "string" ? params.commandKey : "pwd";
|
|
if (!projectId) throw new Error("projectId is required");
|
|
return await runCuratedCommand(ctx, config, companyId, projectId, workspaceId, commandKey);
|
|
});
|
|
|
|
ctx.actions.register("read-workspace-file", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const config = await getConfig(ctx, companyId);
|
|
if (!config.enableWorkspaceDemos) {
|
|
throw new Error("Workspace demos are disabled in plugin settings");
|
|
}
|
|
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
|
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
|
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
|
? params.relativePath
|
|
: config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile;
|
|
if (!projectId) throw new Error("projectId is required");
|
|
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
|
|
const fullPath = ensureInsideWorkspace(workspace.path, relativePath);
|
|
const content = await fs.readFile(fullPath, "utf8");
|
|
return {
|
|
workspaceId: workspace.id,
|
|
relativePath,
|
|
content,
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("write-workspace-scratch", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const config = await getConfig(ctx, companyId);
|
|
if (!config.enableWorkspaceDemos) {
|
|
throw new Error("Workspace demos are disabled in plugin settings");
|
|
}
|
|
const projectId = typeof params.projectId === "string" ? params.projectId : "";
|
|
const workspaceId = typeof params.workspaceId === "string" && params.workspaceId.length > 0 ? params.workspaceId : undefined;
|
|
const relativePath = typeof params.relativePath === "string" && params.relativePath.length > 0
|
|
? params.relativePath
|
|
: config.workspaceScratchFile || DEFAULT_CONFIG.workspaceScratchFile;
|
|
const content = typeof params.content === "string" ? params.content : "Kitchen Sink workspace demo";
|
|
if (!projectId) throw new Error("projectId is required");
|
|
const workspace = await resolveWorkspace(ctx, companyId, projectId, workspaceId);
|
|
const fullPath = ensureInsideWorkspace(workspace.path, relativePath);
|
|
await fs.writeFile(fullPath, content, "utf8");
|
|
pushRecord({
|
|
level: "info",
|
|
source: "workspace",
|
|
message: `Wrote scratch file ${relativePath}`,
|
|
data: { workspaceId: workspace.id },
|
|
});
|
|
return {
|
|
workspaceId: workspace.id,
|
|
relativePath,
|
|
bytes: Buffer.byteLength(content, "utf8"),
|
|
};
|
|
});
|
|
|
|
ctx.actions.register("start-progress-stream", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const steps = typeof params.steps === "number" ? params.steps : 5;
|
|
void (async () => {
|
|
ctx.streams.open(STREAM_CHANNELS.progress, companyId);
|
|
try {
|
|
for (let index = 1; index <= steps; index += 1) {
|
|
ctx.streams.emit(STREAM_CHANNELS.progress, {
|
|
step: index,
|
|
total: steps,
|
|
message: `Progress step ${index}/${steps}`,
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
}
|
|
} finally {
|
|
ctx.streams.close(STREAM_CHANNELS.progress);
|
|
}
|
|
})();
|
|
return { ok: true, channel: STREAM_CHANNELS.progress };
|
|
});
|
|
|
|
ctx.actions.register("invoke-agent", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
|
const prompt = typeof params.prompt === "string" && params.prompt.length > 0
|
|
? params.prompt
|
|
: "Kitchen Sink test invocation";
|
|
if (!agentId) throw new Error("agentId is required");
|
|
const result = await ctx.agents.invoke(agentId, companyId, { prompt, reason: "Kitchen Sink plugin demo" });
|
|
pushRecord({
|
|
level: "info",
|
|
source: "agents.invoke",
|
|
message: `Invoked agent ${agentId}`,
|
|
data: result,
|
|
});
|
|
return result;
|
|
});
|
|
|
|
ctx.actions.register("pause-agent", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
|
if (!agentId) throw new Error("agentId is required");
|
|
return await ctx.agents.pause(agentId, companyId);
|
|
});
|
|
|
|
ctx.actions.register("resume-agent", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
|
if (!agentId) throw new Error("agentId is required");
|
|
return await ctx.agents.resume(agentId, companyId);
|
|
});
|
|
|
|
ctx.actions.register("ask-agent", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
|
const prompt = typeof params.prompt === "string" && params.prompt.length > 0
|
|
? params.prompt
|
|
: "Say hello from the Kitchen Sink plugin.";
|
|
if (!agentId) throw new Error("agentId is required");
|
|
|
|
ctx.streams.open(STREAM_CHANNELS.agentChat, companyId);
|
|
const session = await ctx.agents.sessions.create(agentId, companyId, {
|
|
reason: "Kitchen Sink plugin chat demo",
|
|
});
|
|
|
|
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
|
prompt,
|
|
reason: "Kitchen Sink demo",
|
|
onEvent: (event) => {
|
|
ctx.streams.emit(STREAM_CHANNELS.agentChat, {
|
|
eventType: event.eventType,
|
|
stream: event.stream,
|
|
message: event.message,
|
|
payload: event.payload,
|
|
});
|
|
if (event.eventType === "done" || event.eventType === "error") {
|
|
ctx.streams.close(STREAM_CHANNELS.agentChat);
|
|
}
|
|
},
|
|
});
|
|
|
|
pushRecord({
|
|
level: "info",
|
|
source: "agent.sessions",
|
|
message: `Started agent session ${session.sessionId}`,
|
|
data: { agentId, sessionId: session.sessionId },
|
|
});
|
|
return { channel: STREAM_CHANNELS.agentChat, sessionId: session.sessionId };
|
|
});
|
|
|
|
ctx.actions.register("copy-comment-context", async (params) => {
|
|
const companyId = getCurrentCompanyId(params);
|
|
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
|
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
|
if (!issueId || !commentId) {
|
|
throw new Error("issueId and commentId are required");
|
|
}
|
|
const comments = await ctx.issues.listComments(issueId, companyId);
|
|
const comment = comments.find((entry) => entry.id === commentId);
|
|
if (!comment) {
|
|
throw new Error("Comment not found");
|
|
}
|
|
const record = await ctx.entities.upsert({
|
|
entityType: "copied-comment",
|
|
scopeKind: "issue",
|
|
scopeId: issueId,
|
|
externalId: comment.id,
|
|
title: `Copied comment ${comment.id.slice(0, 8)}`,
|
|
status: "captured",
|
|
data: {
|
|
commentId: comment.id,
|
|
issueId,
|
|
body: comment.body,
|
|
},
|
|
});
|
|
pushRecord({
|
|
level: "info",
|
|
source: "comments",
|
|
message: `Copied comment ${comment.id} into plugin entities`,
|
|
data: { recordId: record.id },
|
|
});
|
|
return record;
|
|
});
|
|
}
|
|
|
|
async function registerToolHandlers(ctx: PluginContext): Promise<void> {
|
|
ctx.tools.register(
|
|
TOOL_NAMES.echo,
|
|
{
|
|
displayName: "Kitchen Sink Echo",
|
|
description: "Echoes the provided message back to the caller.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
required: ["message"],
|
|
},
|
|
},
|
|
async (params, runCtx): Promise<ToolResult> => {
|
|
const payload = params as { message?: string };
|
|
return {
|
|
content: payload.message ?? "No message provided",
|
|
data: {
|
|
runCtx,
|
|
message: payload.message ?? "",
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
ctx.tools.register(
|
|
TOOL_NAMES.companySummary,
|
|
{
|
|
displayName: "Kitchen Sink Company Summary",
|
|
description: "Summarizes current company counts from the Paperclip APIs.",
|
|
parametersSchema: { type: "object", properties: {} },
|
|
},
|
|
async (_params, runCtx): Promise<ToolResult> => {
|
|
const projects = await ctx.projects.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
|
|
const issues = await ctx.issues.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
|
|
const goals = await ctx.goals.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
|
|
const agents = await ctx.agents.list({ companyId: runCtx.companyId, limit: 50, offset: 0 });
|
|
return {
|
|
content: `Company has ${projects.length} projects, ${issues.length} issues, ${goals.length} goals, and ${agents.length} agents.`,
|
|
data: {
|
|
companyId: runCtx.companyId,
|
|
projects: projects.length,
|
|
issues: issues.length,
|
|
goals: goals.length,
|
|
agents: agents.length,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
|
|
ctx.tools.register(
|
|
TOOL_NAMES.createIssue,
|
|
{
|
|
displayName: "Kitchen Sink Create Issue",
|
|
description: "Creates an issue in the current run context.",
|
|
parametersSchema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
},
|
|
required: ["title"],
|
|
},
|
|
},
|
|
async (params, runCtx): Promise<ToolResult> => {
|
|
const payload = params as { title?: string; description?: string };
|
|
if (!payload.title) {
|
|
return { error: "title is required" };
|
|
}
|
|
const issue = await ctx.issues.create({
|
|
companyId: runCtx.companyId,
|
|
// The run context has an empty project ID when the run has no project.
|
|
projectId: runCtx.projectId || undefined,
|
|
title: payload.title,
|
|
description: payload.description,
|
|
});
|
|
return {
|
|
content: `Created issue ${issue.title}`,
|
|
data: issue,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
async function registerEventHandlers(ctx: PluginContext): Promise<void> {
|
|
ctx.events.on("issue.created", async (event: PluginEvent) => {
|
|
pushRecord({
|
|
level: "info",
|
|
source: "events.subscribe",
|
|
message: "Observed issue.created",
|
|
data: event,
|
|
});
|
|
});
|
|
|
|
ctx.events.on("issue.updated", async (event: PluginEvent) => {
|
|
pushRecord({
|
|
level: "info",
|
|
source: "events.subscribe",
|
|
message: "Observed issue.updated",
|
|
data: event,
|
|
});
|
|
});
|
|
|
|
ctx.events.on(`plugin.${PLUGIN_ID}.demo-event`, async (event: PluginEvent) => {
|
|
pushRecord({
|
|
level: "info",
|
|
source: "plugin-event",
|
|
message: "Observed plugin demo event",
|
|
data: event,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function registerJobs(ctx: PluginContext): Promise<void> {
|
|
ctx.jobs.register(JOB_KEYS.heartbeat, async (job: PluginJobContext) => {
|
|
const payload = {
|
|
jobKey: job.jobKey,
|
|
runId: job.runId,
|
|
trigger: job.trigger,
|
|
scheduledAt: job.scheduledAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
await writeInstanceState(ctx, "last-job-run", payload);
|
|
pushRecord({
|
|
level: "info",
|
|
source: "jobs",
|
|
message: "Kitchen Sink demo job ran",
|
|
data: payload,
|
|
});
|
|
await ctx.metrics.write("jobs.demo_heartbeat", 1, { trigger: job.trigger });
|
|
});
|
|
}
|
|
|
|
const plugin: PaperclipPlugin = definePlugin({
|
|
async setup(ctx) {
|
|
currentContext = ctx;
|
|
runtimeLaunchers.set(RUNTIME_LAUNCHER.id, RUNTIME_LAUNCHER);
|
|
ctx.launchers.register(RUNTIME_LAUNCHER);
|
|
pushRecord({
|
|
level: "info",
|
|
source: "setup",
|
|
message: "Kitchen Sink plugin setup complete",
|
|
data: { pluginId: PLUGIN_ID },
|
|
});
|
|
await registerEventHandlers(ctx);
|
|
await registerJobs(ctx);
|
|
await registerDataHandlers(ctx);
|
|
await registerActionHandlers(ctx);
|
|
await registerToolHandlers(ctx);
|
|
},
|
|
|
|
async onHealth(): Promise<PluginHealthDiagnostics> {
|
|
const config: KitchenSinkConfig = DEFAULT_CONFIG;
|
|
return {
|
|
status: "ok",
|
|
message: "Kitchen Sink plugin ready",
|
|
details: {
|
|
recordsTracked: recentRecords.length,
|
|
runtimeLaunchers: runtimeLaunchers.size,
|
|
processDemosEnabled: config.enableProcessDemos === true,
|
|
workspaceDemosEnabled: config.enableWorkspaceDemos !== false,
|
|
},
|
|
};
|
|
},
|
|
|
|
async onConfigChanged(newConfig) {
|
|
pushRecord({
|
|
level: "info",
|
|
source: "config",
|
|
message: "Kitchen Sink config changed",
|
|
data: newConfig,
|
|
});
|
|
},
|
|
|
|
async onValidateConfig(config) {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
const typed = config as KitchenSinkConfig;
|
|
if (typed.httpDemoUrl && typeof typed.httpDemoUrl !== "string") {
|
|
errors.push("httpDemoUrl must be a string");
|
|
}
|
|
if (typed.allowedCommands && !Array.isArray(typed.allowedCommands)) {
|
|
errors.push("allowedCommands must be an array");
|
|
}
|
|
if (Array.isArray(typed.allowedCommands)) {
|
|
const allowed = new Set<string>(SAFE_COMMANDS.map((command) => command.key));
|
|
const invalid = typed.allowedCommands.filter((value) => typeof value !== "string" || !allowed.has(value));
|
|
if (invalid.length > 0) {
|
|
errors.push(`allowedCommands contains unsupported values: ${invalid.join(", ")}`);
|
|
}
|
|
}
|
|
if (typed.enableProcessDemos) {
|
|
warnings.push("Process demos run local child processes and are intended only for trusted development environments.");
|
|
}
|
|
return {
|
|
ok: errors.length === 0,
|
|
warnings,
|
|
errors,
|
|
};
|
|
},
|
|
|
|
async onWebhook(input: PluginWebhookInput) {
|
|
const payload = {
|
|
endpointKey: input.endpointKey,
|
|
requestId: input.requestId,
|
|
rawBody: input.rawBody,
|
|
parsedBody: input.parsedBody,
|
|
receivedAt: new Date().toISOString(),
|
|
};
|
|
const ctx = currentContext;
|
|
if (ctx) {
|
|
await writeInstanceState(ctx, "last-webhook", payload);
|
|
}
|
|
pushRecord({
|
|
level: "info",
|
|
source: "webhook",
|
|
message: `Received webhook ${input.endpointKey}`,
|
|
data: payload,
|
|
});
|
|
if (input.endpointKey !== WEBHOOK_KEYS.demo) {
|
|
throw new Error(`Unsupported webhook endpoint "${input.endpointKey}"`);
|
|
}
|
|
},
|
|
|
|
async onShutdown() {
|
|
pushRecord({
|
|
level: "warning",
|
|
source: "shutdown",
|
|
message: "Kitchen Sink plugin shutting down",
|
|
});
|
|
},
|
|
});
|
|
|
|
export default plugin;
|
|
runWorker(plugin, import.meta.url);
|