refactor(tools): isolate runtime and activity ownership (#17159)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators inspect tool execution, review pending actions and control
local runtimes.
> - These paths shared a large service with token exchange and app
authorization.
> - Runtime state and execution history need explicit owners and stable
public responses.
> - This pull request separates runtime controls, health summaries,
activity reads and the review queue.
> - The benefit is focused modules that preserve the current supervisor
and approval behavior.

## Linked Issues or Issue Description

Depends on #17157. Earlier stack: #17144 → #17146 → #17147 → #17155 →
#17157 → this PR.

**What existing behavior does this improve?**

Maintenance of tool runtime administration and execution activity views.

**Current behavior**

The 16,100-line service contains runtime slot controls, health
calculations, usage history, invocation decisions and action review
queries.

**Proposed behavior**

Runtime modules own slot persistence, operator controls and health
summaries. Activity modules own history, response projections and the
review queue. The public service delegates to these owners.

**Reason and benefit**

The entry point falls to 14,877 lines, a reduction of 1,223 lines. The
coordinator no longer needs execution-history joins or runtime alert
calculations.

## What Changed

- Extract five modules totaling 1,282 lines. The largest is 426 lines.
- Pass the existing supervisor to slot controls. Pass the same live
deployment options, clock and host getter to runtime health.
- Keep activity reads scoped as before. Keep unsigned-request grace and
invalid-signature cancellation in the review-queue owner.
- Preserve public methods, response projections, query order and audit
writes.

## Verification

- The public service and unsigned review-queue tests pass before and
after the move. The combined post-move run passes 469 cases with one
database worker.
- Existing cases exercise actual local stdio stop/restart, route
permissions, stale-slot and audit-failure health, activity attribution,
run decisions and unsigned pending requests.
- Server TypeScript check, module-boundary gate and `git diff --check`
pass.
- Normalized syntax comparison finds all 256 pre-existing
function/method bodies unchanged.
- Full typecheck/build will run at the stack handoff. The earlier
interrupted local full suite remains unverified; all current-head CI
gates pass, with Greptile 5/5 and no open review threads.

## Risks

- Creating another supervisor would split process ownership. The new
module receives the same instance; it does not construct one.
- Copying deployment settings could freeze live values. The health
reader holds the existing options object and getter.
- Review-queue reads can cancel invalid requests. Those writes and the
unsigned-request grace period are preserved and covered by the existing
regression test.
- No schema, public API, authorization policy or background scheduling
change is intended.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
The exact serving model ID and context-window size are not exposed in
this session.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-21 22:21:42 -05:00
1 parent a42f213244
commit ffcd113761
7 files changed
+1313 -1240

No files matched your search

@@ -175,3 +175,17 @@ Pages and RFC 8693 exchanges, plus a failing upstream request, and assert that
issuance/audit rows contain no token material. The complete public service and
HTTP rebinding run passes 495 cases. Server typechecking and module boundaries
pass. These fixtures do not claim live third-party qualification.
### Runtime and activity ownership extraction
Slot persistence and operator controls share the existing runtime supervisor.
The health reader receives the same live deployment options, clock and trusted
host getter. Activity projections and company-scoped history/usage reads live
separately from the action review queue. The queue retains its existing unsigned
request grace period and invalid-signature cancellation writes.
The entry point is 14,877 lines / 600,156 bytes. Five destination modules total
1,282 lines, with no module larger than 426 lines. All 256 pre-existing bodies
match normalized syntax. The public service and unsigned review-queue coverage
pass before and after (469 combined cases). Server typechecking and module
boundaries pass. No new supervisor, background loop or runtime cache is created.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,138 @@
import { toolActionRequests, toolCallEvents, toolInvocations } from "@paperclipai/db";
import type { ToolActionRequest, ToolCallEvent, ToolInvocation } from "@paperclipai/shared";
export function toToolInvocation(
row: typeof toolInvocations.$inferSelect,
): ToolInvocation {
return {
id: row.id,
companyId: row.companyId,
idempotencyKey: row.idempotencyKey,
actorType: row.actorType as ToolInvocation["actorType"],
actorId: row.actorId,
agentId: row.agentId,
issueId: row.issueId,
runId: row.runId,
applicationId: row.applicationId,
connectionId: row.connectionId,
catalogEntryId: row.catalogEntryId,
toolName: row.toolName,
argumentsHash: row.argumentsHash,
argumentsSummary: row.argumentsSummary ?? null,
policyDecision: row.policyDecision,
matchedPolicyIds: row.matchedPolicyIds,
approvalState: row.approvalState,
status: row.status,
upstreamRequestId: row.upstreamRequestId,
resultHash: row.resultHash,
resultSummary: row.resultSummary ?? null,
resultSizeBytes: row.resultSizeBytes,
resultArtifactId: row.resultArtifactId,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
startedAt: row.startedAt,
completedAt: row.completedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function toToolActionRequest(
row: typeof toolActionRequests.$inferSelect,
): ToolActionRequest {
return {
id: row.id,
companyId: row.companyId,
invocationId: row.invocationId,
issueId: row.issueId,
interactionId: row.interactionId,
approvalId: row.approvalId,
status: row.status,
canonicalArgumentsHash: row.canonicalArgumentsHash,
canonicalArgumentsSummary: row.canonicalArgumentsSummary,
signedArguments: row.signedArguments,
previewMarkdown: row.previewMarkdown,
requestedByAgentId: row.requestedByAgentId,
requestedByUserId: row.requestedByUserId,
resolvedByAgentId: row.resolvedByAgentId,
resolvedByUserId: row.resolvedByUserId,
decidedByAgentId: row.decidedByAgentId,
decidedByUserId: row.decidedByUserId,
decidedAt: row.decidedAt,
expiresAt: row.expiresAt,
resolvedAt: row.resolvedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function toToolCallEvent(
row: typeof toolCallEvents.$inferSelect,
): ToolCallEvent {
return {
id: row.id,
companyId: row.companyId,
eventType: row.eventType,
actorType: row.actorType as ToolCallEvent["actorType"],
actorId: row.actorId,
agentId: row.agentId,
runId: row.runId,
issueId: row.issueId,
applicationId: row.applicationId,
connectionId: row.connectionId,
catalogEntryId: row.catalogEntryId,
invocationId: row.invocationId,
actionRequestId: row.actionRequestId,
runtimeSlotId: row.runtimeSlotId,
toolName: row.toolName,
decision: row.decision,
matchedPolicyIds: row.matchedPolicyIds,
reasonCode: row.reasonCode,
outcome: row.outcome,
latencyMs: row.latencyMs,
argumentsSummary: row.argumentsSummary ?? null,
requestHash: row.requestHash,
requestSummary: row.requestSummary ?? null,
resultHash: row.resultHash,
resultSummary: row.resultSummary ?? null,
resultSizeBytes: row.resultSizeBytes,
redactionPlan: row.redactionPlan ?? null,
rateLimitState: row.rateLimitState ?? null,
metadata: row.metadata ?? null,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
createdAt: row.createdAt,
};
}
export function userFallbackName(userId: string): string {
if (userId === "local-board") return "Board";
return userId;
}
export function denialReasonForDecision(
invocation: typeof toolInvocations.$inferSelect,
latestAuditEvent: typeof toolCallEvents.$inferSelect | null,
) {
if (
invocation.status === "denied" ||
invocation.status === "rate_limited" ||
invocation.status === "failed" ||
invocation.status === "timed_out"
) {
return (
invocation.errorMessage ??
invocation.errorCode ??
latestAuditEvent?.reasonCode ??
null
);
}
if (
latestAuditEvent?.outcome === "denied" ||
latestAuditEvent?.outcome === "failure" ||
latestAuditEvent?.outcome === "timeout"
) {
return latestAuditEvent.errorMessage ?? latestAuditEvent.reasonCode ?? null;
}
return null;
}
@@ -0,0 +1,197 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolApplications, toolActionRequests, toolCatalogEntries, toolConnections, toolInvocations } from "@paperclipai/db";
import type { ToolActionRequestListItem, ToolActionRequestStatus } from "@paperclipai/shared";
import { readSignedToolArgumentsPayload, TOOL_ACTION_REQUEST_SIGNING_GRACE_MS } from "../../tool-content-guards.js";
import { toToolActionRequest } from "./projection.js";
/** Pending requests stay hidden until signing completes; invalid signatures are cancelled. */
export function createToolActionReviewQueue(db: Db) {
return {
/**
* List "Ask first" action requests for the review queue, enriched with the
* connection/app context the prosumer card renders. Defaults to pending.
*/
listActionRequests: async (
companyId: string,
status: ToolActionRequestStatus = "pending",
): Promise<ToolActionRequestListItem[]> => {
const requests = await db
.select()
.from(toolActionRequests)
.where(
and(
eq(toolActionRequests.companyId, companyId),
eq(toolActionRequests.status, status),
),
)
.orderBy(desc(toolActionRequests.createdAt));
if (requests.length === 0) return [];
const invocationIds = [
...new Set(requests.map((request) => request.invocationId)),
];
const invocations = await db
.select()
.from(toolInvocations)
.where(
and(
eq(toolInvocations.companyId, companyId),
inArray(toolInvocations.id, invocationIds),
),
);
const invocationById = new Map(
invocations.map((invocation) => [invocation.id, invocation]),
);
let visibleRequests = requests;
if (status === "pending") {
// A pending request that the creator has not signed yet is still being
// set up. The gateway creates the row (signedArguments = null) and signs
// it in a second step, so a review-queue read can observe the row inside
// that window. Hide such a request from the queue, but do not cancel it —
// cancelling here races the two-step create and makes the later approve
// fail with action_not_pending. Only cancel a request that carries a
// signature we cannot verify (secret rotation or tampering), or an
// unsigned row whose creator has exceeded the signing grace period.
const unsignedRequestIds = new Set<string>();
const invalidRequestIds: string[] = [];
for (const request of requests) {
const invocation = invocationById.get(request.invocationId);
if (!invocation) {
invalidRequestIds.push(request.id);
continue;
}
if (request.signedArguments === null) {
if (
Date.now() - request.createdAt.getTime() >=
TOOL_ACTION_REQUEST_SIGNING_GRACE_MS
) {
invalidRequestIds.push(request.id);
} else {
unsignedRequestIds.add(request.id);
}
continue;
}
let readable = false;
try {
readable = Boolean(
readSignedToolArgumentsPayload({
signedArguments: request.signedArguments,
invocationId: invocation.id,
toolName: invocation.toolName,
}),
);
} catch {
readable = false;
}
if (!readable) invalidRequestIds.push(request.id);
}
if (invalidRequestIds.length > 0) {
await db
.update(toolActionRequests)
.set({
status: "cancelled",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(toolActionRequests.companyId, companyId),
eq(toolActionRequests.status, "pending"),
inArray(toolActionRequests.id, invalidRequestIds),
),
);
}
const hiddenIds = new Set([
...invalidRequestIds,
...unsignedRequestIds,
]);
if (hiddenIds.size > 0) {
visibleRequests = requests.filter(
(request) => !hiddenIds.has(request.id),
);
}
}
if (visibleRequests.length === 0) return [];
const visibleInvocations = visibleRequests
.map((request) => invocationById.get(request.invocationId))
.filter(
(invocation): invocation is typeof toolInvocations.$inferSelect =>
Boolean(invocation),
);
const connectionIds = [
...new Set(
visibleInvocations
.map((invocation) => invocation.connectionId)
.filter(Boolean),
),
] as string[];
const connections = connectionIds.length
? await db
.select()
.from(toolConnections)
.where(inArray(toolConnections.id, connectionIds))
: [];
const connectionById = new Map(
connections.map((connection) => [connection.id, connection]),
);
const applicationIds = [
...new Set(
connections
.map((connection) => connection.applicationId)
.filter(Boolean),
),
] as string[];
const applications = applicationIds.length
? await db
.select()
.from(toolApplications)
.where(inArray(toolApplications.id, applicationIds))
: [];
const applicationById = new Map(
applications.map((application) => [application.id, application]),
);
const catalogEntryIds = [
...new Set(
visibleInvocations
.map((invocation) => invocation.catalogEntryId)
.filter(Boolean),
),
] as string[];
const catalogEntries = catalogEntryIds.length
? await db
.select()
.from(toolCatalogEntries)
.where(inArray(toolCatalogEntries.id, catalogEntryIds))
: [];
const catalogById = new Map(
catalogEntries.map((entry) => [entry.id, entry]),
);
return visibleRequests.map((request) => {
const invocation = invocationById.get(request.invocationId);
const connection = invocation?.connectionId
? connectionById.get(invocation.connectionId)
: undefined;
const application = connection?.applicationId
? applicationById.get(connection.applicationId)
: undefined;
const catalogEntry = invocation?.catalogEntryId
? catalogById.get(invocation.catalogEntryId)
: undefined;
return {
request: toToolActionRequest(request),
toolName: invocation?.toolName ?? catalogEntry?.toolName ?? "",
toolTitle: catalogEntry?.title ?? null,
connectionId: connection?.id ?? invocation?.connectionId ?? null,
connectionName: connection?.name ?? null,
applicationName: application?.name ?? null,
riskLevel: catalogEntry?.riskLevel ?? null,
requestedByAgentId: request.requestedByAgentId ?? null,
};
});
}
};
}
@@ -0,0 +1,339 @@
import { and, desc, eq, gte, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, connectionTokenIssuances, authUsers, heartbeatRuns, issues, toolActionRequests, toolConnections, toolCallEvents, toolInvocations } from "@paperclipai/db";
import type { ToolConnectionActivityResponse, ToolRunDecision, ToolRunDecisionLookup } from "@paperclipai/shared";
import { notFound } from "../../../errors.js";
import { listConnectionLifecycleEvents } from "../../tool-connection-activity.js";
import { toToolInvocation, toToolActionRequest, toToolCallEvent, userFallbackName, denialReasonForDecision } from "./projection.js";
type ConnectionLookup = (idOrUid: string, companyId?: string) => Promise<typeof toolConnections.$inferSelect>;
export function createToolActivityService(db: Db, getConnectionRow: ConnectionLookup) {
return {
getConnectionUsage: async (
idOrUid: string,
range: "7d" | "30d",
companyId?: string,
) => {
const connection = await getConnectionRow(idOrUid, companyId);
const days = range === "30d" ? 30 : 7;
const start = new Date();
start.setUTCHours(0, 0, 0, 0);
start.setUTCDate(start.getUTCDate() - days + 1);
const [issuances, invocations] = await Promise.all([
db
.select({
createdAt: connectionTokenIssuances.createdAt,
outcome: connectionTokenIssuances.outcome,
path: connectionTokenIssuances.path,
})
.from(connectionTokenIssuances)
.where(
and(
eq(connectionTokenIssuances.companyId, connection.companyId),
eq(connectionTokenIssuances.connectionId, connection.id),
gte(connectionTokenIssuances.createdAt, start),
),
),
db
.select({
createdAt: toolInvocations.createdAt,
riskLevel: toolInvocations.riskLevel,
})
.from(toolInvocations)
.where(
and(
eq(toolInvocations.companyId, connection.companyId),
eq(toolInvocations.connectionId, connection.id),
gte(toolInvocations.createdAt, start),
),
),
]);
const buckets = Array.from({ length: days }, (_, offset) => {
const date = new Date(start);
date.setUTCDate(start.getUTCDate() + offset);
return {
date: date.toISOString().slice(0, 10),
issuances: {
total: 0,
byOutcome: {} as Record<string, number>,
byPath: {} as Record<string, number>,
},
invocations: { total: 0, byRiskLevel: {} as Record<string, number> },
deliveries: { received: 0, forwarded: 0 },
};
});
const byDate = new Map(buckets.map((bucket) => [bucket.date, bucket]));
for (const row of issuances) {
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
if (!bucket) continue;
bucket.issuances.total += 1;
bucket.issuances.byOutcome[row.outcome] =
(bucket.issuances.byOutcome[row.outcome] ?? 0) + 1;
bucket.issuances.byPath[row.path] =
(bucket.issuances.byPath[row.path] ?? 0) + 1;
}
for (const row of invocations) {
const bucket = byDate.get(row.createdAt.toISOString().slice(0, 10));
if (!bucket) continue;
const riskLevel = row.riskLevel ?? "unknown";
bucket.invocations.total += 1;
bucket.invocations.byRiskLevel[riskLevel] =
(bucket.invocations.byRiskLevel[riskLevel] ?? 0) + 1;
}
return {
connection: { id: connection.id, uid: connection.uid },
range,
buckets,
};
},
/** Recent tool-call events for one connection — drives App detail · Recent activity. */
listConnectionActivity: async (
connectionId: string,
companyId?: string,
limit = 20,
): Promise<ToolConnectionActivityResponse> => {
const connection = await getConnectionRow(connectionId, companyId);
const safeLimit = Math.max(1, Math.min(100, Math.floor(limit)));
const rows = await db
.select()
.from(toolCallEvents)
.where(
and(
eq(toolCallEvents.companyId, connection.companyId),
eq(toolCallEvents.connectionId, connection.id),
),
)
.orderBy(desc(toolCallEvents.createdAt))
.limit(safeLimit);
const events = rows.map(toToolCallEvent);
const issueIds = [
...new Set(rows.map((row) => row.issueId).filter(Boolean)),
] as string[];
const issueRows = issueIds.length
? await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(
and(
eq(issues.companyId, connection.companyId),
inArray(issues.id, issueIds),
),
)
: [];
const issueMap = Object.fromEntries(
issueRows.map((issue) => [
issue.id,
{
identifier: issue.identifier ?? issue.id,
title: issue.title,
},
]),
);
const actionRequestIds = [
...new Set(rows.map((row) => row.actionRequestId).filter(Boolean)),
] as string[];
const requestRows = actionRequestIds.length
? await db
.select({
id: toolActionRequests.id,
status: toolActionRequests.status,
resolvedByAgentId: toolActionRequests.resolvedByAgentId,
resolvedByUserId: toolActionRequests.resolvedByUserId,
})
.from(toolActionRequests)
.where(
and(
eq(toolActionRequests.companyId, connection.companyId),
inArray(toolActionRequests.id, actionRequestIds),
),
)
: [];
const resolverAgentIds = [
...new Set(
requestRows.map((row) => row.resolvedByAgentId).filter(Boolean),
),
] as string[];
const resolverUserIds = [
...new Set(
requestRows.map((row) => row.resolvedByUserId).filter(Boolean),
),
] as string[];
const resolverAgents = resolverAgentIds.length
? await db
.select({ id: agents.id, name: agents.name })
.from(agents)
.where(
and(
eq(agents.companyId, connection.companyId),
inArray(agents.id, resolverAgentIds),
),
)
: [];
const resolverUsers = resolverUserIds.length
? await db
.select({
id: authUsers.id,
name: authUsers.name,
email: authUsers.email,
})
.from(authUsers)
.where(inArray(authUsers.id, resolverUserIds))
: [];
const resolverAgentNames = new Map(
resolverAgents.map((agent) => [agent.id, agent.name]),
);
const resolverUserNames = new Map(
resolverUsers.map((user) => [
user.id,
user.name?.trim() || user.email?.trim() || user.id,
]),
);
const actionRequestMap = Object.fromEntries(
requestRows.map((request) => [
request.id,
{
status: request.status,
resolverDisplayName: request.resolvedByAgentId
? (resolverAgentNames.get(request.resolvedByAgentId) ??
request.resolvedByAgentId)
: request.resolvedByUserId
? (resolverUserNames.get(request.resolvedByUserId) ??
userFallbackName(request.resolvedByUserId))
: null,
resolvedByAgentId: request.resolvedByAgentId,
resolvedByUserId: request.resolvedByUserId,
},
]),
);
const lifecycleEvents = await listConnectionLifecycleEvents(db, {
companyId: connection.companyId,
connectionIds: [connection.id],
limit: safeLimit,
});
return {
connectionId: connection.id,
events,
lifecycleEvents,
issues: issueMap,
actionRequests: actionRequestMap,
};
},
getRunDecisionLookup: async (
companyId: string,
runId: string,
): Promise<ToolRunDecisionLookup> => {
const [run] = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.companyId, companyId),
),
)
.limit(1);
if (!run) throw notFound("Run not found");
const invocationRows = await db
.select()
.from(toolInvocations)
.where(
and(
eq(toolInvocations.companyId, companyId),
eq(toolInvocations.runId, runId),
),
)
.orderBy(desc(toolInvocations.createdAt));
const invocationIds = invocationRows.map((row) => row.id);
const [actionRequestRows, auditEventRows] =
invocationIds.length > 0
? await Promise.all([
db
.select()
.from(toolActionRequests)
.where(
and(
eq(toolActionRequests.companyId, companyId),
inArray(toolActionRequests.invocationId, invocationIds),
),
),
db
.select()
.from(toolCallEvents)
.where(
and(
eq(toolCallEvents.companyId, companyId),
eq(toolCallEvents.runId, runId),
inArray(toolCallEvents.invocationId, invocationIds),
),
)
.orderBy(desc(toolCallEvents.createdAt)),
])
: [[], []];
const actionRequestByInvocation = new Map(
actionRequestRows.map((row) => [row.invocationId, row]),
);
const auditEventsByInvocation = new Map<
string,
(typeof toolCallEvents.$inferSelect)[]
>();
for (const event of auditEventRows) {
if (!event.invocationId) continue;
const events = auditEventsByInvocation.get(event.invocationId) ?? [];
events.push(event);
auditEventsByInvocation.set(event.invocationId, events);
}
const decisions: ToolRunDecision[] = invocationRows.map((invocation) => {
const actionRequest =
actionRequestByInvocation.get(invocation.id) ?? null;
const auditEvents = auditEventsByInvocation.get(invocation.id) ?? [];
const latestAuditEvent = auditEvents[0] ?? null;
const apiInvocation = toToolInvocation(invocation);
const apiActionRequest = actionRequest
? toToolActionRequest(actionRequest)
: null;
const apiAuditEvents = auditEvents.map(toToolCallEvent);
const apiLatestAuditEvent = latestAuditEvent
? toToolCallEvent(latestAuditEvent)
: null;
const pendingAction =
actionRequest && actionRequest.status === "pending"
? {
actionRequestId: actionRequest.id,
issueId: actionRequest.issueId,
interactionId: actionRequest.interactionId,
approvalId: actionRequest.approvalId,
status: actionRequest.status,
previewMarkdown: actionRequest.previewMarkdown,
}
: null;
return {
invocation: apiInvocation,
actionRequest: apiActionRequest,
auditEvents: apiAuditEvents,
latestAuditEvent: apiLatestAuditEvent,
decision: latestAuditEvent?.decision ?? invocation.policyDecision,
outcome: latestAuditEvent?.outcome ?? null,
reasonCode: latestAuditEvent?.reasonCode ?? invocation.errorCode,
denialReason: denialReasonForDecision(invocation, latestAuditEvent),
pendingAction,
} satisfies ToolRunDecision;
});
return { runId, decisions };
}
};
}
@@ -0,0 +1,426 @@
import { and, desc, eq, gte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolAccessAuditEvents, toolConnections, toolCallEvents, toolRuntimeMetricCounters, toolRuntimeSlots } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode, ToolRuntimeAlertRecommendation, ToolRuntimeHealthSummary } from "@paperclipai/shared";
import { TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC } from "../../tool-runtime-metrics.js";
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
function runtimeAlert(
input: ToolRuntimeAlertRecommendation,
): ToolRuntimeAlertRecommendation {
return input;
}
function buildRuntimeAlerts(input: {
stuckStartingSlots: number;
stuckRunningSlots: number;
timeoutRate: number;
timeoutCount: number;
failureRate: number;
failureCount: number;
capacityDeferrals: number;
restartAttempts: number;
restartSuppressions: number;
degradedConnections: number;
disabledConnections: number;
missingSecretFailures: number;
auditWriteFailures: number;
}): ToolRuntimeAlertRecommendation[] {
const runbookSection = "doc/MCP-RUNTIME-OPERATIONS.md";
const timeoutSeverity =
input.timeoutCount >= 10 || input.timeoutRate >= 25
? "critical"
: input.timeoutCount >= 3 && input.timeoutRate >= 10
? "warning"
: "warning";
const failureSeverity =
input.failureCount >= 10 || input.failureRate >= 25
? "critical"
: input.failureCount >= 5 && input.failureRate >= 10
? "warning"
: "warning";
const restartSeverity =
input.restartSuppressions > 0 ? "critical" : "warning";
return [
runtimeAlert({
name: "mcp_runtime_stuck_starting_slot",
severity: "critical",
status: input.stuckStartingSlots > 0 ? "firing" : "ok",
threshold: "Any starting slot older than 5 minutes.",
observed: `${input.stuckStartingSlots} stuck starting slot(s).`,
description:
"A local stdio runtime slot is stuck before it reaches running state.",
firstResponderAction:
"Inspect the slot health/logs, stop the slot, restart it once, then disable the connection if the slot sticks again.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_stuck_running_slot",
severity: "critical",
status: input.stuckRunningSlots > 0 ? "firing" : "ok",
threshold: "Any running slot with no progress for 5 minutes.",
observed: `${input.stuckRunningSlots} stuck running slot(s).`,
description:
"A runtime slot is running but has not recorded progress inside the supervisor stuck-slot window.",
firstResponderAction:
"Inspect recent audit events and active tool calls; restart the slot only after confirming no healthy call is still in progress.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_high_timeout_rate",
severity: timeoutSeverity,
status:
input.timeoutCount >= 3 && input.timeoutRate >= 10 ? "firing" : "ok",
threshold:
"Warning at >=3 timeouts and >=10% timeout rate in 1 hour; critical at >=10 timeouts or >=25%.",
observed: `${input.timeoutCount} timeout(s), ${input.timeoutRate}% timeout rate.`,
description:
"Tool gateway calls are timing out or being runtime-deferred at an elevated rate.",
firstResponderAction:
"Check upstream MCP health, Paperclip runtime capacity, and recent gateway audit failures before retrying workloads.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_high_error_rate",
severity: failureSeverity,
status:
input.failureCount >= 5 && input.failureRate >= 10 ? "firing" : "ok",
threshold:
"Warning at >=5 failures and >=10% failure rate in 1 hour; critical at >=10 failures or >=25%.",
observed: `${input.failureCount} failure(s), ${input.failureRate}% failure rate.`,
description:
"Tool gateway calls are failing after policy authorization.",
firstResponderAction:
"Group audit failures by reasonCode, then fix credentials/config or disable the affected connection.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_capacity_deferrals_repeated",
severity: input.capacityDeferrals >= 10 ? "critical" : "warning",
status: input.capacityDeferrals >= 3 ? "firing" : "ok",
threshold:
"Warning at >=3 capacity deferrals in 1 hour; critical at >=10.",
observed: `${input.capacityDeferrals} capacity deferral(s) in 1 hour.`,
description:
"The runtime supervisor is refusing local stdio work because company or host slot capacity is exhausted.",
firstResponderAction:
"Stop idle/stale slots, lower noisy workloads, or raise slot caps only after confirming host capacity.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_restart_storm",
severity: restartSeverity,
status:
input.restartSuppressions > 0 || input.restartAttempts >= 3
? "firing"
: "ok",
threshold:
"Warning at >=3 restarts in 1 hour; critical on any restart suppression.",
observed: `${input.restartAttempts} restart attempt(s), ${input.restartSuppressions} suppression(s).`,
description:
"Runtime slots are restarting repeatedly or have hit restart-storm suppression.",
firstResponderAction:
"Stop the affected slot, inspect stderr/audit reason codes, and keep the connection disabled until the template/upstream is fixed.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_connection_health_degraded",
severity: input.degradedConnections > 0 ? "critical" : "warning",
status:
input.degradedConnections > 0 || input.disabledConnections > 0
? "firing"
: "ok",
threshold:
"Any active enabled connection with degraded/failed/missing-secret health, or any disabled enabled-path connection.",
observed: `${input.degradedConnections} degraded connection(s), ${input.disabledConnections} disabled connection(s).`,
description:
"A configured MCP connection is not healthy or has been disabled.",
firstResponderAction:
"Run a connection health check, refresh catalog after recovery, or keep the connection disabled and route agents to alternatives.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_missing_secret_failures",
severity: input.missingSecretFailures >= 3 ? "critical" : "warning",
status: input.missingSecretFailures > 0 ? "firing" : "ok",
threshold:
"Warning on any missing-secret failure; critical at >=3 in 1 hour.",
observed: `${input.missingSecretFailures} missing-secret failure(s) in 1 hour.`,
description:
"A connection or tool call needed a bound secret that could not be resolved.",
firstResponderAction:
"Check secret bindings and provider health without printing secret values; rotate or rebind missing secrets.",
runbookSection,
}),
runtimeAlert({
name: "mcp_runtime_audit_write_failures",
severity: "critical",
status: input.auditWriteFailures > 0 ? "firing" : "ok",
threshold: "Any audit write failure.",
observed: `${input.auditWriteFailures} audit write failure(s) in 1 hour.`,
description:
"Tool gateway audit writes failed, reducing incident traceability.",
firstResponderAction:
"Treat as a control-plane incident: check database writes, activity log writes, and retry only after audit durability is restored.",
runbookSection,
}),
];
}
function numberValue(value: unknown): number | null {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function percent(numerator: number, denominator: number): number {
if (denominator <= 0) return 0;
return Math.round((numerator / denominator) * 1000) / 10;
}
function percentile(values: number[], p: number): number | null {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const index = Math.min(
sorted.length - 1,
Math.max(0, Math.ceil((p / 100) * sorted.length) - 1),
);
return sorted[index] ?? null;
}
type RuntimeHealthOptions = { deploymentMode?: DeploymentMode; deploymentExposure?: DeploymentExposure };
export function createToolRuntimeHealth(db: Db, options: RuntimeHealthOptions, now: () => Date, trustedRuntimeHost: () => string | null) {
async function runtimeHealth(
companyId: string,
): Promise<ToolRuntimeHealthSummary> {
const generatedAt = now();
const windowStartedAt = new Date(generatedAt.getTime() - 60 * 60 * 1000);
const stuckSlotMs = 5 * 60 * 1000;
const [
slots,
connections,
auditRows,
callEvents,
auditWriteFailureCounterRows,
] = await Promise.all([
db
.select()
.from(toolRuntimeSlots)
.where(eq(toolRuntimeSlots.companyId, companyId)),
db
.select()
.from(toolConnections)
.where(eq(toolConnections.companyId, companyId)),
db
.select()
.from(toolAccessAuditEvents)
.where(
and(
eq(toolAccessAuditEvents.companyId, companyId),
gte(toolAccessAuditEvents.createdAt, windowStartedAt),
),
)
.orderBy(desc(toolAccessAuditEvents.createdAt)),
db
.select()
.from(toolCallEvents)
.where(
and(
eq(toolCallEvents.companyId, companyId),
gte(toolCallEvents.createdAt, windowStartedAt),
),
)
.orderBy(desc(toolCallEvents.createdAt)),
db
.select({
count: sql<number>`coalesce(sum(${toolRuntimeMetricCounters.count}), 0)::int`,
})
.from(toolRuntimeMetricCounters)
.where(
and(
eq(toolRuntimeMetricCounters.companyId, companyId),
eq(
toolRuntimeMetricCounters.metric,
TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC,
),
gte(toolRuntimeMetricCounters.bucketStartAt, windowStartedAt),
),
),
]);
const activeSlots = slots.filter(
(slot) =>
slot.status === "starting" ||
slot.status === "running" ||
slot.status === "idle",
);
const staleActiveSlots = activeSlots.filter((slot) => {
const lastProgressAt =
slot.lastUsedAt ?? slot.startedAt ?? slot.updatedAt;
return generatedAt.getTime() - lastProgressAt.getTime() > stuckSlotMs;
});
const callTerminalEvents = callEvents.filter(
(event) =>
event.eventType === "call_completed" ||
event.eventType === "call_failed" ||
event.eventType === "call_denied",
);
const toolCallsLastHour = callTerminalEvents.length;
const toolTimeoutsLastHour = callTerminalEvents.filter(
(event) => event.outcome === "timeout",
).length;
const toolFailuresLastHour = callTerminalEvents.filter(
(event) => event.outcome === "failure",
).length;
const durations = auditRows
.map((row) => numberValue(asRecord(row.details).durationMs))
.filter((value): value is number => value !== null && value >= 0);
const capacityDeferrals = auditRows.filter(
(row) =>
row.action === "runtime_deferred" ||
row.reasonCode === "runtime_company_capacity_exhausted" ||
row.reasonCode === "runtime_host_capacity_exhausted",
).length;
const restartAttempts = auditRows.filter(
(row) =>
row.action === "runtime_started" && row.reasonCode !== "lazy_start",
).length;
const restartSuppressions = auditRows.filter(
(row) =>
row.action === "runtime_restart_suppressed" ||
row.reasonCode === "runtime_restart_suppressed",
).length;
const idleEvictions = auditRows.filter(
(row) =>
row.action === "runtime_stopped" &&
row.reasonCode === "idle_ttl_expired",
).length;
const missingSecretFailures = auditRows.filter(
(row) =>
row.reasonCode === "missing_secret" ||
(row.outcome === "failure" && row.reasonCode?.includes("secret")),
).length;
const legacyAuditWriteFailures = auditRows.filter(
(row) =>
row.action === "runtime_audit_write_failed" ||
row.reasonCode === "audit_write_failed",
).length;
const auditWriteFailuresMetric =
Number(auditWriteFailureCounterRows[0]?.count ?? 0) +
legacyAuditWriteFailures;
const enabledPathConnections = connections.filter(
(connection) => connection.status === "active" && connection.enabled,
);
const activeConnections = enabledPathConnections.length;
const disabledConnections = connections.filter(
(connection) => connection.status === "disabled",
).length;
const degradedConnections = enabledPathConnections.filter((connection) =>
["degraded", "failed", "error", "missing_secret"].includes(
connection.healthStatus,
),
).length;
const metrics = {
windowStartedAt,
windowEndedAt: generatedAt,
activeSlots: activeSlots.length,
startingSlots: slots.filter((slot) => slot.status === "starting").length,
runningSlots: slots.filter((slot) => slot.status === "running").length,
idleSlots: slots.filter((slot) => slot.status === "idle").length,
failedSlots: slots.filter(
(slot) => slot.status === "failed" || slot.status === "error",
).length,
stoppedSlots: slots.filter(
(slot) => slot.status === "stopped" || slot.status === "disabled",
).length,
stuckStartingSlots: staleActiveSlots.filter(
(slot) => slot.status === "starting",
).length,
stuckRunningSlots: staleActiveSlots.filter(
(slot) => slot.status === "running",
).length,
capacityDeferralsLastHour: capacityDeferrals,
restartAttemptsLastHour: restartAttempts,
restartSuppressionsLastHour: restartSuppressions,
idleEvictionsLastHour: idleEvictions,
toolCallsLastHour,
toolTimeoutsLastHour,
toolFailuresLastHour,
timeoutRateLastHour: percent(toolTimeoutsLastHour, toolCallsLastHour),
failureRateLastHour: percent(toolFailuresLastHour, toolCallsLastHour),
averageToolLatencyMsLastHour:
durations.length > 0
? Math.round(
durations.reduce((sum, value) => sum + value, 0) /
durations.length,
)
: null,
p95ToolLatencyMsLastHour: percentile(durations, 95),
missingSecretFailuresLastHour: missingSecretFailures,
auditWriteFailuresLastHour: auditWriteFailuresMetric,
activeConnections,
disabledConnections,
degradedConnections,
remoteHttpConnections: connections.filter(
(connection) =>
connection.status !== "archived" &&
connection.transport === "mcp_remote",
).length,
localStdioConnections: connections.filter(
(connection) =>
connection.status !== "archived" &&
connection.transport === "local_stdio",
).length,
};
const recommendations = buildRuntimeAlerts({
stuckStartingSlots: metrics.stuckStartingSlots,
stuckRunningSlots: metrics.stuckRunningSlots,
timeoutRate: metrics.timeoutRateLastHour,
timeoutCount: metrics.toolTimeoutsLastHour,
failureRate: metrics.failureRateLastHour,
failureCount: metrics.toolFailuresLastHour,
capacityDeferrals,
restartAttempts,
restartSuppressions,
degradedConnections,
disabledConnections,
missingSecretFailures,
auditWriteFailures: metrics.auditWriteFailuresLastHour,
});
const firing = recommendations.filter((alert) => alert.status === "firing");
const status = firing.some((alert) => alert.severity === "critical")
? "critical"
: firing.length > 0
? "degraded"
: "ok";
const deploymentMode = options.deploymentMode ?? "local_trusted";
const deploymentExposure = options.deploymentExposure ?? "private";
const localStdioSupported =
deploymentMode === "local_trusted" || Boolean(trustedRuntimeHost());
return {
status,
generatedAt,
runbookPath: "doc/MCP-RUNTIME-OPERATIONS.md",
metrics,
supportMatrix: {
remoteHttp: {
supported: true,
note: "mcp_remote MCP connections are supported in hosted cloud and local deployments.",
},
localStdio: {
supported: localStdioSupported,
note: localStdioSupported
? "local_stdio is available for local trusted mode or through the configured trusted MCP runtime host."
: `local_stdio should stay disabled for ${deploymentMode}/${deploymentExposure}; use mcp_remote or configure a trusted runtime worker.`,
},
},
alerts: firing,
recommendations,
};
}
return runtimeHealth;
}
@@ -0,0 +1,182 @@
import type { ActorInfo } from "../types.js";
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolConnections, toolRuntimeSlots } from "@paperclipai/db";
import type { ToolRuntimeSlot } from "@paperclipai/shared";
import { HttpError, notFound } from "../../../errors.js";
import { logActivity } from "../../activity-log.js";
import { createToolRuntimeSupervisor, ToolRuntimeSupervisorError } from "../../tool-runtime-supervisor.js";
function toRuntimeSlot(
row: typeof toolRuntimeSlots.$inferSelect,
): ToolRuntimeSlot {
return {
id: row.id,
companyId: row.companyId,
applicationId: row.applicationId,
connectionId: row.connectionId,
projectWorkspaceId: row.projectWorkspaceId,
executionWorkspaceId: row.executionWorkspaceId,
issueId: row.issueId,
ownerScopeType: row.ownerScopeType,
ownerScopeId: row.ownerScopeId,
runtimeKind: row.runtimeKind,
slotKey: row.slotKey,
status: row.status,
reuseKey: row.reuseKey,
workspaceScope: row.workspaceScope,
credentialScopeHash: row.credentialScopeHash,
provider: row.provider,
providerRef: row.providerRef,
processId: row.processId,
commandTemplateKey: row.commandTemplateKey,
healthStatus: row.healthStatus,
healthMessage: row.healthMessage,
lastHealthCheckAt: row.lastHealthCheckAt,
lastStartedAt: row.lastStartedAt,
startedAt: row.startedAt,
stoppedAt: row.stoppedAt,
lastUsedAt: row.lastUsedAt,
idleExpiresAt: row.idleExpiresAt,
idleDeadlineAt: row.idleDeadlineAt,
lastError: row.lastError,
metadata: row.metadata ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function createToolRuntimeSlots(db: Db, runtimeSupervisor: Pick<ReturnType<typeof createToolRuntimeSupervisor>, "stopSlot" | "restartSlot">) {
async function ensureRuntimeSlot(
connection: typeof toolConnections.$inferSelect,
): Promise<ToolRuntimeSlot | null> {
if (connection.transport !== "local_stdio" || connection.config.templateId === "paperclip.cognee-cloud") return null;
const slotKey = `mcp:${connection.companyId}:${connection.id}`;
const [existing] = await db
.select()
.from(toolRuntimeSlots)
.where(
and(
eq(toolRuntimeSlots.companyId, connection.companyId),
eq(toolRuntimeSlots.slotKey, slotKey),
),
);
if (existing) return toRuntimeSlot(existing);
const [created] = await db
.insert(toolRuntimeSlots)
.values({
companyId: connection.companyId,
applicationId: connection.applicationId,
connectionId: connection.id,
slotKey,
ownerScopeType: "connection",
ownerScopeId: connection.id,
runtimeKind: "local_stdio",
status: "stopped",
provider: "paperclip",
providerRef: `template:${String(connection.config.templateId)}`,
commandTemplateKey: String(connection.config.templateId),
healthStatus: "unchecked",
metadata: { templateId: connection.config.templateId },
})
.returning();
return toRuntimeSlot(created);
}
async function runtimeSlotById(
companyId: string,
slotId: string,
): Promise<ToolRuntimeSlot> {
const [row] = await db
.select()
.from(toolRuntimeSlots)
.where(
and(
eq(toolRuntimeSlots.companyId, companyId),
eq(toolRuntimeSlots.id, slotId),
),
)
.limit(1);
if (!row) throw notFound("Runtime slot not found");
return toRuntimeSlot(row);
}
function runtimeSupervisorHttpError(error: ToolRuntimeSupervisorError) {
return new HttpError(error.status, error.message, {
code: error.reasonCode,
...error.details,
});
}
async function controlRuntimeSlot(input: {
companyId: string;
slotId: string;
action: "stop" | "restart";
actor?: ActorInfo;
}): Promise<ToolRuntimeSlot> {
try {
if (input.action === "stop") {
await runtimeSupervisor.stopSlot({
companyId: input.companyId,
slotId: input.slotId,
reason: "operator_stop",
});
} else {
await runtimeSupervisor.restartSlot({
companyId: input.companyId,
slotId: input.slotId,
});
}
const slot = await runtimeSlotById(input.companyId, input.slotId);
await logActivity(db, {
companyId: input.companyId,
actorType: input.actor?.actorType ?? "system",
actorId: input.actor?.actorId ?? "tool-access-service",
action:
input.action === "stop"
? "tool_runtime_slot.operator_stopped"
: "tool_runtime_slot.operator_restarted",
entityType: "tool_runtime_slot",
entityId: input.slotId,
details: {
runtimeKind: slot.runtimeKind,
status: slot.status,
slotKey: slot.slotKey,
},
});
return slot;
} catch (error) {
if (error instanceof ToolRuntimeSupervisorError) {
throw runtimeSupervisorHttpError(error);
}
throw error;
}
}
return {
ensureRuntimeSlot,
api: {
listRuntimeSlots: async (companyId: string): Promise<ToolRuntimeSlot[]> => {
const rows = await db
.select()
.from(toolRuntimeSlots)
.where(eq(toolRuntimeSlots.companyId, companyId))
.orderBy(desc(toolRuntimeSlots.updatedAt));
return rows.map(toRuntimeSlot);
},
stopRuntimeSlot: (
companyId: string,
slotId: string,
actor?: ActorInfo,
): Promise<ToolRuntimeSlot> =>
controlRuntimeSlot({ companyId, slotId, action: "stop", actor }),
restartRuntimeSlot: (
companyId: string,
slotId: string,
actor?: ActorInfo,
): Promise<ToolRuntimeSlot> =>
controlRuntimeSlot({ companyId, slotId, action: "restart", actor })
},
};
}