mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
[codex] Add plugin orchestration host APIs (#4114)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is the extension path for optional capabilities that should not require core product changes for every integration. > - Plugins need scoped host APIs for issue orchestration, documents, wakeups, summaries, activity attribution, and isolated database state. > - Without those host APIs, richer plugins either cannot coordinate Paperclip work safely or need privileged core-side special cases. > - This pull request adds the plugin orchestration host surface, scoped route dispatch, a database namespace layer, and a smoke plugin that exercises the contract. > - The benefit is a broader plugin API that remains company-scoped, auditable, and covered by tests. ## What Changed - Added plugin orchestration host APIs for issue creation, document access, wakeups, summaries, plugin-origin activity, and scoped API route dispatch. - Added plugin database namespace tables, schema exports, migration checks, and idempotent replay coverage under migration `0059_plugin_database_namespaces`. - Added shared plugin route/API types and validators used by server and SDK boundaries. - Expanded plugin SDK types, protocol helpers, worker RPC host behavior, and testing utilities for orchestration flows. - Added the `plugin-orchestration-smoke-example` package to exercise scoped routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces. - Kept the new orchestration smoke fixture out of the root pnpm workspace importer so this PR preserves the repository policy of not committing `pnpm-lock.yaml`. - Updated plugin docs and database docs for the new orchestration and database namespace surfaces. - Rebased the branch onto `public-gh/master`, resolved conflicts, and removed `pnpm-lock.yaml` from the final PR diff. ## Verification - `pnpm install --frozen-lockfile` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run packages/db/src/client.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-scoped-api-routes.test.ts server/src/__tests__/plugin-sdk-orchestration-contract.test.ts` - From `packages/plugins/examples/plugin-orchestration-smoke-example`: `pnpm exec vitest run --config ./vitest.config.ts` - `pnpm --dir packages/plugins/examples/plugin-orchestration-smoke-example run typecheck` - `pnpm --filter @paperclipai/server typecheck` - PR CI on latest head `293fc67c`: `policy`, `verify`, `e2e`, and `security/snyk` all passed. ## Risks - Medium risk: this expands plugin host authority, so route auth, company scoping, and plugin-origin activity attribution need careful review. - Medium risk: database namespace migration behavior must remain idempotent for environments that may have seen earlier branch versions. - Medium risk: the orchestration smoke fixture is intentionally excluded from the root workspace importer to avoid a `pnpm-lock.yaml` PR diff; direct fixture verification remains listed above. - Low operational risk from the PR setup itself: the branch is rebased onto current `master`, the migration is ordered after upstream `0057`/`0058`, and `pnpm-lock.yaml` is not in the final diff. > 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`. Roadmap checked: this work aligns with the completed Plugin system milestone and extends the plugin surface rather than duplicating an unrelated planned core feature. ## Model Used - OpenAI Codex, GPT-5-based coding agent in a tool-enabled CLI environment. Exact hosted model build and context-window size are not exposed by the runtime; reasoning/tool use were enabled for repository inspection, editing, testing, git operations, and PR creation. ## 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 (N/A: no core UI screen change; example plugin UI contract is covered by tests) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
16b2b84d84
commit
9c6f551595
53 changed files with 5584 additions and 53 deletions
|
|
@ -1,6 +1,14 @@
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginLogs, agentTaskSessions as agentTaskSessionsTable } from "@paperclipai/db";
|
||||
import { eq, and, like, desc } from "drizzle-orm";
|
||||
import {
|
||||
agentTaskSessions as agentTaskSessionsTable,
|
||||
agents as agentsTable,
|
||||
budgetIncidents,
|
||||
costEvents,
|
||||
heartbeatRuns,
|
||||
issues as issuesTable,
|
||||
pluginLogs,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
|
||||
import type {
|
||||
HostServices,
|
||||
Company,
|
||||
|
|
@ -10,7 +18,10 @@ import type {
|
|||
Goal,
|
||||
PluginWorkspace,
|
||||
IssueComment,
|
||||
PluginIssueAssigneeSummary,
|
||||
PluginIssueOrchestrationSummary,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import type { IssueDocumentSummary } from "@paperclipai/shared";
|
||||
import { companyService } from "./companies.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { projectService } from "./projects.js";
|
||||
|
|
@ -18,6 +29,8 @@ import { issueService } from "./issues.js";
|
|||
import { goalService } from "./goals.js";
|
||||
import { documentService } from "./documents.js";
|
||||
import { heartbeatService } from "./heartbeat.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { issueApprovalService } from "./issue-approvals.js";
|
||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { activityService } from "./activity.js";
|
||||
|
|
@ -25,6 +38,7 @@ 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 { createPluginSecretsHandler } from "./plugin-secrets-handler.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||
|
|
@ -447,6 +461,7 @@ export function buildHostServices(
|
|||
): HostServices & { dispose(): void } {
|
||||
const registry = pluginRegistryService(db);
|
||||
const stateStore = pluginStateStore(db);
|
||||
const pluginDb = pluginDatabaseService(db);
|
||||
const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||
const companies = companyService(db);
|
||||
const agents = agentService(db);
|
||||
|
|
@ -457,6 +472,8 @@ export function buildHostServices(
|
|||
const goals = goalService(db);
|
||||
const activity = activityService(db);
|
||||
const costs = costService(db);
|
||||
const budgets = budgetService(db);
|
||||
const issueApprovals = issueApprovalService(db);
|
||||
const assets = assetService(db);
|
||||
const scopedBus = eventBus.forPlugin(pluginKey);
|
||||
|
||||
|
|
@ -512,6 +529,216 @@ export function buildHostServices(
|
|||
return record;
|
||||
};
|
||||
|
||||
const pluginActivityDetails = (
|
||||
details: Record<string, unknown> | null | undefined,
|
||||
actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null },
|
||||
) => {
|
||||
const initiatingActorType = actor?.actorAgentId ? "agent" : actor?.actorUserId ? "user" : null;
|
||||
const initiatingActorId = actor?.actorAgentId ?? actor?.actorUserId ?? null;
|
||||
return {
|
||||
...(details ?? {}),
|
||||
sourcePluginId: pluginId,
|
||||
sourcePluginKey: pluginKey,
|
||||
initiatingActorType,
|
||||
initiatingActorId,
|
||||
initiatingAgentId: actor?.actorAgentId ?? null,
|
||||
initiatingUserId: actor?.actorUserId ?? null,
|
||||
initiatingRunId: actor?.actorRunId ?? null,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultPluginOriginKind = `plugin:${pluginKey}`;
|
||||
const normalizePluginOriginKind = (originKind: unknown = defaultPluginOriginKind) => {
|
||||
if (originKind == null || originKind === "") return defaultPluginOriginKind;
|
||||
if (typeof originKind !== "string") {
|
||||
throw new Error("Plugin issue originKind must be a string");
|
||||
}
|
||||
if (originKind === defaultPluginOriginKind || originKind.startsWith(`${defaultPluginOriginKind}:`)) {
|
||||
return originKind;
|
||||
}
|
||||
throw new Error(`Plugin may only use originKind values under ${defaultPluginOriginKind}`);
|
||||
};
|
||||
|
||||
const assertReadableOriginFilter = (originKind: unknown) => {
|
||||
if (typeof originKind !== "string" || !originKind.startsWith("plugin:")) return;
|
||||
normalizePluginOriginKind(originKind);
|
||||
};
|
||||
|
||||
const logPluginActivity = async (input: {
|
||||
companyId: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
actor?: { actorAgentId?: string | null; actorUserId?: string | null; actorRunId?: string | null };
|
||||
}) => {
|
||||
await logActivity(db, {
|
||||
companyId: input.companyId,
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
agentId: input.actor?.actorAgentId ?? null,
|
||||
runId: input.actor?.actorRunId ?? null,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
details: pluginActivityDetails(input.details, input.actor),
|
||||
});
|
||||
};
|
||||
|
||||
const collectIssueSubtreeIds = async (companyId: string, rootIssueId: string) => {
|
||||
const seen = new Set<string>([rootIssueId]);
|
||||
let frontier = [rootIssueId];
|
||||
|
||||
while (frontier.length > 0) {
|
||||
const children = await db
|
||||
.select({ id: issuesTable.id })
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.parentId, frontier)));
|
||||
frontier = children.map((child) => child.id).filter((id) => !seen.has(id));
|
||||
for (const id of frontier) seen.add(id);
|
||||
}
|
||||
|
||||
return [...seen];
|
||||
};
|
||||
|
||||
const getIssueRunSummaries = async (
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
options: { activeOnly?: boolean } = {},
|
||||
) => {
|
||||
if (issueIds.length === 0) return [];
|
||||
const issueIdExpr = sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`;
|
||||
const statusCondition = options.activeOnly
|
||||
? inArray(heartbeatRuns.status, ["queued", "running"])
|
||||
: undefined;
|
||||
const rows = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
issueId: issueIdExpr,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
error: heartbeatRuns.error,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, companyId), inArray(issueIdExpr, issueIds), statusCondition))
|
||||
.orderBy(desc(heartbeatRuns.createdAt))
|
||||
.limit(100);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAt: row.startedAt?.toISOString() ?? null,
|
||||
finishedAt: row.finishedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
const setBlockedByWithActivity = async (params: {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
blockedByIssueIds: string[];
|
||||
mutation: "set" | "add" | "remove";
|
||||
actorAgentId?: string | null;
|
||||
actorUserId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
}) => {
|
||||
const existing = requireInCompany("Issue", await issues.getById(params.issueId), params.companyId);
|
||||
const previous = await issues.getRelationSummaries(params.issueId);
|
||||
await issues.update(params.issueId, {
|
||||
blockedByIssueIds: params.blockedByIssueIds,
|
||||
actorAgentId: params.actorAgentId ?? null,
|
||||
actorUserId: params.actorUserId ?? null,
|
||||
} as any);
|
||||
const relations = await issues.getRelationSummaries(params.issueId);
|
||||
await logPluginActivity({
|
||||
companyId: params.companyId,
|
||||
action: "issue.relations.updated",
|
||||
entityType: "issue",
|
||||
entityId: params.issueId,
|
||||
actor: {
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
},
|
||||
details: {
|
||||
identifier: existing.identifier,
|
||||
mutation: params.mutation,
|
||||
blockedByIssueIds: params.blockedByIssueIds,
|
||||
previousBlockedByIssueIds: previous.blockedBy.map((relation) => relation.id),
|
||||
},
|
||||
});
|
||||
return relations;
|
||||
};
|
||||
|
||||
const getIssueCostSummary = async (
|
||||
companyId: string,
|
||||
issueIds: string[],
|
||||
billingCode?: string | null,
|
||||
) => {
|
||||
const scopeConditions = [
|
||||
issueIds.length > 0 ? inArray(costEvents.issueId, issueIds) : undefined,
|
||||
billingCode ? eq(costEvents.billingCode, billingCode) : undefined,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
||||
if (scopeConditions.length === 0) {
|
||||
return {
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
billingCode: billingCode ?? null,
|
||||
};
|
||||
}
|
||||
const scopeCondition = scopeConditions.length === 1 ? scopeConditions[0]! : and(...scopeConditions);
|
||||
const [row] = await db
|
||||
.select({
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::double precision`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::double precision`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::double precision`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(eq(costEvents.companyId, companyId), scopeCondition));
|
||||
|
||||
return {
|
||||
costCents: Number(row?.costCents ?? 0),
|
||||
inputTokens: Number(row?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(row?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(row?.outputTokens ?? 0),
|
||||
billingCode: billingCode ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getOpenBudgetIncidents = async (companyId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: budgetIncidents.id,
|
||||
scopeType: budgetIncidents.scopeType,
|
||||
scopeId: budgetIncidents.scopeId,
|
||||
metric: budgetIncidents.metric,
|
||||
windowKind: budgetIncidents.windowKind,
|
||||
thresholdType: budgetIncidents.thresholdType,
|
||||
amountLimit: budgetIncidents.amountLimit,
|
||||
amountObserved: budgetIncidents.amountObserved,
|
||||
status: budgetIncidents.status,
|
||||
approvalId: budgetIncidents.approvalId,
|
||||
createdAt: budgetIncidents.createdAt,
|
||||
})
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
|
||||
.orderBy(desc(budgetIncidents.createdAt));
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
config: {
|
||||
async get() {
|
||||
|
|
@ -544,6 +771,18 @@ export function buildHostServices(
|
|||
},
|
||||
},
|
||||
|
||||
db: {
|
||||
async namespace() {
|
||||
return pluginDb.getRuntimeNamespace(pluginId);
|
||||
},
|
||||
async query(params) {
|
||||
return pluginDb.query(pluginId, params.sql, params.params);
|
||||
},
|
||||
async execute(params) {
|
||||
return pluginDb.execute(pluginId, params.sql, params.params);
|
||||
},
|
||||
},
|
||||
|
||||
entities: {
|
||||
async upsert(params) {
|
||||
return registry.upsertEntity(pluginId, params as any) as any;
|
||||
|
|
@ -604,12 +843,12 @@ export function buildHostServices(
|
|||
await ensurePluginAvailableForCompany(companyId);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
action: params.message,
|
||||
entityType: params.entityType ?? "plugin",
|
||||
entityId: params.entityId ?? pluginId,
|
||||
details: params.metadata,
|
||||
details: pluginActivityDetails(params.metadata),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
@ -775,6 +1014,7 @@ export function buildHostServices(
|
|||
async list(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
assertReadableOriginFilter(params.originKind);
|
||||
return applyWindow((await issues.list(companyId, params as any)) as Issue[], params);
|
||||
},
|
||||
async get(params) {
|
||||
|
|
@ -786,13 +1026,456 @@ export function buildHostServices(
|
|||
async create(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return (await issues.create(companyId, params as any)) as Issue;
|
||||
const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params;
|
||||
const normalizedOriginKind = normalizePluginOriginKind(originKind);
|
||||
const issue = (await issues.create(companyId, {
|
||||
...(issueInput as any),
|
||||
originKind: normalizedOriginKind,
|
||||
originId: params.originId ?? null,
|
||||
originRunId: params.originRunId ?? actorRunId ?? null,
|
||||
createdByAgentId: actorAgentId ?? null,
|
||||
createdByUserId: actorUserId ?? null,
|
||||
})) as Issue;
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.created",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
actor: { actorAgentId, actorUserId, actorRunId },
|
||||
details: {
|
||||
title: issue.title,
|
||||
identifier: issue.identifier,
|
||||
originKind: normalizedOriginKind,
|
||||
originId: issue.originId,
|
||||
billingCode: issue.billingCode,
|
||||
blockedByIssueIds: params.blockedByIssueIds ?? [],
|
||||
},
|
||||
});
|
||||
return issue;
|
||||
},
|
||||
async update(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const existing = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const patch = { ...(params.patch as Record<string, unknown>) };
|
||||
const actorAgentId = typeof patch.actorAgentId === "string" ? patch.actorAgentId : null;
|
||||
const actorUserId = typeof patch.actorUserId === "string" ? patch.actorUserId : null;
|
||||
const actorRunId = typeof patch.actorRunId === "string" ? patch.actorRunId : null;
|
||||
delete patch.actorAgentId;
|
||||
delete patch.actorUserId;
|
||||
delete patch.actorRunId;
|
||||
if (patch.originKind !== undefined) {
|
||||
patch.originKind = normalizePluginOriginKind(patch.originKind);
|
||||
}
|
||||
const updated = (await issues.update(params.issueId, {
|
||||
...(patch as any),
|
||||
actorAgentId,
|
||||
actorUserId,
|
||||
})) as Issue;
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: updated.id,
|
||||
actor: { actorAgentId, actorUserId, actorRunId },
|
||||
details: {
|
||||
identifier: updated.identifier,
|
||||
patch,
|
||||
_previous: {
|
||||
status: existing.status,
|
||||
assigneeAgentId: existing.assigneeAgentId,
|
||||
assigneeUserId: existing.assigneeUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
async getRelations(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
return (await issues.update(params.issueId, params.patch as any)) as Issue;
|
||||
return await issues.getRelationSummaries(params.issueId);
|
||||
},
|
||||
async setBlockedBy(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: params.blockedByIssueIds,
|
||||
mutation: "set",
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
},
|
||||
async addBlockers(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const previous = await issues.getRelationSummaries(params.issueId);
|
||||
const nextBlockedByIssueIds = [
|
||||
...new Set([
|
||||
...previous.blockedBy.map((relation) => relation.id),
|
||||
...params.blockerIssueIds,
|
||||
]),
|
||||
];
|
||||
return setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: nextBlockedByIssueIds,
|
||||
mutation: "add",
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
},
|
||||
async removeBlockers(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const previous = await issues.getRelationSummaries(params.issueId);
|
||||
const removals = new Set(params.blockerIssueIds);
|
||||
const nextBlockedByIssueIds = previous.blockedBy
|
||||
.map((relation) => relation.id)
|
||||
.filter((issueId) => !removals.has(issueId));
|
||||
return setBlockedByWithActivity({
|
||||
companyId,
|
||||
issueId: params.issueId,
|
||||
blockedByIssueIds: nextBlockedByIssueIds,
|
||||
mutation: "remove",
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
});
|
||||
},
|
||||
async assertCheckoutOwner(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const ownership = await issues.assertCheckoutOwner(
|
||||
params.issueId,
|
||||
params.actorAgentId,
|
||||
params.actorRunId,
|
||||
);
|
||||
if (ownership.adoptedFromRunId) {
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.checkout_lock_adopted",
|
||||
entityType: "issue",
|
||||
entityId: params.issueId,
|
||||
actor: {
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorRunId: params.actorRunId,
|
||||
},
|
||||
details: {
|
||||
previousCheckoutRunId: ownership.adoptedFromRunId,
|
||||
checkoutRunId: params.actorRunId,
|
||||
reason: "stale_checkout_run",
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
issueId: ownership.id,
|
||||
status: ownership.status as Issue["status"],
|
||||
assigneeAgentId: ownership.assigneeAgentId,
|
||||
checkoutRunId: ownership.checkoutRunId,
|
||||
adoptedFromRunId: ownership.adoptedFromRunId,
|
||||
};
|
||||
},
|
||||
async getSubtree(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const includeRoot = params.includeRoot !== false;
|
||||
const subtreeIssueIds = await collectIssueSubtreeIds(companyId, rootIssue.id);
|
||||
const issueIds = includeRoot ? subtreeIssueIds : subtreeIssueIds.filter((issueId) => issueId !== rootIssue.id);
|
||||
const issueRows = issueIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, issueIds)))
|
||||
: [];
|
||||
const issuesById = new Map(issueRows.map((issue) => [issue.id, issue as Issue]));
|
||||
const outputIssues = issueIds
|
||||
.map((issueId) => issuesById.get(issueId))
|
||||
.filter((issue): issue is Issue => Boolean(issue));
|
||||
|
||||
const assigneeAgentIds = [
|
||||
...new Set(outputIssues.map((issue) => issue.assigneeAgentId).filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
|
||||
const [relationPairs, documentPairs, activeRunRows, assigneeRows] = await Promise.all([
|
||||
params.includeRelations
|
||||
? Promise.all(issueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const))
|
||||
: Promise.resolve(null),
|
||||
params.includeDocuments
|
||||
? Promise.all(
|
||||
issueIds.map(async (issueId) => {
|
||||
const docs = await documents.listIssueDocuments(issueId);
|
||||
const summaries: IssueDocumentSummary[] = docs.map((document) => {
|
||||
const { body: _body, ...summary } = document as typeof document & { body?: string };
|
||||
return { ...summary, format: "markdown" as const };
|
||||
});
|
||||
return [
|
||||
issueId,
|
||||
summaries,
|
||||
] as const;
|
||||
}),
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
params.includeActiveRuns
|
||||
? getIssueRunSummaries(companyId, issueIds, { activeOnly: true })
|
||||
: Promise.resolve(null),
|
||||
params.includeAssignees && assigneeAgentIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: agentsTable.id,
|
||||
name: agentsTable.name,
|
||||
role: agentsTable.role,
|
||||
title: agentsTable.title,
|
||||
status: agentsTable.status,
|
||||
})
|
||||
.from(agentsTable)
|
||||
.where(and(eq(agentsTable.companyId, companyId), inArray(agentsTable.id, assigneeAgentIds)))
|
||||
: Promise.resolve(params.includeAssignees ? [] : null),
|
||||
]);
|
||||
|
||||
const activeRuns = activeRunRows
|
||||
? Object.fromEntries(issueIds.map((issueId) => [
|
||||
issueId,
|
||||
activeRunRows.filter((run) => run.issueId === issueId),
|
||||
]))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
rootIssueId: rootIssue.id,
|
||||
companyId,
|
||||
issueIds,
|
||||
issues: outputIssues,
|
||||
...(relationPairs ? { relations: Object.fromEntries(relationPairs) } : {}),
|
||||
...(documentPairs ? { documents: Object.fromEntries(documentPairs) } : {}),
|
||||
...(activeRuns ? { activeRuns } : {}),
|
||||
...(assigneeRows
|
||||
? {
|
||||
assignees: Object.fromEntries(assigneeRows.map((agent) => [
|
||||
agent.id,
|
||||
{ ...agent, status: agent.status as Agent["status"] } as PluginIssueAssigneeSummary,
|
||||
])),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
async requestWakeup(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
if (!issue.assigneeAgentId) {
|
||||
throw new Error("Issue has no assigned agent to wake");
|
||||
}
|
||||
if (["backlog", "done", "cancelled"].includes(issue.status)) {
|
||||
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
|
||||
}
|
||||
const relations = await issues.getRelationSummaries(issue.id);
|
||||
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
|
||||
if (unresolvedBlockers.length > 0) {
|
||||
throw new Error("Issue is blocked by unresolved blockers");
|
||||
}
|
||||
const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
if (budgetBlock) {
|
||||
throw new Error(budgetBlock.reason);
|
||||
}
|
||||
const contextSource = params.contextSource ?? "plugin.issue.requestWakeup";
|
||||
const run = await heartbeat.wakeup(issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
mutation: "plugin_wakeup",
|
||||
pluginId,
|
||||
pluginKey,
|
||||
contextSource,
|
||||
},
|
||||
idempotencyKey: params.idempotencyKey ?? null,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: pluginId,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
wakeReason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
source: contextSource,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
},
|
||||
});
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.assignment_wakeup_requested",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
actor: {
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
},
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
runId: run?.id ?? null,
|
||||
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
contextSource,
|
||||
},
|
||||
});
|
||||
return { queued: Boolean(run), runId: run?.id ?? null };
|
||||
},
|
||||
async requestWakeups(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const results = [];
|
||||
for (const issueId of [...new Set(params.issueIds)]) {
|
||||
const issue = requireInCompany("Issue", await issues.getById(issueId), companyId);
|
||||
if (!issue.assigneeAgentId) {
|
||||
throw new Error("Issue has no assigned agent to wake");
|
||||
}
|
||||
if (["backlog", "done", "cancelled"].includes(issue.status)) {
|
||||
throw new Error(`Issue is not wakeable in status: ${issue.status}`);
|
||||
}
|
||||
const relations = await issues.getRelationSummaries(issue.id);
|
||||
const unresolvedBlockers = relations.blockedBy.filter((blocker) => blocker.status !== "done");
|
||||
if (unresolvedBlockers.length > 0) {
|
||||
throw new Error("Issue is blocked by unresolved blockers");
|
||||
}
|
||||
const budgetBlock = await budgets.getInvocationBlock(companyId, issue.assigneeAgentId, {
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
if (budgetBlock) {
|
||||
throw new Error(budgetBlock.reason);
|
||||
}
|
||||
const contextSource = params.contextSource ?? "plugin.issue.requestWakeups";
|
||||
const run = await heartbeat.wakeup(issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
mutation: "plugin_wakeup",
|
||||
pluginId,
|
||||
pluginKey,
|
||||
contextSource,
|
||||
},
|
||||
idempotencyKey: params.idempotencyKeyPrefix ? `${params.idempotencyKeyPrefix}:${issue.id}` : null,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: pluginId,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
wakeReason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
source: contextSource,
|
||||
pluginId,
|
||||
pluginKey,
|
||||
},
|
||||
});
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.assignment_wakeup_requested",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
actor: {
|
||||
actorAgentId: params.actorAgentId,
|
||||
actorUserId: params.actorUserId,
|
||||
actorRunId: params.actorRunId,
|
||||
},
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
runId: run?.id ?? null,
|
||||
reason: params.reason ?? "plugin_issue_wakeup_requested",
|
||||
contextSource,
|
||||
},
|
||||
});
|
||||
results.push({ issueId: issue.id, queued: Boolean(run), runId: run?.id ?? null });
|
||||
}
|
||||
return results;
|
||||
},
|
||||
async getOrchestrationSummary(params): Promise<PluginIssueOrchestrationSummary> {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rootIssue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const subtreeIssueIds = params.includeSubtree
|
||||
? await collectIssueSubtreeIds(companyId, rootIssue.id)
|
||||
: [rootIssue.id];
|
||||
const relationPairs = await Promise.all(
|
||||
subtreeIssueIds.map(async (issueId) => [issueId, await issues.getRelationSummaries(issueId)] as const),
|
||||
);
|
||||
const approvalRows = (
|
||||
await Promise.all(
|
||||
subtreeIssueIds.map(async (issueId) => {
|
||||
const rows = await issueApprovals.listApprovalsForIssue(issueId);
|
||||
return rows.map((approval) => ({
|
||||
issueId,
|
||||
id: approval.id,
|
||||
type: approval.type,
|
||||
status: approval.status,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
requestedByUserId: approval.requestedByUserId,
|
||||
decidedByUserId: approval.decidedByUserId,
|
||||
decidedAt: approval.decidedAt?.toISOString() ?? null,
|
||||
createdAt: approval.createdAt.toISOString(),
|
||||
}));
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const [runs, costsSummary, openBudgetIncidents] = await Promise.all([
|
||||
getIssueRunSummaries(companyId, subtreeIssueIds),
|
||||
getIssueCostSummary(companyId, subtreeIssueIds, params.billingCode ?? rootIssue.billingCode ?? null),
|
||||
getOpenBudgetIncidents(companyId),
|
||||
]);
|
||||
const issueRows = await db
|
||||
.select({
|
||||
id: issuesTable.id,
|
||||
assigneeAgentId: issuesTable.assigneeAgentId,
|
||||
projectId: issuesTable.projectId,
|
||||
})
|
||||
.from(issuesTable)
|
||||
.where(and(eq(issuesTable.companyId, companyId), inArray(issuesTable.id, subtreeIssueIds)));
|
||||
const invocationBlocks = (
|
||||
await Promise.all(
|
||||
issueRows
|
||||
.filter((issueRow) => issueRow.assigneeAgentId)
|
||||
.map(async (issueRow) => {
|
||||
const block = await budgets.getInvocationBlock(companyId, issueRow.assigneeAgentId!, {
|
||||
issueId: issueRow.id,
|
||||
projectId: issueRow.projectId,
|
||||
});
|
||||
return block
|
||||
? {
|
||||
issueId: issueRow.id,
|
||||
agentId: issueRow.assigneeAgentId!,
|
||||
scopeType: block.scopeType,
|
||||
scopeId: block.scopeId,
|
||||
scopeName: block.scopeName,
|
||||
reason: block.reason,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
)
|
||||
).filter((block): block is NonNullable<typeof block> => block !== null);
|
||||
return {
|
||||
issueId: rootIssue.id,
|
||||
companyId,
|
||||
subtreeIssueIds,
|
||||
relations: Object.fromEntries(relationPairs),
|
||||
approvals: approvalRows,
|
||||
runs,
|
||||
costs: costsSummary,
|
||||
openBudgetIncidents,
|
||||
invocationBlocks,
|
||||
};
|
||||
},
|
||||
async listComments(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
|
|
@ -803,12 +1486,25 @@ export function buildHostServices(
|
|||
async createComment(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
return (await issues.addComment(
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const comment = (await issues.addComment(
|
||||
params.issueId,
|
||||
params.body,
|
||||
{ agentId: params.authorAgentId },
|
||||
)) as IssueComment;
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.comment.created",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
actor: { actorAgentId: params.authorAgentId ?? null },
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
commentId: comment.id,
|
||||
bodySnippet: comment.body.slice(0, 120),
|
||||
},
|
||||
});
|
||||
return comment;
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -830,7 +1526,7 @@ export function buildHostServices(
|
|||
async upsert(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const result = await documents.upsertIssueDocument({
|
||||
issueId: params.issueId,
|
||||
key: params.key,
|
||||
|
|
@ -839,13 +1535,35 @@ export function buildHostServices(
|
|||
format: params.format ?? "markdown",
|
||||
changeSummary: params.changeSummary ?? null,
|
||||
});
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.document_upserted",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
documentKey: params.key,
|
||||
title: params.title ?? null,
|
||||
format: params.format ?? "markdown",
|
||||
},
|
||||
});
|
||||
return result.document as any;
|
||||
},
|
||||
async delete(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.issueId), companyId);
|
||||
await documents.deleteIssueDocument(params.issueId, params.key);
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "issue.document_deleted",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
documentKey: params.key,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue