mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 10:00:38 +09:00
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
|
|
import { randomUUID } from "node:crypto";
|
||
|
|
import { definePlugin, runWorker, type PluginApiRequestInput } from "@paperclipai/plugin-sdk";
|
||
|
|
|
||
|
|
type SmokeInput = {
|
||
|
|
companyId: string;
|
||
|
|
issueId: string;
|
||
|
|
assigneeAgentId?: string | null;
|
||
|
|
actorAgentId?: string | null;
|
||
|
|
actorUserId?: string | null;
|
||
|
|
actorRunId?: string | null;
|
||
|
|
};
|
||
|
|
|
||
|
|
type SmokeSummary = {
|
||
|
|
rootIssueId: string;
|
||
|
|
childIssueId: string | null;
|
||
|
|
blockerIssueId: string | null;
|
||
|
|
billingCode: string;
|
||
|
|
joinedRows: unknown[];
|
||
|
|
subtreeIssueIds: string[];
|
||
|
|
wakeupQueued: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
let readSmokeSummary: ((companyId: string, issueId: string) => Promise<SmokeSummary | null>) | null = null;
|
||
|
|
let initializeSmoke: ((input: SmokeInput) => Promise<SmokeSummary>) | null = null;
|
||
|
|
|
||
|
|
function tableName(namespace: string) {
|
||
|
|
return `${namespace}.smoke_runs`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function stringField(value: unknown): string | null {
|
||
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const plugin = definePlugin({
|
||
|
|
async setup(ctx) {
|
||
|
|
readSmokeSummary = async function readSummary(companyId: string, issueId: string): Promise<SmokeSummary | null> {
|
||
|
|
const rows = await ctx.db.query<{
|
||
|
|
root_issue_id: string;
|
||
|
|
child_issue_id: string | null;
|
||
|
|
blocker_issue_id: string | null;
|
||
|
|
billing_code: string;
|
||
|
|
issue_title: string;
|
||
|
|
last_summary: unknown;
|
||
|
|
}>(
|
||
|
|
`SELECT s.root_issue_id, s.child_issue_id, s.blocker_issue_id, s.billing_code, i.title AS issue_title, s.last_summary
|
||
|
|
FROM ${tableName(ctx.db.namespace)} s
|
||
|
|
JOIN public.issues i ON i.id = s.root_issue_id
|
||
|
|
WHERE s.root_issue_id = $1`,
|
||
|
|
[issueId],
|
||
|
|
);
|
||
|
|
const row = rows[0];
|
||
|
|
if (!row) return null;
|
||
|
|
const orchestration = await ctx.issues.summaries.getOrchestration({
|
||
|
|
issueId,
|
||
|
|
companyId,
|
||
|
|
includeSubtree: true,
|
||
|
|
billingCode: row.billing_code,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
rootIssueId: row.root_issue_id,
|
||
|
|
childIssueId: row.child_issue_id,
|
||
|
|
blockerIssueId: row.blocker_issue_id,
|
||
|
|
billingCode: row.billing_code,
|
||
|
|
joinedRows: rows,
|
||
|
|
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||
|
|
wakeupQueued: Boolean((row.last_summary as { wakeupQueued?: unknown } | null)?.wakeupQueued),
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
initializeSmoke = async function runSmoke(input: SmokeInput): Promise<SmokeSummary> {
|
||
|
|
const root = await ctx.issues.get(input.issueId, input.companyId);
|
||
|
|
if (!root) throw new Error(`Issue not found: ${input.issueId}`);
|
||
|
|
|
||
|
|
const billingCode = `plugin-smoke:${input.issueId}`;
|
||
|
|
const actor = {
|
||
|
|
actorAgentId: input.actorAgentId ?? null,
|
||
|
|
actorUserId: input.actorUserId ?? null,
|
||
|
|
actorRunId: input.actorRunId ?? null,
|
||
|
|
};
|
||
|
|
const blocker = await ctx.issues.create({
|
||
|
|
companyId: input.companyId,
|
||
|
|
parentId: input.issueId,
|
||
|
|
inheritExecutionWorkspaceFromIssueId: input.issueId,
|
||
|
|
title: "Orchestration smoke blocker",
|
||
|
|
description: "Resolved blocker used to verify plugin relation writes without preventing the smoke wakeup.",
|
||
|
|
status: "done",
|
||
|
|
priority: "low",
|
||
|
|
billingCode,
|
||
|
|
originKind: `plugin:${ctx.manifest.id}:blocker`,
|
||
|
|
originId: `${input.issueId}:blocker`,
|
||
|
|
actor,
|
||
|
|
});
|
||
|
|
|
||
|
|
const child = await ctx.issues.create({
|
||
|
|
companyId: input.companyId,
|
||
|
|
parentId: input.issueId,
|
||
|
|
inheritExecutionWorkspaceFromIssueId: input.issueId,
|
||
|
|
title: "Orchestration smoke child",
|
||
|
|
description: "Generated by the orchestration smoke plugin to verify issue, document, relation, wakeup, and summary APIs.",
|
||
|
|
status: "todo",
|
||
|
|
priority: "medium",
|
||
|
|
assigneeAgentId: input.assigneeAgentId ?? root.assigneeAgentId ?? undefined,
|
||
|
|
billingCode,
|
||
|
|
originKind: `plugin:${ctx.manifest.id}:child`,
|
||
|
|
originId: `${input.issueId}:child`,
|
||
|
|
blockedByIssueIds: [blocker.id],
|
||
|
|
actor,
|
||
|
|
});
|
||
|
|
|
||
|
|
await ctx.issues.relations.setBlockedBy(child.id, [blocker.id], input.companyId, actor);
|
||
|
|
await ctx.issues.documents.upsert({
|
||
|
|
issueId: child.id,
|
||
|
|
companyId: input.companyId,
|
||
|
|
key: "orchestration-smoke",
|
||
|
|
title: "Orchestration Smoke",
|
||
|
|
format: "markdown",
|
||
|
|
body: [
|
||
|
|
"# Orchestration Smoke",
|
||
|
|
"",
|
||
|
|
`- Root issue: ${input.issueId}`,
|
||
|
|
`- Child issue: ${child.id}`,
|
||
|
|
`- Billing code: ${billingCode}`,
|
||
|
|
].join("\n"),
|
||
|
|
changeSummary: "Recorded orchestration smoke output",
|
||
|
|
});
|
||
|
|
|
||
|
|
const wakeup = await ctx.issues.requestWakeup(child.id, input.companyId, {
|
||
|
|
reason: "plugin:orchestration_smoke",
|
||
|
|
contextSource: "plugin-orchestration-smoke",
|
||
|
|
idempotencyKey: `${input.issueId}:child`,
|
||
|
|
...actor,
|
||
|
|
});
|
||
|
|
const orchestration = await ctx.issues.summaries.getOrchestration({
|
||
|
|
issueId: input.issueId,
|
||
|
|
companyId: input.companyId,
|
||
|
|
includeSubtree: true,
|
||
|
|
billingCode,
|
||
|
|
});
|
||
|
|
const summarySnapshot = {
|
||
|
|
childIssueId: child.id,
|
||
|
|
blockerIssueId: blocker.id,
|
||
|
|
wakeupQueued: wakeup.queued,
|
||
|
|
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||
|
|
};
|
||
|
|
|
||
|
|
await ctx.db.execute(
|
||
|
|
`INSERT INTO ${tableName(ctx.db.namespace)} (id, root_issue_id, child_issue_id, blocker_issue_id, billing_code, last_summary)
|
||
|
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||
|
|
ON CONFLICT (id) DO UPDATE SET
|
||
|
|
child_issue_id = EXCLUDED.child_issue_id,
|
||
|
|
blocker_issue_id = EXCLUDED.blocker_issue_id,
|
||
|
|
billing_code = EXCLUDED.billing_code,
|
||
|
|
last_summary = EXCLUDED.last_summary,
|
||
|
|
updated_at = now()`,
|
||
|
|
[
|
||
|
|
randomUUID(),
|
||
|
|
input.issueId,
|
||
|
|
child.id,
|
||
|
|
blocker.id,
|
||
|
|
billingCode,
|
||
|
|
JSON.stringify(summarySnapshot),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
rootIssueId: input.issueId,
|
||
|
|
childIssueId: child.id,
|
||
|
|
blockerIssueId: blocker.id,
|
||
|
|
billingCode,
|
||
|
|
joinedRows: await ctx.db.query(
|
||
|
|
`SELECT s.id, s.billing_code, i.title AS root_title
|
||
|
|
FROM ${tableName(ctx.db.namespace)} s
|
||
|
|
JOIN public.issues i ON i.id = s.root_issue_id
|
||
|
|
WHERE s.root_issue_id = $1`,
|
||
|
|
[input.issueId],
|
||
|
|
),
|
||
|
|
subtreeIssueIds: orchestration.subtreeIssueIds,
|
||
|
|
wakeupQueued: wakeup.queued,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
ctx.data.register("surface-status", async (params) => {
|
||
|
|
const companyId = stringField(params.companyId);
|
||
|
|
const issueId = stringField(params.issueId);
|
||
|
|
return {
|
||
|
|
status: "ok",
|
||
|
|
checkedAt: new Date().toISOString(),
|
||
|
|
databaseNamespace: ctx.db.namespace,
|
||
|
|
routeKeys: (ctx.manifest.apiRoutes ?? []).map((route) => route.routeKey),
|
||
|
|
capabilities: ctx.manifest.capabilities,
|
||
|
|
summary: companyId && issueId ? await readSmokeSummary?.(companyId, issueId) ?? null : null,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
ctx.actions.register("initialize-smoke", async (params) => {
|
||
|
|
const companyId = stringField(params.companyId);
|
||
|
|
const issueId = stringField(params.issueId);
|
||
|
|
if (!companyId || !issueId) throw new Error("companyId and issueId are required");
|
||
|
|
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
|
||
|
|
return initializeSmoke({
|
||
|
|
companyId,
|
||
|
|
issueId,
|
||
|
|
assigneeAgentId: stringField(params.assigneeAgentId),
|
||
|
|
actorAgentId: stringField(params.actorAgentId),
|
||
|
|
actorUserId: stringField(params.actorUserId),
|
||
|
|
actorRunId: stringField(params.actorRunId),
|
||
|
|
});
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
async onApiRequest(input: PluginApiRequestInput) {
|
||
|
|
if (input.routeKey === "summary") {
|
||
|
|
const issueId = input.params.issueId;
|
||
|
|
return {
|
||
|
|
body: await readSmokeSummary?.(input.companyId, issueId) ?? null,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (input.routeKey === "initialize") {
|
||
|
|
if (!initializeSmoke) throw new Error("Smoke initializer is not ready");
|
||
|
|
const body = input.body as Record<string, unknown> | null;
|
||
|
|
return {
|
||
|
|
status: 201,
|
||
|
|
body: await initializeSmoke({
|
||
|
|
companyId: input.companyId,
|
||
|
|
issueId: input.params.issueId,
|
||
|
|
assigneeAgentId: stringField(body?.assigneeAgentId),
|
||
|
|
actorAgentId: input.actor.agentId ?? null,
|
||
|
|
actorUserId: input.actor.userId ?? null,
|
||
|
|
actorRunId: input.actor.runId ?? null,
|
||
|
|
}),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
status: 404,
|
||
|
|
body: { error: `Unknown orchestration smoke route: ${input.routeKey}` },
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
async onHealth() {
|
||
|
|
return {
|
||
|
|
status: "ok",
|
||
|
|
message: "Orchestration smoke plugin worker is running",
|
||
|
|
details: {
|
||
|
|
surfaces: ["database", "scoped-api-route", "issue-panel", "orchestration-apis"],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
export default plugin;
|
||
|
|
runWorker(plugin, import.meta.url);
|