merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,380 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
principalPermissionGrants,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
|
||||
type MembershipRow = typeof companyMemberships.$inferSelect;
|
||||
type GrantInput = {
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function accessService(db: Db) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const row = await db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
async function getMembership(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<MembershipRow | null> {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, principalType),
|
||||
eq(companyMemberships.principalId, principalId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function hasPermission(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
permissionKey: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
const membership = await getMembership(companyId, principalType, principalId);
|
||||
if (!membership || membership.status !== "active") return false;
|
||||
const grant = await db
|
||||
.select({ id: principalPermissionGrants.id })
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
eq(principalPermissionGrants.permissionKey, permissionKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(grant);
|
||||
}
|
||||
|
||||
async function canUser(
|
||||
companyId: string,
|
||||
userId: string | null | undefined,
|
||||
permissionKey: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
if (await isInstanceAdmin(userId)) return true;
|
||||
return hasPermission(companyId, "user", userId, permissionKey);
|
||||
}
|
||||
|
||||
async function listMembers(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.companyId, companyId))
|
||||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
||||
}
|
||||
|
||||
async function listActiveUserMemberships(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${companyMemberships.createdAt} asc`);
|
||||
}
|
||||
|
||||
async function setMemberPermissions(
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
grants: GrantInput[],
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
const member = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!member) return null;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, member.principalType),
|
||||
eq(principalPermissionGrants.principalId, member.principalId),
|
||||
),
|
||||
);
|
||||
if (grants.length > 0) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
grants.map((grant) => ({
|
||||
companyId,
|
||||
principalType: member.principalType,
|
||||
principalId: member.principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
async function promoteInstanceAdmin(userId: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
return db
|
||||
.insert(instanceUserRoles)
|
||||
.values({
|
||||
userId,
|
||||
role: "instance_admin",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function demoteInstanceAdmin(userId: string) {
|
||||
return db
|
||||
.delete(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function listUserCompanyAccess(userId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId)))
|
||||
.orderBy(sql`${companyMemberships.createdAt} desc`);
|
||||
}
|
||||
|
||||
async function setUserCompanyAccess(userId: string, companyIds: string[]) {
|
||||
const existing = await listUserCompanyAccess(userId);
|
||||
const existingByCompany = new Map(existing.map((row) => [row.companyId, row]));
|
||||
const target = new Set(companyIds);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id);
|
||||
if (toDelete.length > 0) {
|
||||
await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete));
|
||||
}
|
||||
|
||||
for (const companyId of target) {
|
||||
if (existingByCompany.has(companyId)) continue;
|
||||
await tx.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return listUserCompanyAccess(userId);
|
||||
}
|
||||
|
||||
async function ensureMembership(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
membershipRole: string | null = "member",
|
||||
status: "pending" | "active" | "suspended" = "active",
|
||||
) {
|
||||
const existing = await getMembership(companyId, principalType, principalId);
|
||||
if (existing) {
|
||||
if (existing.status !== status || existing.membershipRole !== membershipRole) {
|
||||
const updated = await db
|
||||
.update(companyMemberships)
|
||||
.set({ status, membershipRole, updatedAt: new Date() })
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return updated ?? existing;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(companyMemberships)
|
||||
.values({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
status,
|
||||
membershipRole,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function setPrincipalGrants(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
grants: GrantInput[],
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
),
|
||||
);
|
||||
if (grants.length === 0) return;
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
grants.map((grant) => ({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) {
|
||||
const sourceMemberships = await listActiveUserMemberships(sourceCompanyId);
|
||||
for (const membership of sourceMemberships) {
|
||||
await ensureMembership(
|
||||
targetCompanyId,
|
||||
"user",
|
||||
membership.principalId,
|
||||
membership.membershipRole,
|
||||
"active",
|
||||
);
|
||||
}
|
||||
return sourceMemberships;
|
||||
}
|
||||
|
||||
async function listPrincipalGrants(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
),
|
||||
)
|
||||
.orderBy(principalPermissionGrants.permissionKey);
|
||||
}
|
||||
|
||||
async function setPrincipalPermission(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
permissionKey: PermissionKey,
|
||||
enabled: boolean,
|
||||
grantedByUserId: string | null,
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
if (!enabled) {
|
||||
await db
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
eq(principalPermissionGrants.permissionKey, permissionKey),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureMembership(companyId, principalType, principalId, "member", "active");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
eq(principalPermissionGrants.permissionKey, permissionKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(principalPermissionGrants)
|
||||
.set({
|
||||
scope,
|
||||
grantedByUserId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(principalPermissionGrants.id, existing.id));
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
permissionKey,
|
||||
scope,
|
||||
grantedByUserId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isInstanceAdmin,
|
||||
canUser,
|
||||
hasPermission,
|
||||
getMembership,
|
||||
ensureMembership,
|
||||
listMembers,
|
||||
listActiveUserMemberships,
|
||||
copyActiveUserMemberships,
|
||||
setMemberPermissions,
|
||||
promoteInstanceAdmin,
|
||||
demoteInstanceAdmin,
|
||||
listUserCompanyAccess,
|
||||
setUserCompanyAccess,
|
||||
setPrincipalGrants,
|
||||
listPrincipalGrants,
|
||||
setPrincipalPermission,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog } from "@paperclipai/db";
|
||||
import { PLUGIN_EVENT_TYPES, type PluginEventType } from "@paperclipai/shared";
|
||||
import type { PluginEvent } from "@paperclipai/plugin-sdk";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { PluginEventBus } from "./plugin-event-bus.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
|
||||
|
||||
let _pluginEventBus: PluginEventBus | null = null;
|
||||
|
||||
/** Wire the plugin event bus so domain events are forwarded to plugins. */
|
||||
export function setPluginEventBus(bus: PluginEventBus): void {
|
||||
if (_pluginEventBus) {
|
||||
logger.warn("setPluginEventBus called more than once, replacing existing bus");
|
||||
}
|
||||
_pluginEventBus = bus;
|
||||
}
|
||||
|
||||
export interface LogActivityInput {
|
||||
companyId: string;
|
||||
actorType: "agent" | "user" | "system";
|
||||
actorId: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId?: string | null;
|
||||
runId?: string | null;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
||||
const redactedDetails = sanitizedDetails
|
||||
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
|
||||
: null;
|
||||
await db.insert(activityLog).values({
|
||||
companyId: input.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
details: redactedDetails,
|
||||
});
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: input.companyId,
|
||||
type: "activity.logged",
|
||||
payload: {
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
details: redactedDetails,
|
||||
},
|
||||
});
|
||||
|
||||
if (_pluginEventBus && PLUGIN_EVENT_SET.has(input.action)) {
|
||||
const event: PluginEvent = {
|
||||
eventId: randomUUID(),
|
||||
eventType: input.action as PluginEventType,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
companyId: input.companyId,
|
||||
payload: {
|
||||
...redactedDetails,
|
||||
agentId: input.agentId ?? null,
|
||||
runId: input.runId ?? null,
|
||||
},
|
||||
};
|
||||
void _pluginEventBus.emit(event).then(({ errors }) => {
|
||||
for (const { pluginId, error } of errors) {
|
||||
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
|
||||
export interface ActivityFilters {
|
||||
companyId: string;
|
||||
agentId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
export function activityService(db: Db) {
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
return {
|
||||
list: (filters: ActivityFilters) => {
|
||||
const conditions = [eq(activityLog.companyId, filters.companyId)];
|
||||
|
||||
if (filters.agentId) {
|
||||
conditions.push(eq(activityLog.agentId, filters.agentId));
|
||||
}
|
||||
if (filters.entityType) {
|
||||
conditions.push(eq(activityLog.entityType, filters.entityType));
|
||||
}
|
||||
if (filters.entityId) {
|
||||
conditions.push(eq(activityLog.entityId, filters.entityId));
|
||||
}
|
||||
|
||||
return db
|
||||
.select({ activityLog })
|
||||
.from(activityLog)
|
||||
.leftJoin(
|
||||
issues,
|
||||
and(
|
||||
eq(activityLog.entityType, sql`'issue'`),
|
||||
eq(activityLog.entityId, issueIdAsText),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...conditions,
|
||||
or(
|
||||
sql`${activityLog.entityType} != 'issue'`,
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.then((rows) => rows.map((r) => r.activityLog));
|
||||
},
|
||||
|
||||
forIssue: (issueId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, issueId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt)),
|
||||
|
||||
runsForIssue: (companyId: string, issueId: string) =>
|
||||
db
|
||||
.select({
|
||||
runId: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
usageJson: heartbeatRuns.usageJson,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
or(
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
||||
sql`exists (
|
||||
select 1
|
||||
from ${activityLog}
|
||||
where ${activityLog.companyId} = ${companyId}
|
||||
and ${activityLog.entityType} = 'issue'
|
||||
and ${activityLog.entityId} = ${issueId}
|
||||
and ${activityLog.runId} = ${heartbeatRuns.id}
|
||||
)`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt)),
|
||||
|
||||
issuesForRun: async (runId: string) => {
|
||||
const run = await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!run) return [];
|
||||
|
||||
const fromActivity = await db
|
||||
.selectDistinctOn([issueIdAsText], {
|
||||
issueId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
})
|
||||
.from(activityLog)
|
||||
.innerJoin(issues, eq(activityLog.entityId, issueIdAsText))
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, run.companyId),
|
||||
eq(activityLog.runId, runId),
|
||||
eq(activityLog.entityType, "issue"),
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
)
|
||||
.orderBy(issueIdAsText);
|
||||
|
||||
const context = run.contextSnapshot;
|
||||
const contextIssueId =
|
||||
context && typeof context === "object" && typeof (context as Record<string, unknown>).issueId === "string"
|
||||
? ((context as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
if (!contextIssueId) return fromActivity;
|
||||
if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity;
|
||||
|
||||
const fromContext = await db
|
||||
.select({
|
||||
issueId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, run.companyId),
|
||||
eq(issues.id, contextIssueId),
|
||||
isNull(issues.hiddenAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!fromContext) return fromActivity;
|
||||
return [fromContext, ...fromActivity];
|
||||
},
|
||||
|
||||
create: (data: typeof activityLog.$inferInsert) =>
|
||||
db
|
||||
.insert(activityLog)
|
||||
.values(data)
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
const ENTRY_FILE_DEFAULT = "AGENTS.md";
|
||||
const MODE_KEY = "instructionsBundleMode";
|
||||
const ROOT_KEY = "instructionsRootPath";
|
||||
const ENTRY_KEY = "instructionsEntryFile";
|
||||
const FILE_KEY = "instructionsFilePath";
|
||||
const PROMPT_KEY = "promptTemplate";
|
||||
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
|
||||
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
|
||||
const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]);
|
||||
const IGNORED_INSTRUCTIONS_DIRECTORY_NAMES = new Set([
|
||||
".git",
|
||||
".nox",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
"venv",
|
||||
]);
|
||||
|
||||
type BundleMode = "managed" | "external";
|
||||
|
||||
type AgentLike = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
adapterConfig: unknown;
|
||||
};
|
||||
|
||||
type AgentInstructionsFileSummary = {
|
||||
path: string;
|
||||
size: number;
|
||||
language: string;
|
||||
markdown: boolean;
|
||||
isEntryFile: boolean;
|
||||
editable: boolean;
|
||||
deprecated: boolean;
|
||||
virtual: boolean;
|
||||
};
|
||||
|
||||
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
|
||||
content: string;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
type AgentInstructionsBundle = {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
mode: BundleMode | null;
|
||||
rootPath: string | null;
|
||||
managedRootPath: string;
|
||||
entryFile: string;
|
||||
resolvedEntryPath: string | null;
|
||||
editable: boolean;
|
||||
warnings: string[];
|
||||
legacyPromptTemplateActive: boolean;
|
||||
legacyBootstrapPromptTemplateActive: boolean;
|
||||
files: AgentInstructionsFileSummary[];
|
||||
};
|
||||
|
||||
type BundleState = {
|
||||
config: Record<string, unknown>;
|
||||
mode: BundleMode | null;
|
||||
rootPath: string | null;
|
||||
entryFile: string;
|
||||
resolvedEntryPath: string | null;
|
||||
warnings: string[];
|
||||
legacyPromptTemplateActive: boolean;
|
||||
legacyBootstrapPromptTemplateActive: boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function isBundleMode(value: unknown): value is BundleMode {
|
||||
return value === "managed" || value === "external";
|
||||
}
|
||||
|
||||
function inferLanguage(relativePath: string): string {
|
||||
const lower = relativePath.toLowerCase();
|
||||
if (lower.endsWith(".md")) return "markdown";
|
||||
if (lower.endsWith(".json")) return "json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
|
||||
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
|
||||
return "javascript";
|
||||
}
|
||||
if (lower.endsWith(".sh")) return "bash";
|
||||
if (lower.endsWith(".py")) return "python";
|
||||
if (lower.endsWith(".toml")) return "toml";
|
||||
if (lower.endsWith(".txt")) return "text";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function isMarkdown(relativePath: string) {
|
||||
return relativePath.toLowerCase().endsWith(".md");
|
||||
}
|
||||
|
||||
function normalizeRelativeFilePath(candidatePath: string): string {
|
||||
const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, "");
|
||||
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
||||
throw unprocessable("Instructions file path must stay within the bundle root");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
|
||||
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
|
||||
const absoluteRoot = path.resolve(rootPath);
|
||||
const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath);
|
||||
const relativeToRoot = path.relative(absoluteRoot, absolutePath);
|
||||
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) {
|
||||
throw unprocessable("Instructions file path must stay within the bundle root");
|
||||
}
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function resolveManagedInstructionsRoot(agent: AgentLike): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
"companies",
|
||||
agent.companyId,
|
||||
"agents",
|
||||
agent.id,
|
||||
"instructions",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLegacyInstructionsPath(candidatePath: string, config: Record<string, unknown>): string {
|
||||
if (path.isAbsolute(candidatePath)) return candidatePath;
|
||||
const cwd = asString(config.cwd);
|
||||
if (!cwd || !path.isAbsolute(cwd)) {
|
||||
throw unprocessable(
|
||||
"Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path",
|
||||
);
|
||||
}
|
||||
return path.resolve(cwd, candidatePath);
|
||||
}
|
||||
|
||||
async function statIfExists(targetPath: string) {
|
||||
return fs.stat(targetPath).catch(() => null);
|
||||
}
|
||||
|
||||
function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) {
|
||||
if (entry.name === "." || entry.name === "..") return true;
|
||||
if (entry.isDirectory()) {
|
||||
return IGNORED_INSTRUCTIONS_DIRECTORY_NAMES.has(entry.name);
|
||||
}
|
||||
if (!entry.isFile()) return false;
|
||||
return (
|
||||
IGNORED_INSTRUCTIONS_FILE_NAMES.has(entry.name)
|
||||
|| entry.name.startsWith("._")
|
||||
|| entry.name.endsWith(".pyc")
|
||||
|| entry.name.endsWith(".pyo")
|
||||
);
|
||||
}
|
||||
|
||||
async function listFilesRecursive(rootPath: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
|
||||
async function walk(currentPath: string, relativeDir: string) {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (shouldIgnoreInstructionsEntry(entry)) continue;
|
||||
const absolutePath = path.join(currentPath, entry.name);
|
||||
const relativePath = normalizeRelativeFilePath(
|
||||
relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name,
|
||||
);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolutePath, relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
output.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
return output.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
|
||||
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
|
||||
const stat = await fs.stat(absolutePath);
|
||||
return {
|
||||
path: relativePath,
|
||||
size: stat.size,
|
||||
language: inferLanguage(relativePath),
|
||||
markdown: isMarkdown(relativePath),
|
||||
isEntryFile: relativePath === entryFile,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyInstructions(agent: AgentLike, config: Record<string, unknown>): Promise<string> {
|
||||
const instructionsFilePath = asString(config[FILE_KEY]);
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config);
|
||||
return await fs.readFile(resolvedPath, "utf8");
|
||||
} catch {
|
||||
// Fall back to promptTemplate below.
|
||||
}
|
||||
}
|
||||
return asString(config[PROMPT_KEY]) ?? "";
|
||||
}
|
||||
|
||||
function deriveBundleState(agent: AgentLike): BundleState {
|
||||
const config = asRecord(agent.adapterConfig);
|
||||
const warnings: string[] = [];
|
||||
const storedModeRaw = config[MODE_KEY];
|
||||
const storedRootRaw = asString(config[ROOT_KEY]);
|
||||
const legacyInstructionsPath = asString(config[FILE_KEY]);
|
||||
|
||||
let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null;
|
||||
let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null;
|
||||
let entryFile = ENTRY_FILE_DEFAULT;
|
||||
|
||||
const storedEntryRaw = asString(config[ENTRY_KEY]);
|
||||
if (storedEntryRaw) {
|
||||
try {
|
||||
entryFile = normalizeRelativeFilePath(storedEntryRaw);
|
||||
} catch {
|
||||
warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rootPath && legacyInstructionsPath) {
|
||||
try {
|
||||
const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config);
|
||||
rootPath = path.dirname(resolvedLegacyPath);
|
||||
entryFile = path.basename(resolvedLegacyPath);
|
||||
mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|
||||
|| resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
|
||||
? "managed"
|
||||
: "external";
|
||||
if (!path.isAbsolute(legacyInstructionsPath)) {
|
||||
warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle.");
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null;
|
||||
|
||||
return {
|
||||
config,
|
||||
mode,
|
||||
rootPath,
|
||||
entryFile,
|
||||
resolvedEntryPath,
|
||||
warnings,
|
||||
legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])),
|
||||
legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])),
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise<BundleState> {
|
||||
const managedRootPath = resolveManagedInstructionsRoot(agent);
|
||||
const stat = await statIfExists(managedRootPath);
|
||||
if (!stat?.isDirectory()) return state;
|
||||
|
||||
const files = await listFilesRecursive(managedRootPath);
|
||||
if (files.length === 0) return state;
|
||||
|
||||
const recoveredEntryFile = files.includes(state.entryFile)
|
||||
? state.entryFile
|
||||
: files.includes(ENTRY_FILE_DEFAULT)
|
||||
? ENTRY_FILE_DEFAULT
|
||||
: files[0]!;
|
||||
|
||||
if (!state.rootPath) {
|
||||
return {
|
||||
...state,
|
||||
mode: "managed",
|
||||
rootPath: managedRootPath,
|
||||
entryFile: recoveredEntryFile,
|
||||
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.mode === "external") return state;
|
||||
|
||||
const resolvedConfiguredRoot = path.resolve(state.rootPath);
|
||||
const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath;
|
||||
const hasEntryMismatch = recoveredEntryFile !== state.entryFile;
|
||||
|
||||
if (configuredRootMatchesManaged && !hasEntryMismatch) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const warnings = [...state.warnings];
|
||||
if (!configuredRootMatchesManaged) {
|
||||
warnings.push(
|
||||
`Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`,
|
||||
);
|
||||
}
|
||||
if (hasEntryMismatch) {
|
||||
warnings.push(
|
||||
`Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
mode: "managed",
|
||||
rootPath: managedRootPath,
|
||||
entryFile: recoveredEntryFile,
|
||||
resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
|
||||
const nextFiles = [...files];
|
||||
if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) {
|
||||
const legacyPromptTemplate = asString(state.config[PROMPT_KEY]) ?? "";
|
||||
nextFiles.push({
|
||||
path: LEGACY_PROMPT_TEMPLATE_PATH,
|
||||
size: legacyPromptTemplate.length,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: false,
|
||||
editable: true,
|
||||
deprecated: true,
|
||||
virtual: true,
|
||||
});
|
||||
}
|
||||
nextFiles.sort((left, right) => left.path.localeCompare(right.path));
|
||||
return {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
mode: state.mode,
|
||||
rootPath: state.rootPath,
|
||||
managedRootPath: resolveManagedInstructionsRoot(agent),
|
||||
entryFile: state.entryFile,
|
||||
resolvedEntryPath: state.resolvedEntryPath,
|
||||
editable: Boolean(state.rootPath),
|
||||
warnings: state.warnings,
|
||||
legacyPromptTemplateActive: state.legacyPromptTemplateActive,
|
||||
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
|
||||
files: nextFiles,
|
||||
};
|
||||
}
|
||||
|
||||
function applyBundleConfig(
|
||||
config: Record<string, unknown>,
|
||||
input: {
|
||||
mode: BundleMode;
|
||||
rootPath: string;
|
||||
entryFile: string;
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {
|
||||
...config,
|
||||
[MODE_KEY]: input.mode,
|
||||
[ROOT_KEY]: input.rootPath,
|
||||
[ENTRY_KEY]: input.entryFile,
|
||||
[FILE_KEY]: path.resolve(input.rootPath, input.entryFile),
|
||||
};
|
||||
if (input.clearLegacyPromptTemplate) {
|
||||
delete next[PROMPT_KEY];
|
||||
delete next[BOOTSTRAP_PROMPT_KEY];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildPersistedBundleConfig(
|
||||
derived: BundleState,
|
||||
current: BundleState,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null;
|
||||
const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null;
|
||||
const configMatchesRecoveredState =
|
||||
derived.mode === current.mode
|
||||
&& derivedRootPath !== null
|
||||
&& currentRootPath !== null
|
||||
&& derivedRootPath === currentRootPath
|
||||
&& derived.entryFile === current.entryFile;
|
||||
|
||||
if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) {
|
||||
return current.config;
|
||||
}
|
||||
|
||||
if (!current.rootPath || !current.mode) {
|
||||
return current.config;
|
||||
}
|
||||
|
||||
return applyBundleConfig(current.config, {
|
||||
mode: current.mode,
|
||||
rootPath: current.rootPath,
|
||||
entryFile: current.entryFile,
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeBundleFiles(
|
||||
rootPath: string,
|
||||
files: Record<string, string>,
|
||||
options?: { overwriteExisting?: boolean },
|
||||
) {
|
||||
for (const [relativePath, content] of Object.entries(files)) {
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
const absolutePath = resolvePathWithinRoot(rootPath, normalizedPath);
|
||||
const existingStat = await statIfExists(absolutePath);
|
||||
if (existingStat?.isFile() && !options?.overwriteExisting) continue;
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export function syncInstructionsBundleConfigFromFilePath(
|
||||
agent: AgentLike,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const instructionsFilePath = asString(adapterConfig[FILE_KEY]);
|
||||
const next = { ...adapterConfig };
|
||||
if (!instructionsFilePath) {
|
||||
delete next[MODE_KEY];
|
||||
delete next[ROOT_KEY];
|
||||
delete next[ENTRY_KEY];
|
||||
return next;
|
||||
}
|
||||
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig);
|
||||
const rootPath = path.dirname(resolvedPath);
|
||||
const entryFile = path.basename(resolvedPath);
|
||||
const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|
||||
|| resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
|
||||
? "managed"
|
||||
: "external";
|
||||
return applyBundleConfig(next, { mode, rootPath, entryFile });
|
||||
}
|
||||
|
||||
export function agentInstructionsService() {
|
||||
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (!state.rootPath) return toBundle(agent, state, []);
|
||||
const stat = await statIfExists(state.rootPath);
|
||||
if (!stat?.isDirectory()) {
|
||||
return toBundle(agent, {
|
||||
...state,
|
||||
warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`],
|
||||
}, []);
|
||||
}
|
||||
const files = await listFilesRecursive(state.rootPath);
|
||||
const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile)));
|
||||
return toBundle(agent, state, summaries);
|
||||
}
|
||||
|
||||
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||
const content = asString(state.config[PROMPT_KEY]);
|
||||
if (content === null) throw notFound("Instructions file not found");
|
||||
return {
|
||||
path: LEGACY_PROMPT_TEMPLATE_PATH,
|
||||
size: content.length,
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
isEntryFile: false,
|
||||
editable: true,
|
||||
deprecated: true,
|
||||
virtual: true,
|
||||
content,
|
||||
};
|
||||
}
|
||||
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
|
||||
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
|
||||
const [content, stat] = await Promise.all([
|
||||
fs.readFile(absolutePath, "utf8").catch(() => null),
|
||||
fs.stat(absolutePath).catch(() => null),
|
||||
]);
|
||||
if (content === null || !stat?.isFile()) throw notFound("Instructions file not found");
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
size: stat.size,
|
||||
language: inferLanguage(normalizedPath),
|
||||
markdown: isMarkdown(normalizedPath),
|
||||
isEntryFile: normalizedPath === state.entryFile,
|
||||
editable: true,
|
||||
deprecated: false,
|
||||
virtual: false,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureWritableBundle(
|
||||
agent: AgentLike,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
|
||||
const derived = deriveBundleState(agent);
|
||||
const current = await recoverManagedBundleState(agent, derived);
|
||||
if (current.rootPath && current.mode) {
|
||||
const adapterConfig = buildPersistedBundleConfig(derived, current, options);
|
||||
return {
|
||||
adapterConfig,
|
||||
state: deriveBundleState({ ...agent, adapterConfig }),
|
||||
};
|
||||
}
|
||||
|
||||
const managedRoot = resolveManagedInstructionsRoot(agent);
|
||||
const entryFile = current.entryFile || ENTRY_FILE_DEFAULT;
|
||||
const nextConfig = applyBundleConfig(current.config, {
|
||||
mode: "managed",
|
||||
rootPath: managedRoot,
|
||||
entryFile,
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
await fs.mkdir(managedRoot, { recursive: true });
|
||||
|
||||
const entryPath = resolvePathWithinRoot(managedRoot, entryFile);
|
||||
const entryStat = await statIfExists(entryPath);
|
||||
if (!entryStat?.isFile()) {
|
||||
const legacyInstructions = await readLegacyInstructions(agent, current.config);
|
||||
if (legacyInstructions.trim().length > 0) {
|
||||
await fs.mkdir(path.dirname(entryPath), { recursive: true });
|
||||
await fs.writeFile(entryPath, legacyInstructions, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterConfig: nextConfig,
|
||||
state: deriveBundleState({ ...agent, adapterConfig: nextConfig }),
|
||||
};
|
||||
}
|
||||
|
||||
async function updateBundle(
|
||||
agent: AgentLike,
|
||||
input: {
|
||||
mode?: BundleMode;
|
||||
rootPath?: string | null;
|
||||
entryFile?: string;
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
const nextMode = input.mode ?? state.mode ?? "managed";
|
||||
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
|
||||
let nextRootPath: string;
|
||||
|
||||
if (nextMode === "managed") {
|
||||
nextRootPath = resolveManagedInstructionsRoot(agent);
|
||||
} else {
|
||||
const rootPath = asString(input.rootPath) ?? state.rootPath;
|
||||
if (!rootPath) {
|
||||
throw unprocessable("External instructions bundles require an absolute rootPath");
|
||||
}
|
||||
const resolvedRoot = resolveHomeAwarePath(rootPath);
|
||||
if (!path.isAbsolute(resolvedRoot)) {
|
||||
throw unprocessable("External instructions bundles require an absolute rootPath");
|
||||
}
|
||||
nextRootPath = resolvedRoot;
|
||||
}
|
||||
|
||||
await fs.mkdir(nextRootPath, { recursive: true });
|
||||
|
||||
const existingFiles = await listFilesRecursive(nextRootPath);
|
||||
const exported = await exportFiles(agent);
|
||||
if (existingFiles.length === 0) {
|
||||
await writeBundleFiles(nextRootPath, exported.files);
|
||||
}
|
||||
const refreshedFiles = existingFiles.length === 0 ? await listFilesRecursive(nextRootPath) : existingFiles;
|
||||
if (!refreshedFiles.includes(nextEntryFile)) {
|
||||
const nextEntryContent = exported.files[nextEntryFile] ?? exported.files[exported.entryFile] ?? "";
|
||||
await writeBundleFiles(nextRootPath, { [nextEntryFile]: nextEntryContent });
|
||||
}
|
||||
|
||||
const nextConfig = applyBundleConfig(state.config, {
|
||||
mode: nextMode,
|
||||
rootPath: nextRootPath,
|
||||
entryFile: nextEntryFile,
|
||||
clearLegacyPromptTemplate: input.clearLegacyPromptTemplate,
|
||||
});
|
||||
const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig });
|
||||
return { bundle: nextBundle, adapterConfig: nextConfig };
|
||||
}
|
||||
|
||||
async function writeFile(
|
||||
agent: AgentLike,
|
||||
relativePath: string,
|
||||
content: string,
|
||||
options?: { clearLegacyPromptTemplate?: boolean },
|
||||
): Promise<{
|
||||
bundle: AgentInstructionsBundle;
|
||||
file: AgentInstructionsFileDetail;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
}> {
|
||||
const current = deriveBundleState(agent);
|
||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||
const adapterConfig: Record<string, unknown> = {
|
||||
...current.config,
|
||||
[PROMPT_KEY]: content,
|
||||
};
|
||||
const nextAgent = { ...agent, adapterConfig };
|
||||
const [bundle, file] = await Promise.all([
|
||||
getBundle(nextAgent),
|
||||
readFile(nextAgent, LEGACY_PROMPT_TEMPLATE_PATH),
|
||||
]);
|
||||
return { bundle, file, adapterConfig };
|
||||
}
|
||||
|
||||
const prepared = await ensureWritableBundle(agent, options);
|
||||
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig };
|
||||
const [bundle, file] = await Promise.all([
|
||||
getBundle(nextAgent),
|
||||
readFile(nextAgent, relativePath),
|
||||
]);
|
||||
return { bundle, file, adapterConfig: prepared.adapterConfig };
|
||||
}
|
||||
|
||||
async function deleteFile(agent: AgentLike, relativePath: string): Promise<{
|
||||
bundle: AgentInstructionsBundle;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
}> {
|
||||
const derived = deriveBundleState(agent);
|
||||
const state = await recoverManagedBundleState(agent, derived);
|
||||
if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) {
|
||||
throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file");
|
||||
}
|
||||
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
|
||||
const normalizedPath = normalizeRelativeFilePath(relativePath);
|
||||
if (normalizedPath === state.entryFile) {
|
||||
throw unprocessable("Cannot delete the bundle entry file");
|
||||
}
|
||||
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
const adapterConfig = buildPersistedBundleConfig(derived, state);
|
||||
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||
return { bundle, adapterConfig };
|
||||
}
|
||||
|
||||
async function exportFiles(agent: AgentLike): Promise<{
|
||||
files: Record<string, string>;
|
||||
entryFile: string;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
|
||||
if (state.rootPath) {
|
||||
const stat = await statIfExists(state.rootPath);
|
||||
if (stat?.isDirectory()) {
|
||||
const relativePaths = await listFilesRecursive(state.rootPath);
|
||||
const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => {
|
||||
const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath);
|
||||
const content = await fs.readFile(absolutePath, "utf8");
|
||||
return [relativePath, content] as const;
|
||||
})));
|
||||
if (Object.keys(files).length > 0) {
|
||||
return { files, entryFile: state.entryFile, warnings: state.warnings };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyBody = await readLegacyInstructions(agent, state.config);
|
||||
return {
|
||||
files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" },
|
||||
entryFile: state.entryFile,
|
||||
warnings: state.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeManagedBundle(
|
||||
agent: AgentLike,
|
||||
files: Record<string, string>,
|
||||
options?: {
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
replaceExisting?: boolean;
|
||||
entryFile?: string;
|
||||
},
|
||||
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
|
||||
const rootPath = resolveManagedInstructionsRoot(agent);
|
||||
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
|
||||
|
||||
if (options?.replaceExisting) {
|
||||
await fs.rm(rootPath, { recursive: true, force: true });
|
||||
}
|
||||
await fs.mkdir(rootPath, { recursive: true });
|
||||
|
||||
const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [
|
||||
normalizeRelativeFilePath(relativePath),
|
||||
content,
|
||||
] as const);
|
||||
for (const [relativePath, content] of normalizedEntries) {
|
||||
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, "utf8");
|
||||
}
|
||||
if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) {
|
||||
await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8");
|
||||
}
|
||||
|
||||
const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), {
|
||||
mode: "managed",
|
||||
rootPath,
|
||||
entryFile,
|
||||
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
|
||||
});
|
||||
const bundle = await getBundle({ ...agent, adapterConfig });
|
||||
return { bundle, adapterConfig };
|
||||
}
|
||||
|
||||
return {
|
||||
getBundle,
|
||||
readFile,
|
||||
updateBundle,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
exportFiles,
|
||||
ensureManagedBundle: ensureWritableBundle,
|
||||
materializeManagedBundle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type NormalizedAgentPermissions = Record<string, unknown> & {
|
||||
canCreateAgents: boolean;
|
||||
};
|
||||
|
||||
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
|
||||
return {
|
||||
canCreateAgents: role === "ceo",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentPermissions(
|
||||
permissions: unknown,
|
||||
role: string,
|
||||
): NormalizedAgentPermissions {
|
||||
const defaults = defaultPermissionsForRole(role);
|
||||
if (typeof permissions !== "object" || permissions === null || Array.isArray(permissions)) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
const record = permissions as Record<string, unknown>;
|
||||
return {
|
||||
canCreateAgents:
|
||||
typeof record.canCreateAgents === "boolean"
|
||||
? record.canCreateAgents
|
||||
: defaults.canCreateAgents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
agentConfigRevisions,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
costEvents,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
} from "@paperclipai/db";
|
||||
import { isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
return `pcp_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
const CONFIG_REVISION_FIELDS = [
|
||||
"name",
|
||||
"role",
|
||||
"title",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
"adapterConfig",
|
||||
"runtimeConfig",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
|
||||
type ConfigRevisionField = (typeof CONFIG_REVISION_FIELDS)[number];
|
||||
type AgentConfigSnapshot = Pick<typeof agents.$inferSelect, ConfigRevisionField>;
|
||||
|
||||
interface RevisionMetadata {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
source?: string;
|
||||
rolledBackFromRevisionId?: string | null;
|
||||
}
|
||||
|
||||
interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentShortnameCollisionOptions {
|
||||
excludeAgentId?: string | null;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function buildConfigSnapshot(
|
||||
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
|
||||
): AgentConfigSnapshot {
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? sanitizeRecord(row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const runtimeConfig =
|
||||
typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig)
|
||||
? sanitizeRecord(row.runtimeConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const metadata =
|
||||
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
|
||||
? sanitizeRecord(row.metadata as Record<string, unknown>)
|
||||
: row.metadata ?? null;
|
||||
return {
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function containsRedactedMarker(value: unknown): boolean {
|
||||
if (value === REDACTED_EVENT_VALUE) return true;
|
||||
if (Array.isArray(value)) return value.some((item) => containsRedactedMarker(item));
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
return Object.values(value as Record<string, unknown>).some((entry) => containsRedactedMarker(entry));
|
||||
}
|
||||
|
||||
function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
|
||||
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
|
||||
}
|
||||
|
||||
function diffConfigSnapshot(
|
||||
before: AgentConfigSnapshot,
|
||||
after: AgentConfigSnapshot,
|
||||
): string[] {
|
||||
return CONFIG_REVISION_FIELDS.filter((field) => !jsonEqual(before[field], after[field]));
|
||||
}
|
||||
|
||||
function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$inferInsert> {
|
||||
if (!isPlainRecord(snapshot)) throw unprocessable("Invalid revision snapshot");
|
||||
|
||||
if (typeof snapshot.name !== "string" || snapshot.name.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: name");
|
||||
}
|
||||
if (typeof snapshot.role !== "string" || snapshot.role.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: role");
|
||||
}
|
||||
if (typeof snapshot.adapterType !== "string" || snapshot.adapterType.length === 0) {
|
||||
throw unprocessable("Invalid revision snapshot: adapterType");
|
||||
}
|
||||
if (typeof snapshot.budgetMonthlyCents !== "number" || !Number.isFinite(snapshot.budgetMonthlyCents)) {
|
||||
throw unprocessable("Invalid revision snapshot: budgetMonthlyCents");
|
||||
}
|
||||
|
||||
return {
|
||||
name: snapshot.name,
|
||||
role: snapshot.role,
|
||||
title: typeof snapshot.title === "string" || snapshot.title === null ? snapshot.title : null,
|
||||
reportsTo:
|
||||
typeof snapshot.reportsTo === "string" || snapshot.reportsTo === null ? snapshot.reportsTo : null,
|
||||
capabilities:
|
||||
typeof snapshot.capabilities === "string" || snapshot.capabilities === null
|
||||
? snapshot.capabilities
|
||||
: null,
|
||||
adapterType: snapshot.adapterType,
|
||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAgentShortnameCollision(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
): boolean {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return false;
|
||||
|
||||
return existingAgents.some((agent) => {
|
||||
if (agent.status === "terminated") return false;
|
||||
if (options?.excludeAgentId && agent.id === options.excludeAgentId) return false;
|
||||
return normalizeAgentUrlKey(agent.name) === candidateShortname;
|
||||
});
|
||||
}
|
||||
|
||||
export function deduplicateAgentName(
|
||||
candidateName: string,
|
||||
existingAgents: AgentShortnameRow[],
|
||||
): string {
|
||||
if (!hasAgentShortnameCollision(candidateName, existingAgents)) {
|
||||
return candidateName;
|
||||
}
|
||||
for (let i = 2; i <= 100; i++) {
|
||||
const suffixed = `${candidateName} ${i}`;
|
||||
if (!hasAgentShortnameCollision(suffixed, existingAgents)) {
|
||||
return suffixed;
|
||||
}
|
||||
}
|
||||
return `${candidateName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function withUrlKey<T extends { id: string; name: string }>(row: T) {
|
||||
return {
|
||||
...row,
|
||||
urlKey: normalizeAgentUrlKey(row.name) ?? row.id,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
||||
return withUrlKey({
|
||||
...row,
|
||||
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
||||
});
|
||||
}
|
||||
|
||||
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
|
||||
if (agentIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
inArray(costEvents.agentId, agentIds),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.agentId);
|
||||
return new Map(rows.map((row) => [row.agentId, Number(row.spentMonthlyCents ?? 0)]));
|
||||
}
|
||||
|
||||
async function hydrateAgentSpend<T extends { id: string; companyId: string; spentMonthlyCents: number }>(rows: T[]) {
|
||||
const agentIds = rows.map((row) => row.id);
|
||||
const companyId = rows[0]?.companyId;
|
||||
if (!companyId || agentIds.length === 0) return rows;
|
||||
const spendByAgentId = await getMonthlySpendByAgentIds(companyId, agentIds);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
spentMonthlyCents: spendByAgentId.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [hydrated] = await hydrateAgentSpend([row]);
|
||||
return normalizeAgentRow(hydrated);
|
||||
}
|
||||
|
||||
async function ensureManager(companyId: string, managerId: string) {
|
||||
const manager = await getById(managerId);
|
||||
if (!manager) throw notFound("Manager not found");
|
||||
if (manager.companyId !== companyId) {
|
||||
throw unprocessable("Manager must belong to same company");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
|
||||
if (!reportsTo) return;
|
||||
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
|
||||
|
||||
let cursor: string | null = reportsTo;
|
||||
while (cursor) {
|
||||
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
|
||||
const next = await getById(cursor);
|
||||
cursor = next?.reportsTo ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCompanyShortnameAvailable(
|
||||
companyId: string,
|
||||
candidateName: string,
|
||||
options?: AgentShortnameCollisionOptions,
|
||||
) {
|
||||
const candidateShortname = normalizeAgentUrlKey(candidateName);
|
||||
if (!candidateShortname) return;
|
||||
|
||||
const existingAgents = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
|
||||
const hasCollision = hasAgentShortnameCollision(candidateName, existingAgents, options);
|
||||
if (hasCollision) {
|
||||
throw conflict(
|
||||
`Agent shortname '${candidateShortname}' is already in use in this company`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
options?: UpdateAgentOptions,
|
||||
) {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
|
||||
throw conflict("Terminated agents cannot be resumed");
|
||||
}
|
||||
if (
|
||||
existing.status === "pending_approval" &&
|
||||
data.status &&
|
||||
data.status !== "pending_approval" &&
|
||||
data.status !== "terminated"
|
||||
) {
|
||||
throw conflict("Pending approval agents cannot be activated directly");
|
||||
}
|
||||
|
||||
if (data.reportsTo !== undefined) {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(existing.companyId, data.reportsTo);
|
||||
}
|
||||
await assertNoCycle(id, data.reportsTo);
|
||||
}
|
||||
|
||||
if (data.name !== undefined) {
|
||||
const previousShortname = normalizeAgentUrlKey(existing.name);
|
||||
const nextShortname = normalizeAgentUrlKey(data.name);
|
||||
if (previousShortname !== nextShortname) {
|
||||
await assertCompanyShortnameAvailable(existing.companyId, data.name, { excludeAgentId: id });
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
|
||||
}
|
||||
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ ...normalizedPatch, updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const normalizedUpdated = updated ? normalizeAgentRow(updated) : null;
|
||||
|
||||
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
|
||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||
const changedKeys = diffConfigSnapshot(beforeConfig, afterConfig);
|
||||
if (changedKeys.length > 0) {
|
||||
await db.insert(agentConfigRevisions).values({
|
||||
companyId: normalizedUpdated.companyId,
|
||||
agentId: normalizedUpdated.id,
|
||||
createdByAgentId: options?.recordRevision?.createdByAgentId ?? null,
|
||||
createdByUserId: options?.recordRevision?.createdByUserId ?? null,
|
||||
source: options?.recordRevision?.source ?? "patch",
|
||||
rolledBackFromRevisionId: options?.recordRevision?.rolledBackFromRevisionId ?? null,
|
||||
changedKeys,
|
||||
beforeConfig: beforeConfig as unknown as Record<string, unknown>,
|
||||
afterConfig: afterConfig as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedUpdated;
|
||||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, options?: { includeTerminated?: boolean }) => {
|
||||
const conditions = [eq(agents.companyId, companyId)];
|
||||
if (!options?.includeTerminated) {
|
||||
conditions.push(ne(agents.status, "terminated"));
|
||||
}
|
||||
const rows = await db.select().from(agents).where(and(...conditions));
|
||||
const hydrated = await hydrateAgentSpend(rows);
|
||||
return hydrated.map(normalizeAgentRow);
|
||||
},
|
||||
|
||||
getById,
|
||||
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
|
||||
const existingAgents = await db
|
||||
.select({ id: agents.id, name: agents.name, status: agents.status })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
const uniqueName = deduplicateAgentName(data.name, existingAgents);
|
||||
|
||||
const role = data.role ?? "general";
|
||||
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
|
||||
const created = await db
|
||||
.insert(agents)
|
||||
.values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return normalizeAgentRow(created);
|
||||
},
|
||||
|
||||
update: updateAgent,
|
||||
|
||||
pause: async (id: string, reason: "manual" | "budget" | "system" = "manual") => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: reason,
|
||||
pausedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
resume: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Pending approval agents cannot be resumed");
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
terminate: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "terminated",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id));
|
||||
|
||||
await db
|
||||
.update(agentApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(agentApiKeys.agentId, id));
|
||||
|
||||
return getById(id);
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.agentId, id));
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.agentId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.agentId, id));
|
||||
const deleted = await tx
|
||||
.delete(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return deleted ? normalizeAgentRow(deleted) : null;
|
||||
});
|
||||
},
|
||||
|
||||
activatePendingApproval: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
if (existing.status !== "pending_approval") return existing;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({ status: "idle", updatedAt: new Date() })
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
permissions: normalizeAgentPermissions(permissions, existing.role),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return updated ? normalizeAgentRow(updated) : null;
|
||||
},
|
||||
|
||||
listConfigRevisions: async (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(eq(agentConfigRevisions.agentId, id))
|
||||
.orderBy(desc(agentConfigRevisions.createdAt)),
|
||||
|
||||
getConfigRevision: async (id: string, revisionId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
rollbackConfigRevision: async (
|
||||
id: string,
|
||||
revisionId: string,
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
) => {
|
||||
const revision = await db
|
||||
.select()
|
||||
.from(agentConfigRevisions)
|
||||
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!revision) return null;
|
||||
if (containsRedactedMarker(revision.afterConfig)) {
|
||||
throw unprocessable("Cannot roll back a revision that contains redacted secret values");
|
||||
}
|
||||
|
||||
const patch = configPatchFromSnapshot(revision.afterConfig);
|
||||
return updateAgent(id, patch, {
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
source: "rollback",
|
||||
rolledBackFromRevisionId: revision.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
createApiKey: async (id: string, name: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) throw notFound("Agent not found");
|
||||
if (existing.status === "pending_approval") {
|
||||
throw conflict("Cannot create keys for pending approval agents");
|
||||
}
|
||||
if (existing.status === "terminated") {
|
||||
throw conflict("Cannot create keys for terminated agents");
|
||||
}
|
||||
|
||||
const token = createToken();
|
||||
const keyHash = hashToken(token);
|
||||
const created = await db
|
||||
.insert(agentApiKeys)
|
||||
.values({
|
||||
agentId: id,
|
||||
companyId: existing.companyId,
|
||||
name,
|
||||
keyHash,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
token,
|
||||
createdAt: created.createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
listKeys: (id: string) =>
|
||||
db
|
||||
.select({
|
||||
id: agentApiKeys.id,
|
||||
name: agentApiKeys.name,
|
||||
createdAt: agentApiKeys.createdAt,
|
||||
revokedAt: agentApiKeys.revokedAt,
|
||||
})
|
||||
.from(agentApiKeys)
|
||||
.where(eq(agentApiKeys.agentId, id)),
|
||||
|
||||
revokeKey: async (keyId: string) => {
|
||||
const rows = await db
|
||||
.update(agentApiKeys)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(eq(agentApiKeys.id, keyId))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
orgForCompany: async (companyId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
const normalizedRows = rows.map(normalizeAgentRow);
|
||||
const byManager = new Map<string | null, typeof normalizedRows>();
|
||||
for (const row of normalizedRows) {
|
||||
const key = row.reportsTo ?? null;
|
||||
const group = byManager.get(key) ?? [];
|
||||
group.push(row);
|
||||
byManager.set(key, group);
|
||||
}
|
||||
|
||||
const build = (managerId: string | null): Array<Record<string, unknown>> => {
|
||||
const members = byManager.get(managerId) ?? [];
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
reports: build(member.id),
|
||||
}));
|
||||
};
|
||||
|
||||
return build(null);
|
||||
},
|
||||
|
||||
getChainOfCommand: async (agentId: string) => {
|
||||
const chain: { id: string; name: string; role: string; title: string | null }[] = [];
|
||||
const visited = new Set<string>([agentId]);
|
||||
const start = await getById(agentId);
|
||||
let currentId = start?.reportsTo ?? null;
|
||||
while (currentId && !visited.has(currentId) && chain.length < 50) {
|
||||
visited.add(currentId);
|
||||
const mgr = await getById(currentId);
|
||||
if (!mgr) break;
|
||||
chain.push({ id: mgr.id, name: mgr.name, role: mgr.role, title: mgr.title ?? null });
|
||||
currentId = mgr.reportsTo ?? null;
|
||||
}
|
||||
return chain;
|
||||
},
|
||||
|
||||
runningForAgent: (agentId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
|
||||
|
||||
resolveByReference: async (companyId: string, reference: string) => {
|
||||
const raw = reference.trim();
|
||||
if (raw.length === 0) {
|
||||
return { agent: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
if (isUuidLike(raw)) {
|
||||
const byId = await getById(raw);
|
||||
if (!byId || byId.companyId !== companyId) {
|
||||
return { agent: null, ambiguous: false } as const;
|
||||
}
|
||||
return { agent: byId, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
const urlKey = normalizeAgentUrlKey(raw);
|
||||
if (!urlKey) {
|
||||
return { agent: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
const matches = rows
|
||||
.map(normalizeAgentRow)
|
||||
.filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated");
|
||||
if (matches.length === 1) {
|
||||
return { agent: matches[0] ?? null, ambiguous: false } as const;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { agent: null, ambiguous: true } as const;
|
||||
}
|
||||
return { agent: null, ambiguous: false } as const;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
import { notifyHireApproved } from "./hire-hook.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
export function approvalService(db: Db) {
|
||||
const agentsSvc = agentService(db);
|
||||
const budgets = budgetService(db);
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||
const resolvableStatuses = Array.from(canResolveStatuses);
|
||||
type ApprovalRecord = typeof approvals.$inferSelect;
|
||||
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
|
||||
|
||||
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
|
||||
return {
|
||||
...comment,
|
||||
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
|
||||
};
|
||||
}
|
||||
|
||||
async function getExistingApproval(id: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Approval not found");
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function resolveApproval(
|
||||
id: string,
|
||||
targetStatus: "approved" | "rejected",
|
||||
decidedByUserId: string,
|
||||
decisionNote: string | null | undefined,
|
||||
): Promise<ResolutionResult> {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (!canResolveStatuses.has(existing.status)) {
|
||||
if (existing.status === targetStatus) {
|
||||
return { approval: existing, applied: false };
|
||||
}
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: targetStatus,
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (updated) {
|
||||
return { approval: updated, applied: true };
|
||||
}
|
||||
|
||||
const latest = await getExistingApproval(id);
|
||||
if (latest.status === targetStatus) {
|
||||
return { approval: latest, applied: false };
|
||||
}
|
||||
|
||||
throw unprocessable(
|
||||
`Only pending or revision requested approvals can be ${targetStatus === "approved" ? "approved" : "rejected"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
list: (companyId: string, status?: string) => {
|
||||
const conditions = [eq(approvals.companyId, companyId)];
|
||||
if (status) conditions.push(eq(approvals.status, status));
|
||||
return db.select().from(approvals).where(and(...conditions));
|
||||
},
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(approvals)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"approved",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
);
|
||||
|
||||
let hireApprovedAgentId: string | null = null;
|
||||
const now = new Date();
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.activatePendingApproval(payloadAgentId);
|
||||
hireApprovedAgentId = payloadAgentId;
|
||||
} else {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
|
||||
adapterType: String(payload.adapterType ?? "process"),
|
||||
adapterConfig:
|
||||
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
|
||||
? (payload.adapterConfig as Record<string, unknown>)
|
||||
: {},
|
||||
budgetMonthlyCents:
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
|
||||
metadata:
|
||||
typeof payload.metadata === "object" && payload.metadata !== null
|
||||
? (payload.metadata as Record<string, unknown>)
|
||||
: null,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
permissions: undefined,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
hireApprovedAgentId = created?.id ?? null;
|
||||
}
|
||||
if (hireApprovedAgentId) {
|
||||
const budgetMonthlyCents =
|
||||
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0;
|
||||
if (budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
updated.companyId,
|
||||
{
|
||||
scopeType: "agent",
|
||||
scopeId: hireApprovedAgentId,
|
||||
amount: budgetMonthlyCents,
|
||||
windowKind: "calendar_month_utc",
|
||||
},
|
||||
decidedByUserId,
|
||||
);
|
||||
}
|
||||
void notifyHireApproved(db, {
|
||||
companyId: updated.companyId,
|
||||
agentId: hireApprovedAgentId,
|
||||
source: "approval",
|
||||
sourceId: id,
|
||||
approvedAt: now,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return { approval: updated, applied };
|
||||
},
|
||||
|
||||
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const { approval: updated, applied } = await resolveApproval(
|
||||
id,
|
||||
"rejected",
|
||||
decidedByUserId,
|
||||
decisionNote,
|
||||
);
|
||||
|
||||
if (applied && updated.type === "hire_agent") {
|
||||
const payload = updated.payload as Record<string, unknown>;
|
||||
const payloadAgentId = typeof payload.agentId === "string" ? payload.agentId : null;
|
||||
if (payloadAgentId) {
|
||||
await agentsSvc.terminate(payloadAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return { approval: updated, applied };
|
||||
},
|
||||
|
||||
requestRevision: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (existing.status !== "pending") {
|
||||
throw unprocessable("Only pending approvals can request revision");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "revision_requested",
|
||||
decidedByUserId,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
resubmit: async (id: string, payload?: Record<string, unknown>) => {
|
||||
const existing = await getExistingApproval(id);
|
||||
if (existing.status !== "revision_requested") {
|
||||
throw unprocessable("Only revision requested approvals can be resubmitted");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status: "pending",
|
||||
payload: payload ?? existing.payload,
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(approvals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
listComments: async (approvalId: string) => {
|
||||
const existing = await getExistingApproval(approvalId);
|
||||
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
|
||||
return db
|
||||
.select()
|
||||
.from(approvalComments)
|
||||
.where(
|
||||
and(
|
||||
eq(approvalComments.approvalId, approvalId),
|
||||
eq(approvalComments.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(approvalComments.createdAt))
|
||||
.then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
|
||||
},
|
||||
|
||||
addComment: async (
|
||||
approvalId: string,
|
||||
body: string,
|
||||
actor: { agentId?: string; userId?: string },
|
||||
) => {
|
||||
const existing = await getExistingApproval(approvalId);
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
|
||||
return db
|
||||
.insert(approvalComments)
|
||||
.values({
|
||||
companyId: existing.companyId,
|
||||
approvalId,
|
||||
authorAgentId: actor.agentId ?? null,
|
||||
authorUserId: actor.userId ?? null,
|
||||
body: redactedBody,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { assets } from "@paperclipai/db";
|
||||
|
||||
export function assetService(db: Db) {
|
||||
return {
|
||||
create: (companyId: string, data: Omit<typeof assets.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(assets)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(eq(assets.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
authUsers,
|
||||
boardApiKeys,
|
||||
cliAuthChallenges,
|
||||
companies,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
|
||||
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type CliAuthChallengeStatus = "pending" | "approved" | "cancelled" | "expired";
|
||||
|
||||
export function hashBearerToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function tokenHashesMatch(left: string, right: string) {
|
||||
const leftBytes = Buffer.from(left, "utf8");
|
||||
const rightBytes = Buffer.from(right, "utf8");
|
||||
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
export function createBoardApiToken() {
|
||||
return `pcp_board_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function createCliAuthSecret() {
|
||||
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function boardApiKeyExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + BOARD_API_KEY_TTL_MS);
|
||||
}
|
||||
|
||||
export function cliAuthChallengeExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + CLI_AUTH_CHALLENGE_TTL_MS);
|
||||
}
|
||||
|
||||
function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliAuthChallengeStatus {
|
||||
if (row.cancelledAt) return "cancelled";
|
||||
if (row.expiresAt.getTime() <= Date.now()) return "expired";
|
||||
if (row.approvedAt && row.boardApiKeyId) return "approved";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function boardAuthService(db: Db) {
|
||||
async function resolveBoardAccess(userId: string) {
|
||||
const [user, memberships, adminRole] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.companyId)),
|
||||
db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
companyIds: memberships,
|
||||
isInstanceAdmin: Boolean(adminRole),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveBoardActivityCompanyIds(input: {
|
||||
userId: string;
|
||||
requestedCompanyId?: string | null;
|
||||
boardApiKeyId?: string | null;
|
||||
}) {
|
||||
const access = await resolveBoardAccess(input.userId);
|
||||
const companyIds = new Set(access.companyIds);
|
||||
|
||||
if (companyIds.size === 0 && input.requestedCompanyId?.trim()) {
|
||||
companyIds.add(input.requestedCompanyId.trim());
|
||||
}
|
||||
|
||||
if (companyIds.size === 0 && input.boardApiKeyId?.trim()) {
|
||||
const challengeCompanyIds = await db
|
||||
.select({ requestedCompanyId: cliAuthChallenges.requestedCompanyId })
|
||||
.from(cliAuthChallenges)
|
||||
.where(eq(cliAuthChallenges.boardApiKeyId, input.boardApiKeyId.trim()))
|
||||
.then((rows) =>
|
||||
rows
|
||||
.map((row) => row.requestedCompanyId?.trim() ?? null)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
for (const companyId of challengeCompanyIds) {
|
||||
companyIds.add(companyId);
|
||||
}
|
||||
}
|
||||
|
||||
if (companyIds.size === 0 && access.isInstanceAdmin) {
|
||||
const allCompanyIds = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.then((rows) => rows.map((row) => row.id));
|
||||
for (const companyId of allCompanyIds) {
|
||||
companyIds.add(companyId);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(companyIds);
|
||||
}
|
||||
|
||||
async function findBoardApiKeyByToken(token: string) {
|
||||
const tokenHash = hashBearerToken(token);
|
||||
const now = new Date();
|
||||
return db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(boardApiKeys.keyHash, tokenHash),
|
||||
isNull(boardApiKeys.revokedAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
|
||||
}
|
||||
|
||||
async function touchBoardApiKey(id: string) {
|
||||
await db.update(boardApiKeys).set({ lastUsedAt: new Date() }).where(eq(boardApiKeys.id, id));
|
||||
}
|
||||
|
||||
async function revokeBoardApiKey(id: string) {
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(boardApiKeys)
|
||||
.set({ revokedAt: now, lastUsedAt: now })
|
||||
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function createCliAuthChallenge(input: {
|
||||
command: string;
|
||||
clientName?: string | null;
|
||||
requestedAccess: "board" | "instance_admin_required";
|
||||
requestedCompanyId?: string | null;
|
||||
}) {
|
||||
const challengeSecret = createCliAuthSecret();
|
||||
const pendingBoardToken = createBoardApiToken();
|
||||
const expiresAt = cliAuthChallengeExpiresAt();
|
||||
const labelBase = input.clientName?.trim() || "paperclipai cli";
|
||||
const pendingKeyName =
|
||||
input.requestedAccess === "instance_admin_required"
|
||||
? `${labelBase} (instance admin)`
|
||||
: `${labelBase} (board)`;
|
||||
|
||||
const created = await db
|
||||
.insert(cliAuthChallenges)
|
||||
.values({
|
||||
secretHash: hashBearerToken(challengeSecret),
|
||||
command: input.command.trim(),
|
||||
clientName: input.clientName?.trim() || null,
|
||||
requestedAccess: input.requestedAccess,
|
||||
requestedCompanyId: input.requestedCompanyId?.trim() || null,
|
||||
pendingKeyHash: hashBearerToken(pendingBoardToken),
|
||||
pendingKeyName,
|
||||
expiresAt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return {
|
||||
challenge: created,
|
||||
challengeSecret,
|
||||
pendingBoardToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCliAuthChallenge(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(cliAuthChallenges)
|
||||
.where(eq(cliAuthChallenges.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getCliAuthChallengeBySecret(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallenge(id);
|
||||
if (!challenge) return null;
|
||||
if (!tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) return null;
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async function describeCliAuthChallenge(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallengeBySecret(id, token);
|
||||
if (!challenge) return null;
|
||||
|
||||
const [company, approvedBy] = await Promise.all([
|
||||
challenge.requestedCompanyId
|
||||
? db
|
||||
.select({ id: companies.id, name: companies.name })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, challenge.requestedCompanyId))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
challenge.approvedByUserId
|
||||
? db
|
||||
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, challenge.approvedByUserId))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: challenge.id,
|
||||
status: challengeStatusForRow(challenge),
|
||||
command: challenge.command,
|
||||
clientName: challenge.clientName ?? null,
|
||||
requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required",
|
||||
requestedCompanyId: challenge.requestedCompanyId ?? null,
|
||||
requestedCompanyName: company?.name ?? null,
|
||||
approvedAt: challenge.approvedAt?.toISOString() ?? null,
|
||||
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
|
||||
expiresAt: challenge.expiresAt.toISOString(),
|
||||
approvedByUser: approvedBy
|
||||
? {
|
||||
id: approvedBy.id,
|
||||
name: approvedBy.name,
|
||||
email: approvedBy.email,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
|
||||
const access = await resolveBoardAccess(userId);
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select ${cliAuthChallenges.id} from ${cliAuthChallenges} where ${cliAuthChallenges.id} = ${id} for update`,
|
||||
);
|
||||
|
||||
const challenge = await tx
|
||||
.select()
|
||||
.from(cliAuthChallenges)
|
||||
.where(eq(cliAuthChallenges.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!challenge || !tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) {
|
||||
throw notFound("CLI auth challenge not found");
|
||||
}
|
||||
|
||||
const status = challengeStatusForRow(challenge);
|
||||
if (status === "expired") return { status, challenge };
|
||||
if (status === "cancelled") return { status, challenge };
|
||||
|
||||
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
|
||||
throw forbidden("Instance admin required");
|
||||
}
|
||||
|
||||
let boardKeyId = challenge.boardApiKeyId;
|
||||
if (!boardKeyId) {
|
||||
const createdKey = await tx
|
||||
.insert(boardApiKeys)
|
||||
.values({
|
||||
userId,
|
||||
name: challenge.pendingKeyName,
|
||||
keyHash: challenge.pendingKeyHash,
|
||||
expiresAt: boardApiKeyExpiresAt(),
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
boardKeyId = createdKey.id;
|
||||
}
|
||||
|
||||
const approvedAt = challenge.approvedAt ?? new Date();
|
||||
const updated = await tx
|
||||
.update(cliAuthChallenges)
|
||||
.set({
|
||||
approvedByUserId: userId,
|
||||
boardApiKeyId: boardKeyId,
|
||||
approvedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cliAuthChallenges.id, challenge.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? challenge);
|
||||
|
||||
return { status: "approved" as const, challenge: updated };
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelCliAuthChallenge(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallengeBySecret(id, token);
|
||||
if (!challenge) throw notFound("CLI auth challenge not found");
|
||||
|
||||
const status = challengeStatusForRow(challenge);
|
||||
if (status === "approved") return { status, challenge };
|
||||
if (status === "expired") return { status, challenge };
|
||||
if (status === "cancelled") return { status, challenge };
|
||||
|
||||
const updated = await db
|
||||
.update(cliAuthChallenges)
|
||||
.set({
|
||||
cancelledAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cliAuthChallenges.id, challenge.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? challenge);
|
||||
|
||||
return { status: "cancelled" as const, challenge: updated };
|
||||
}
|
||||
|
||||
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
|
||||
if (!keyId || !userId) throw conflict("Board API key context is required");
|
||||
const key = await db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!key || key.revokedAt) throw notFound("Board API key not found");
|
||||
return key;
|
||||
}
|
||||
|
||||
return {
|
||||
resolveBoardAccess,
|
||||
findBoardApiKeyByToken,
|
||||
touchBoardApiKey,
|
||||
revokeBoardApiKey,
|
||||
createCliAuthChallenge,
|
||||
getCliAuthChallengeBySecret,
|
||||
describeCliAuthChallenge,
|
||||
approveCliAuthChallenge,
|
||||
cancelCliAuthChallenge,
|
||||
assertCurrentBoardKey,
|
||||
resolveBoardActivityCompanyIds,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,958 @@
|
||||
import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
budgetIncidents,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
costEvents,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
BudgetIncident,
|
||||
BudgetIncidentResolutionInput,
|
||||
BudgetMetric,
|
||||
BudgetOverview,
|
||||
BudgetPolicy,
|
||||
BudgetPolicySummary,
|
||||
BudgetPolicyUpsertInput,
|
||||
BudgetScopeType,
|
||||
BudgetThresholdType,
|
||||
BudgetWindowKind,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
type ScopeRecord = {
|
||||
companyId: string;
|
||||
name: string;
|
||||
paused: boolean;
|
||||
pauseReason: "manual" | "budget" | "system" | null;
|
||||
};
|
||||
|
||||
type PolicyRow = typeof budgetPolicies.$inferSelect;
|
||||
type IncidentRow = typeof budgetIncidents.$inferSelect;
|
||||
|
||||
export type BudgetEnforcementScope = {
|
||||
companyId: string;
|
||||
scopeType: BudgetScopeType;
|
||||
scopeId: string;
|
||||
};
|
||||
|
||||
export type BudgetServiceHooks = {
|
||||
cancelWorkForScope?: (scope: BudgetEnforcementScope) => Promise<void>;
|
||||
};
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
const start = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
|
||||
const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
|
||||
if (windowKind === "lifetime") {
|
||||
return {
|
||||
start: new Date(Date.UTC(1970, 0, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(9999, 0, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
return currentUtcMonthWindow(now);
|
||||
}
|
||||
|
||||
function budgetStatusFromObserved(
|
||||
observedAmount: number,
|
||||
amount: number,
|
||||
warnPercent: number,
|
||||
): BudgetPolicySummary["status"] {
|
||||
if (amount <= 0) return "ok";
|
||||
if (observedAmount >= amount) return "hard_stop";
|
||||
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function normalizeScopeName(scopeType: BudgetScopeType, name: string) {
|
||||
if (scopeType === "company") return name;
|
||||
return name.trim().length > 0 ? name : scopeType;
|
||||
}
|
||||
|
||||
async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: string): Promise<ScopeRecord> {
|
||||
if (scopeType === "company") {
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: companies.id,
|
||||
name: companies.name,
|
||||
status: companies.status,
|
||||
pauseReason: companies.pauseReason,
|
||||
pausedAt: companies.pausedAt,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: row.status === "paused" || Boolean(row.pausedAt),
|
||||
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (scopeType === "agent") {
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
status: agents.status,
|
||||
pauseReason: agents.pauseReason,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Agent not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: row.status === "paused",
|
||||
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: projects.companyId,
|
||||
name: projects.name,
|
||||
pauseReason: projects.pauseReason,
|
||||
pausedAt: projects.pausedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, scopeId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Project not found");
|
||||
return {
|
||||
companyId: row.companyId,
|
||||
name: row.name,
|
||||
paused: Boolean(row.pausedAt),
|
||||
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function computeObservedAmount(
|
||||
db: Db,
|
||||
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
|
||||
) {
|
||||
if (policy.metric !== "billed_cents") return 0;
|
||||
|
||||
const conditions = [eq(costEvents.companyId, policy.companyId)];
|
||||
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
|
||||
if (policy.scopeType === "project") conditions.push(eq(costEvents.projectId, policy.scopeId));
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
if (policy.windowKind === "calendar_month_utc") {
|
||||
conditions.push(gte(costEvents.occurredAt, start));
|
||||
conditions.push(lt(costEvents.occurredAt, end));
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
function buildApprovalPayload(input: {
|
||||
policy: PolicyRow;
|
||||
scopeName: string;
|
||||
thresholdType: BudgetThresholdType;
|
||||
amountObserved: number;
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}) {
|
||||
return {
|
||||
scopeType: input.policy.scopeType,
|
||||
scopeId: input.policy.scopeId,
|
||||
scopeName: input.scopeName,
|
||||
metric: input.policy.metric,
|
||||
windowKind: input.policy.windowKind,
|
||||
thresholdType: input.thresholdType,
|
||||
budgetAmount: input.policy.amount,
|
||||
observedAmount: input.amountObserved,
|
||||
warnPercent: input.policy.warnPercent,
|
||||
windowStart: input.windowStart.toISOString(),
|
||||
windowEnd: input.windowEnd.toISOString(),
|
||||
policyId: input.policy.id,
|
||||
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
|
||||
};
|
||||
}
|
||||
|
||||
async function markApprovalStatus(
|
||||
db: Db,
|
||||
approvalId: string | null,
|
||||
status: "approved" | "rejected",
|
||||
decisionNote: string | null | undefined,
|
||||
decidedByUserId: string,
|
||||
) {
|
||||
if (!approvalId) return;
|
||||
await db
|
||||
.update(approvals)
|
||||
.set({
|
||||
status,
|
||||
decisionNote: decisionNote ?? null,
|
||||
decidedByUserId,
|
||||
decidedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(approvals.id, approvalId));
|
||||
}
|
||||
|
||||
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
|
||||
async function pauseScopeForBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(projects.id, policy.scopeId));
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, policy.scopeId));
|
||||
}
|
||||
|
||||
async function pauseAndCancelScopeForBudget(policy: PolicyRow) {
|
||||
await pauseScopeForBudget(policy);
|
||||
await hooks.cancelWorkForScope?.({
|
||||
companyId: policy.companyId,
|
||||
scopeType: policy.scopeType as BudgetScopeType,
|
||||
scopeId: policy.scopeId,
|
||||
});
|
||||
}
|
||||
|
||||
async function resumeScopeFromBudget(policy: PolicyRow) {
|
||||
const now = new Date();
|
||||
if (policy.scopeType === "agent") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
status: "idle",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy.scopeType === "project") {
|
||||
await db
|
||||
.update(projects)
|
||||
.set({
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(projects.id, policy.scopeId), eq(projects.pauseReason, "budget")));
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(companies.id, policy.scopeId), eq(companies.pauseReason, "budget")));
|
||||
}
|
||||
|
||||
async function getPolicyRow(policyId: string) {
|
||||
const policy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(eq(budgetPolicies.id, policyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!policy) throw notFound("Budget policy not found");
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function listPolicyRows(companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(eq(budgetPolicies.companyId, companyId))
|
||||
.orderBy(desc(budgetPolicies.updatedAt));
|
||||
}
|
||||
|
||||
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
const amount = policy.isActive ? policy.amount : 0;
|
||||
const utilizationPercent =
|
||||
amount > 0 ? Number(((observedAmount / amount) * 100).toFixed(2)) : 0;
|
||||
return {
|
||||
policyId: policy.id,
|
||||
companyId: policy.companyId,
|
||||
scopeType: policy.scopeType as BudgetScopeType,
|
||||
scopeId: policy.scopeId,
|
||||
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
|
||||
metric: policy.metric as BudgetMetric,
|
||||
windowKind: policy.windowKind as BudgetWindowKind,
|
||||
amount,
|
||||
observedAmount,
|
||||
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
|
||||
utilizationPercent,
|
||||
warnPercent: policy.warnPercent,
|
||||
hardStopEnabled: policy.hardStopEnabled,
|
||||
notifyEnabled: policy.notifyEnabled,
|
||||
isActive: policy.isActive,
|
||||
status: policy.isActive
|
||||
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
|
||||
: "ok",
|
||||
paused: scope.paused,
|
||||
pauseReason: scope.pauseReason,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
};
|
||||
}
|
||||
|
||||
async function createIncidentIfNeeded(
|
||||
policy: PolicyRow,
|
||||
thresholdType: BudgetThresholdType,
|
||||
amountObserved: number,
|
||||
) {
|
||||
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetIncidents.policyId, policy.id),
|
||||
eq(budgetIncidents.windowStart, start),
|
||||
eq(budgetIncidents.thresholdType, thresholdType),
|
||||
ne(budgetIncidents.status, "dismissed"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
|
||||
const payload = buildApprovalPayload({
|
||||
policy,
|
||||
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
|
||||
thresholdType,
|
||||
amountObserved,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
});
|
||||
|
||||
const approval = thresholdType === "hard"
|
||||
? await db
|
||||
.insert(approvals)
|
||||
.values({
|
||||
companyId: policy.companyId,
|
||||
type: "budget_override_required",
|
||||
requestedByUserId: null,
|
||||
requestedByAgentId: null,
|
||||
status: "pending",
|
||||
payload,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
|
||||
return db
|
||||
.insert(budgetIncidents)
|
||||
.values({
|
||||
companyId: policy.companyId,
|
||||
policyId: policy.id,
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
metric: policy.metric,
|
||||
windowKind: policy.windowKind,
|
||||
windowStart: start,
|
||||
windowEnd: end,
|
||||
thresholdType,
|
||||
amountLimit: policy.amount,
|
||||
amountObserved,
|
||||
status: "open",
|
||||
approvalId: approval?.id ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function resolveOpenSoftIncidents(policyId: string) {
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(budgetIncidents.policyId, policyId),
|
||||
eq(budgetIncidents.thresholdType, "soft"),
|
||||
eq(budgetIncidents.status, "open"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveOpenIncidentsForPolicy(
|
||||
policyId: string,
|
||||
approvalStatus: "approved" | "rejected" | null,
|
||||
decidedByUserId: string | null,
|
||||
) {
|
||||
const openRows = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
|
||||
|
||||
if (!approvalStatus || !decidedByUserId) return;
|
||||
for (const row of openRows) {
|
||||
await markApprovalStatus(db, row.approvalId ?? null, approvalStatus, "Resolved via budget update", decidedByUserId);
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateIncidentRows(rows: IncidentRow[]): Promise<BudgetIncident[]> {
|
||||
const approvalIds = rows.map((row) => row.approvalId).filter((value): value is string => Boolean(value));
|
||||
const approvalRows = approvalIds.length > 0
|
||||
? await db
|
||||
.select({ id: approvals.id, status: approvals.status })
|
||||
.from(approvals)
|
||||
.where(inArray(approvals.id, approvalIds))
|
||||
: [];
|
||||
const approvalStatusById = new Map(approvalRows.map((row) => [row.id, row.status]));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
policyId: row.policyId,
|
||||
scopeType: row.scopeType as BudgetScopeType,
|
||||
scopeId: row.scopeId,
|
||||
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
|
||||
metric: row.metric as BudgetMetric,
|
||||
windowKind: row.windowKind as BudgetWindowKind,
|
||||
windowStart: row.windowStart,
|
||||
windowEnd: row.windowEnd,
|
||||
thresholdType: row.thresholdType as BudgetThresholdType,
|
||||
amountLimit: row.amountLimit,
|
||||
amountObserved: row.amountObserved,
|
||||
status: row.status as BudgetIncident["status"],
|
||||
approvalId: row.approvalId ?? null,
|
||||
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
|
||||
resolvedAt: row.resolvedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
|
||||
const rows = await listPolicyRows(companyId);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
scopeType: row.scopeType as BudgetScopeType,
|
||||
metric: row.metric as BudgetMetric,
|
||||
windowKind: row.windowKind as BudgetWindowKind,
|
||||
}));
|
||||
},
|
||||
|
||||
upsertPolicy: async (
|
||||
companyId: string,
|
||||
input: BudgetPolicyUpsertInput,
|
||||
actorUserId: string | null,
|
||||
): Promise<BudgetPolicySummary> => {
|
||||
const scope = await resolveScopeRecord(db, input.scopeType, input.scopeId);
|
||||
if (scope.companyId !== companyId) {
|
||||
throw unprocessable("Budget scope does not belong to company");
|
||||
}
|
||||
|
||||
const metric = input.metric ?? "billed_cents";
|
||||
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
|
||||
const amount = Math.max(0, Math.floor(input.amount));
|
||||
const nextIsActive = amount > 0 && (input.isActive ?? true);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, input.scopeType),
|
||||
eq(budgetPolicies.scopeId, input.scopeId),
|
||||
eq(budgetPolicies.metric, metric),
|
||||
eq(budgetPolicies.windowKind, windowKind),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
const now = new Date();
|
||||
const row = existing
|
||||
? await db
|
||||
.update(budgetPolicies)
|
||||
.set({
|
||||
amount,
|
||||
warnPercent: input.warnPercent ?? existing.warnPercent,
|
||||
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
|
||||
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
|
||||
isActive: nextIsActive,
|
||||
updatedByUserId: actorUserId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(budgetPolicies.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0])
|
||||
: await db
|
||||
.insert(budgetPolicies)
|
||||
.values({
|
||||
companyId,
|
||||
scopeType: input.scopeType,
|
||||
scopeId: input.scopeId,
|
||||
metric,
|
||||
windowKind,
|
||||
amount,
|
||||
warnPercent: input.warnPercent ?? 80,
|
||||
hardStopEnabled: input.hardStopEnabled ?? true,
|
||||
notifyEnabled: input.notifyEnabled ?? true,
|
||||
isActive: nextIsActive,
|
||||
createdByUserId: actorUserId,
|
||||
updatedByUserId: actorUserId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (input.scopeType === "company" && windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
budgetMonthlyCents: amount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companies.id, input.scopeId));
|
||||
}
|
||||
|
||||
if (input.scopeType === "agent" && windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
budgetMonthlyCents: amount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agents.id, input.scopeId));
|
||||
}
|
||||
|
||||
if (amount > 0) {
|
||||
const observedAmount = await computeObservedAmount(db, row);
|
||||
if (observedAmount < amount) {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
} else {
|
||||
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
|
||||
if (row.notifyEnabled && observedAmount >= softThreshold) {
|
||||
await createIncidentIfNeeded(row, "soft", observedAmount);
|
||||
}
|
||||
if (row.hardStopEnabled && observedAmount >= row.amount) {
|
||||
await resolveOpenSoftIncidents(row.id);
|
||||
await createIncidentIfNeeded(row, "hard", observedAmount);
|
||||
await pauseAndCancelScopeForBudget(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await resumeScopeFromBudget(row);
|
||||
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "budget.policy_upserted",
|
||||
entityType: "budget_policy",
|
||||
entityId: row.id,
|
||||
details: {
|
||||
scopeType: row.scopeType,
|
||||
scopeId: row.scopeId,
|
||||
amount: row.amount,
|
||||
windowKind: row.windowKind,
|
||||
},
|
||||
});
|
||||
|
||||
return buildPolicySummary(row);
|
||||
},
|
||||
|
||||
overview: async (companyId: string): Promise<BudgetOverview> => {
|
||||
const rows = await listPolicyRows(companyId);
|
||||
const policies = await Promise.all(rows.map((row) => buildPolicySummary(row)));
|
||||
const activeIncidentRows = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
|
||||
.orderBy(desc(budgetIncidents.createdAt));
|
||||
const activeIncidents = await hydrateIncidentRows(activeIncidentRows);
|
||||
return {
|
||||
companyId,
|
||||
policies,
|
||||
activeIncidents,
|
||||
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
|
||||
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
|
||||
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
|
||||
};
|
||||
},
|
||||
|
||||
evaluateCostEvent: async (event: typeof costEvents.$inferSelect) => {
|
||||
const candidatePolicies = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, event.companyId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
inArray(budgetPolicies.scopeType, ["company", "agent", "project"]),
|
||||
),
|
||||
);
|
||||
|
||||
const relevantPolicies = candidatePolicies.filter((policy) => {
|
||||
if (policy.scopeType === "company") return policy.scopeId === event.companyId;
|
||||
if (policy.scopeType === "agent") return policy.scopeId === event.agentId;
|
||||
if (policy.scopeType === "project") return Boolean(event.projectId) && policy.scopeId === event.projectId;
|
||||
return false;
|
||||
});
|
||||
|
||||
for (const policy of relevantPolicies) {
|
||||
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
|
||||
const observedAmount = await computeObservedAmount(db, policy);
|
||||
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
|
||||
|
||||
if (policy.notifyEnabled && observedAmount >= softThreshold) {
|
||||
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
|
||||
if (softIncident) {
|
||||
await logActivity(db, {
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
action: "budget.soft_threshold_crossed",
|
||||
entityType: "budget_incident",
|
||||
entityId: softIncident.id,
|
||||
details: {
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
amountObserved: observedAmount,
|
||||
amountLimit: policy.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
|
||||
await resolveOpenSoftIncidents(policy.id);
|
||||
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
|
||||
await pauseAndCancelScopeForBudget(policy);
|
||||
if (hardIncident) {
|
||||
await logActivity(db, {
|
||||
companyId: policy.companyId,
|
||||
actorType: "system",
|
||||
actorId: "budget_service",
|
||||
action: "budget.hard_threshold_crossed",
|
||||
entityType: "budget_incident",
|
||||
entityId: hardIncident.id,
|
||||
details: {
|
||||
scopeType: policy.scopeType,
|
||||
scopeId: policy.scopeId,
|
||||
amountObserved: observedAmount,
|
||||
amountLimit: policy.amount,
|
||||
approvalId: hardIncident.approvalId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getInvocationBlock: async (
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
context?: { issueId?: string | null; projectId?: string | null },
|
||||
) => {
|
||||
const agent = await db
|
||||
.select({
|
||||
status: agents.status,
|
||||
pauseReason: agents.pauseReason,
|
||||
companyId: agents.companyId,
|
||||
name: agents.name,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
|
||||
|
||||
const company = await db
|
||||
.select({
|
||||
status: companies.status,
|
||||
pauseReason: companies.pauseReason,
|
||||
name: companies.name,
|
||||
})
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) throw notFound("Company not found");
|
||||
if (company.status === "paused") {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason:
|
||||
company.pauseReason === "budget"
|
||||
? "Company is paused because its budget hard-stop was reached."
|
||||
: "Company is paused and cannot start new work.",
|
||||
};
|
||||
}
|
||||
|
||||
const companyPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "company"),
|
||||
eq(budgetPolicies.scopeId, companyId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, companyPolicy);
|
||||
if (observed >= companyPolicy.amount) {
|
||||
return {
|
||||
scopeType: "company" as const,
|
||||
scopeId: companyId,
|
||||
scopeName: company.name,
|
||||
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (agent.status === "paused" && agent.pauseReason === "budget") {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
}
|
||||
|
||||
const agentPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "agent"),
|
||||
eq(budgetPolicies.scopeId, agentId),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, agentPolicy);
|
||||
if (observed >= agentPolicy.amount) {
|
||||
return {
|
||||
scopeType: "agent" as const,
|
||||
scopeId: agentId,
|
||||
scopeName: agent.name,
|
||||
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidateProjectId = context?.projectId ?? null;
|
||||
if (!candidateProjectId) return null;
|
||||
|
||||
const project = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
companyId: projects.companyId,
|
||||
pauseReason: projects.pauseReason,
|
||||
pausedAt: projects.pausedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, candidateProjectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!project || project.companyId !== companyId) return null;
|
||||
const projectPolicy = await db
|
||||
.select()
|
||||
.from(budgetPolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(budgetPolicies.companyId, companyId),
|
||||
eq(budgetPolicies.scopeType, "project"),
|
||||
eq(budgetPolicies.scopeId, project.id),
|
||||
eq(budgetPolicies.isActive, true),
|
||||
eq(budgetPolicies.metric, "billed_cents"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
|
||||
const observed = await computeObservedAmount(db, projectPolicy);
|
||||
if (observed >= projectPolicy.amount) {
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.pausedAt || project.pauseReason !== "budget") return null;
|
||||
return {
|
||||
scopeType: "project" as const,
|
||||
scopeId: project.id,
|
||||
scopeName: project.name,
|
||||
reason: "Project is paused because its budget hard-stop was reached.",
|
||||
};
|
||||
},
|
||||
|
||||
resolveIncident: async (
|
||||
companyId: string,
|
||||
incidentId: string,
|
||||
input: BudgetIncidentResolutionInput,
|
||||
actorUserId: string,
|
||||
): Promise<BudgetIncident> => {
|
||||
const incident = await db
|
||||
.select()
|
||||
.from(budgetIncidents)
|
||||
.where(eq(budgetIncidents.id, incidentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!incident) throw notFound("Budget incident not found");
|
||||
if (incident.companyId !== companyId) throw notFound("Budget incident not found");
|
||||
|
||||
const policy = await getPolicyRow(incident.policyId);
|
||||
if (input.action === "raise_budget_and_resume") {
|
||||
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
|
||||
const currentObserved = await computeObservedAmount(db, policy);
|
||||
if (nextAmount <= currentObserved) {
|
||||
throw unprocessable("New budget must exceed current observed spend");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(budgetPolicies)
|
||||
.set({
|
||||
amount: nextAmount,
|
||||
isActive: true,
|
||||
updatedByUserId: actorUserId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(budgetPolicies.id, policy.id));
|
||||
|
||||
if (policy.scopeType === "company" && policy.windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(companies)
|
||||
.set({ budgetMonthlyCents: nextAmount, updatedAt: now })
|
||||
.where(eq(companies.id, policy.scopeId));
|
||||
}
|
||||
|
||||
if (policy.scopeType === "agent" && policy.windowKind === "calendar_month_utc") {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({ budgetMonthlyCents: nextAmount, updatedAt: now })
|
||||
.where(eq(agents.id, policy.scopeId));
|
||||
}
|
||||
|
||||
await resumeScopeFromBudget(policy);
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(budgetIncidents.policyId, policy.id), eq(budgetIncidents.status, "open")));
|
||||
|
||||
await markApprovalStatus(db, incident.approvalId ?? null, "approved", input.decisionNote, actorUserId);
|
||||
} else {
|
||||
await db
|
||||
.update(budgetIncidents)
|
||||
.set({
|
||||
status: "dismissed",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(budgetIncidents.id, incident.id));
|
||||
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: incident.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId,
|
||||
action: "budget.incident_resolved",
|
||||
entityType: "budget_incident",
|
||||
entityId: incident.id,
|
||||
details: {
|
||||
action: input.action,
|
||||
amount: input.amount ?? null,
|
||||
scopeType: incident.scopeType,
|
||||
scopeId: incident.scopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const [updated] = await hydrateIncidentRows([{
|
||||
...incident,
|
||||
status: input.action === "raise_budget_and_resume" ? "resolved" : "dismissed",
|
||||
resolvedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}]);
|
||||
return updated!;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { and, count, eq, gte, inArray, lt, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companies,
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
agentTaskSessions,
|
||||
agentWakeupRequests,
|
||||
issues,
|
||||
issueComments,
|
||||
projects,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
heartbeatRunEvents,
|
||||
costEvents,
|
||||
financeEvents,
|
||||
approvalComments,
|
||||
approvals,
|
||||
activityLog,
|
||||
companySecrets,
|
||||
joinRequests,
|
||||
invites,
|
||||
principalPermissionGrants,
|
||||
companyMemberships,
|
||||
} from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export function companyService(db: Db) {
|
||||
const ISSUE_PREFIX_FALLBACK = "CMP";
|
||||
|
||||
const companySelection = {
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
description: companies.description,
|
||||
status: companies.status,
|
||||
issuePrefix: companies.issuePrefix,
|
||||
issueCounter: companies.issueCounter,
|
||||
budgetMonthlyCents: companies.budgetMonthlyCents,
|
||||
spentMonthlyCents: companies.spentMonthlyCents,
|
||||
requireBoardApprovalForNewAgents: companies.requireBoardApprovalForNewAgents,
|
||||
brandColor: companies.brandColor,
|
||||
logoAssetId: companyLogos.assetId,
|
||||
createdAt: companies.createdAt,
|
||||
updatedAt: companies.updatedAt,
|
||||
};
|
||||
|
||||
function enrichCompany<T extends { logoAssetId: string | null }>(company: T) {
|
||||
return {
|
||||
...company,
|
||||
logoUrl: company.logoAssetId ? `/api/assets/${company.logoAssetId}/content` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMonthlySpendByCompanyIds(
|
||||
companyIds: string[],
|
||||
database: Pick<Db, "select"> = db,
|
||||
) {
|
||||
if (companyIds.length === 0) return new Map<string, number>();
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const rows = await database
|
||||
.select({
|
||||
companyId: costEvents.companyId,
|
||||
spentMonthlyCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
inArray(costEvents.companyId, companyIds),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.companyId);
|
||||
return new Map(rows.map((row) => [row.companyId, Number(row.spentMonthlyCents ?? 0)]));
|
||||
}
|
||||
|
||||
async function hydrateCompanySpend<T extends { id: string; spentMonthlyCents: number }>(
|
||||
rows: T[],
|
||||
database: Pick<Db, "select"> = db,
|
||||
) {
|
||||
const spendByCompanyId = await getMonthlySpendByCompanyIds(rows.map((row) => row.id), database);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
spentMonthlyCents: spendByCompanyId.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function getCompanyQuery(database: Pick<Db, "select">) {
|
||||
return database
|
||||
.select(companySelection)
|
||||
.from(companies)
|
||||
.leftJoin(companyLogos, eq(companyLogos.companyId, companies.id));
|
||||
}
|
||||
|
||||
function deriveIssuePrefixBase(name: string) {
|
||||
const normalized = name.toUpperCase().replace(/[^A-Z]/g, "");
|
||||
return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK;
|
||||
}
|
||||
|
||||
function suffixForAttempt(attempt: number) {
|
||||
if (attempt <= 1) return "";
|
||||
return "A".repeat(attempt - 1);
|
||||
}
|
||||
|
||||
function isIssuePrefixConflict(error: unknown) {
|
||||
const constraint = typeof error === "object" && error !== null && "constraint" in error
|
||||
? (error as { constraint?: string }).constraint
|
||||
: typeof error === "object" && error !== null && "constraint_name" in error
|
||||
? (error as { constraint_name?: string }).constraint_name
|
||||
: undefined;
|
||||
return typeof error === "object"
|
||||
&& error !== null
|
||||
&& "code" in error
|
||||
&& (error as { code?: string }).code === "23505"
|
||||
&& constraint === "companies_issue_prefix_idx";
|
||||
}
|
||||
|
||||
async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) {
|
||||
const base = deriveIssuePrefixBase(data.name);
|
||||
let suffix = 1;
|
||||
while (suffix < 10000) {
|
||||
const candidate = `${base}${suffixForAttempt(suffix)}`;
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(companies)
|
||||
.values({ ...data, issuePrefix: candidate })
|
||||
.returning();
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
if (!isIssuePrefixConflict(error)) throw error;
|
||||
}
|
||||
suffix += 1;
|
||||
}
|
||||
throw new Error("Unable to allocate unique issue prefix");
|
||||
}
|
||||
|
||||
return {
|
||||
list: async () => {
|
||||
const rows = await getCompanyQuery(db);
|
||||
const hydrated = await hydrateCompanySpend(rows);
|
||||
return hydrated.map((row) => enrichCompany(row));
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [hydrated] = await hydrateCompanySpend([row], db);
|
||||
return enrichCompany(hydrated);
|
||||
},
|
||||
|
||||
create: async (data: typeof companies.$inferInsert) => {
|
||||
const created = await createCompanyWithUniquePrefix(data);
|
||||
const row = await getCompanyQuery(db)
|
||||
.where(eq(companies.id, created.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Company not found after creation");
|
||||
const [hydrated] = await hydrateCompanySpend([row], db);
|
||||
return enrichCompany(hydrated);
|
||||
},
|
||||
|
||||
update: (
|
||||
id: string,
|
||||
data: Partial<typeof companies.$inferInsert> & { logoAssetId?: string | null },
|
||||
) =>
|
||||
db.transaction(async (tx) => {
|
||||
const existing = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const { logoAssetId, ...companyPatch } = data;
|
||||
|
||||
if (logoAssetId !== undefined && logoAssetId !== null) {
|
||||
const nextLogoAsset = await tx
|
||||
.select({ id: assets.id, companyId: assets.companyId })
|
||||
.from(assets)
|
||||
.where(eq(assets.id, logoAssetId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!nextLogoAsset) throw notFound("Logo asset not found");
|
||||
if (nextLogoAsset.companyId !== existing.id) {
|
||||
throw unprocessable("Logo asset must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ ...companyPatch, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
if (logoAssetId === null) {
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
} else if (logoAssetId !== undefined) {
|
||||
await tx
|
||||
.insert(companyLogos)
|
||||
.values({
|
||||
companyId: id,
|
||||
assetId: logoAssetId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: companyLogos.companyId,
|
||||
set: {
|
||||
assetId: logoAssetId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (logoAssetId !== undefined && existing.logoAssetId && existing.logoAssetId !== logoAssetId) {
|
||||
await tx.delete(assets).where(eq(assets.id, existing.logoAssetId));
|
||||
}
|
||||
|
||||
const [hydrated] = await hydrateCompanySpend([{
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
}], tx);
|
||||
|
||||
return enrichCompany(hydrated);
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
const row = await getCompanyQuery(tx)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [hydrated] = await hydrateCompanySpend([row], tx);
|
||||
return enrichCompany(hydrated);
|
||||
}),
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
// Delete from child tables in dependency order
|
||||
await tx.delete(heartbeatRunEvents).where(eq(heartbeatRunEvents.companyId, id));
|
||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
|
||||
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
|
||||
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
|
||||
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
|
||||
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
|
||||
await tx.delete(approvals).where(eq(approvals.companyId, id));
|
||||
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
|
||||
await tx.delete(joinRequests).where(eq(joinRequests.companyId, id));
|
||||
await tx.delete(invites).where(eq(invites.companyId, id));
|
||||
await tx.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, id));
|
||||
await tx.delete(companyMemberships).where(eq(companyMemberships.companyId, id));
|
||||
await tx.delete(issues).where(eq(issues.companyId, id));
|
||||
await tx.delete(companyLogos).where(eq(companyLogos.companyId, id));
|
||||
await tx.delete(assets).where(eq(assets.companyId, id));
|
||||
await tx.delete(goals).where(eq(goals.companyId, id));
|
||||
await tx.delete(projects).where(eq(projects.companyId, id));
|
||||
await tx.delete(agents).where(eq(agents.companyId, id));
|
||||
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
|
||||
const rows = await tx
|
||||
.delete(companies)
|
||||
.where(eq(companies.id, id))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
}),
|
||||
|
||||
stats: () =>
|
||||
Promise.all([
|
||||
db
|
||||
.select({ companyId: agents.companyId, count: count() })
|
||||
.from(agents)
|
||||
.groupBy(agents.companyId),
|
||||
db
|
||||
.select({ companyId: issues.companyId, count: count() })
|
||||
.from(issues)
|
||||
.groupBy(issues.companyId),
|
||||
]).then(([agentRows, issueRows]) => {
|
||||
const result: Record<string, { agentCount: number; issueCount: number }> = {};
|
||||
for (const row of agentRows) {
|
||||
result[row.companyId] = { agentCount: row.count, issueCount: 0 };
|
||||
}
|
||||
for (const row of issueRows) {
|
||||
if (result[row.companyId]) {
|
||||
result[row.companyId].issueCount = row.count;
|
||||
} else {
|
||||
result[row.companyId] = { agentCount: 0, issueCount: row.count };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Generates README.md with Mermaid org chart for company exports.
|
||||
*/
|
||||
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ceo: "CEO",
|
||||
cto: "CTO",
|
||||
cmo: "CMO",
|
||||
cfo: "CFO",
|
||||
coo: "COO",
|
||||
vp: "VP",
|
||||
manager: "Manager",
|
||||
engineer: "Engineer",
|
||||
agent: "Agent",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a Mermaid flowchart (TD = top-down) representing the org chart.
|
||||
* Returns null if there are no agents.
|
||||
*/
|
||||
export function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null {
|
||||
if (agents.length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("```mermaid");
|
||||
lines.push("graph TD");
|
||||
|
||||
// Node definitions with role labels
|
||||
for (const agent of agents) {
|
||||
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
|
||||
const id = mermaidId(agent.slug);
|
||||
lines.push(` ${id}["${mermaidEscape(agent.name)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
|
||||
}
|
||||
|
||||
// Edges from parent to child
|
||||
const slugSet = new Set(agents.map((a) => a.slug));
|
||||
for (const agent of agents) {
|
||||
if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) {
|
||||
lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("```");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Sanitize slug for use as a Mermaid node ID (alphanumeric + underscore). */
|
||||
function mermaidId(slug: string): string {
|
||||
return slug.replace(/[^a-zA-Z0-9_]/g, "_");
|
||||
}
|
||||
|
||||
/** Escape text for Mermaid node labels. */
|
||||
function mermaidEscape(s: string): string {
|
||||
return s.replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Build a display label for a skill's source, linking to GitHub when available. */
|
||||
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
|
||||
if (skill.sourceLocator) {
|
||||
// For GitHub or URL sources, render as a markdown link
|
||||
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
|
||||
return `[${skill.sourceType}](${skill.sourceLocator})`;
|
||||
}
|
||||
return skill.sourceLocator;
|
||||
}
|
||||
if (skill.sourceType === "local") return "local";
|
||||
return skill.sourceType ?? "\u2014";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the README.md content for a company export.
|
||||
*/
|
||||
export function generateReadme(
|
||||
manifest: CompanyPortabilityManifest,
|
||||
options: {
|
||||
companyName: string;
|
||||
companyDescription: string | null;
|
||||
},
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# ${options.companyName}`);
|
||||
lines.push("");
|
||||
if (options.companyDescription) {
|
||||
lines.push(`> ${options.companyDescription}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Org chart image (generated during export as images/org-chart.png)
|
||||
if (manifest.agents.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// What's Inside table
|
||||
lines.push("## What's Inside");
|
||||
lines.push("");
|
||||
lines.push("> This is an [Agent Company](https://agentcompanies.io) package from [Paperclip](https://paperclip.ing)");
|
||||
lines.push("");
|
||||
|
||||
const counts: Array<[string, number]> = [];
|
||||
if (manifest.agents.length > 0) counts.push(["Agents", manifest.agents.length]);
|
||||
if (manifest.projects.length > 0) counts.push(["Projects", manifest.projects.length]);
|
||||
if (manifest.skills.length > 0) counts.push(["Skills", manifest.skills.length]);
|
||||
if (manifest.issues.length > 0) counts.push(["Tasks", manifest.issues.length]);
|
||||
|
||||
if (counts.length > 0) {
|
||||
lines.push("| Content | Count |");
|
||||
lines.push("|---------|-------|");
|
||||
for (const [label, count] of counts) {
|
||||
lines.push(`| ${label} | ${count} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Agents table
|
||||
if (manifest.agents.length > 0) {
|
||||
lines.push("### Agents");
|
||||
lines.push("");
|
||||
lines.push("| Agent | Role | Reports To |");
|
||||
lines.push("|-------|------|------------|");
|
||||
for (const agent of manifest.agents) {
|
||||
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
|
||||
const reportsTo = agent.reportsToSlug ?? "\u2014";
|
||||
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Projects list
|
||||
if (manifest.projects.length > 0) {
|
||||
lines.push("### Projects");
|
||||
lines.push("");
|
||||
for (const project of manifest.projects) {
|
||||
const desc = project.description ? ` \u2014 ${project.description}` : "";
|
||||
lines.push(`- **${project.name}**${desc}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Skills list
|
||||
if (manifest.skills.length > 0) {
|
||||
lines.push("### Skills");
|
||||
lines.push("");
|
||||
lines.push("| Skill | Description | Source |");
|
||||
lines.push("|-------|-------------|--------|");
|
||||
for (const skill of manifest.skills) {
|
||||
const desc = skill.description ?? "\u2014";
|
||||
const source = skillSourceLabel(skill);
|
||||
lines.push(`| ${skill.name} | ${desc} | ${source} |`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Getting Started
|
||||
lines.push("## Getting Started");
|
||||
lines.push("");
|
||||
lines.push("```bash");
|
||||
lines.push("pnpm paperclipai company import this-github-url-or-folder");
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
|
||||
lines.push("");
|
||||
|
||||
// Footer
|
||||
lines.push("---");
|
||||
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,364 @@
|
||||
import { and, desc, eq, gte, isNotNull, lt, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { budgetService, type BudgetServiceHooks } from "./budgets.js";
|
||||
|
||||
export interface CostDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
const METERED_BILLING_TYPE = "metered_api";
|
||||
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
|
||||
|
||||
function currentUtcMonthWindow(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth();
|
||||
return {
|
||||
start: new Date(Date.UTC(year, month, 1, 0, 0, 0, 0)),
|
||||
end: new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMonthlySpendTotal(
|
||||
db: Db,
|
||||
scope: { companyId: string; agentId?: string | null },
|
||||
) {
|
||||
const { start, end } = currentUtcMonthWindow();
|
||||
const conditions = [
|
||||
eq(costEvents.companyId, scope.companyId),
|
||||
gte(costEvents.occurredAt, start),
|
||||
lt(costEvents.occurredAt, end),
|
||||
];
|
||||
if (scope.agentId) {
|
||||
conditions.push(eq(costEvents.agentId, scope.agentId));
|
||||
}
|
||||
const [row] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
const budgets = budgetService(db, budgetHooks);
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
|
||||
const agent = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, data.agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!agent) throw notFound("Agent not found");
|
||||
if (agent.companyId !== companyId) {
|
||||
throw unprocessable("Agent does not belong to company");
|
||||
}
|
||||
|
||||
const event = await db
|
||||
.insert(costEvents)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
biller: data.biller ?? data.provider,
|
||||
billingType: data.billingType ?? "unknown",
|
||||
cachedInputTokens: data.cachedInputTokens ?? 0,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const [agentMonthSpend, companyMonthSpend] = await Promise.all([
|
||||
getMonthlySpendTotal(db, { companyId, agentId: event.agentId }),
|
||||
getMonthlySpendTotal(db, { companyId }),
|
||||
]);
|
||||
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
spentMonthlyCents: agentMonthSpend,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, event.agentId));
|
||||
|
||||
await db
|
||||
.update(companies)
|
||||
.set({
|
||||
spentMonthlyCents: companyMonthSpend,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companies.id, companyId));
|
||||
|
||||
await budgets.evaluateCostEvent(event);
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
summary: async (companyId: string, range?: CostDateRange) => {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const [{ total }] = await db
|
||||
.select({
|
||||
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
const spendCents = Number(total);
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (spendCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
companyId,
|
||||
spendCents,
|
||||
budgetCents: company.budgetMonthlyCents,
|
||||
utilizationPercent: Number(utilization.toFixed(2)),
|
||||
};
|
||||
},
|
||||
|
||||
byAgent: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
agentStatus: agents.status,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, agents.status)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
byBiller: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
.select({
|
||||
biller: costEvents.biller,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
apiRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionRunCount:
|
||||
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
|
||||
subscriptionCachedInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
|
||||
subscriptionInputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
|
||||
subscriptionOutputTokens:
|
||||
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
|
||||
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
|
||||
modelCount: sql<number>`count(distinct ${costEvents.model})::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.biller)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
},
|
||||
|
||||
/**
|
||||
* aggregates cost_events by provider for each of three rolling windows:
|
||||
* last 5 hours, last 24 hours, last 7 days.
|
||||
* purely internal consumption data, no external rate-limit sources.
|
||||
*/
|
||||
windowSpend: async (companyId: string) => {
|
||||
const windows = [
|
||||
{ label: "5h", hours: 5 },
|
||||
{ label: "24h", hours: 24 },
|
||||
{ label: "7d", hours: 168 },
|
||||
] as const;
|
||||
|
||||
const results = await Promise.all(
|
||||
windows.map(async ({ label, hours }) => {
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const rows = await db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, since),
|
||||
),
|
||||
)
|
||||
.groupBy(costEvents.provider)
|
||||
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
|
||||
|
||||
return rows.map((row) => ({
|
||||
provider: row.provider,
|
||||
biller: row.biller,
|
||||
window: label as string,
|
||||
windowHours: hours,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
},
|
||||
|
||||
byAgentModel: async (companyId: string, range?: CostDateRange) => {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
// single query: group by agent + provider + model.
|
||||
// the (companyId, agentId, occurredAt) composite index covers this well.
|
||||
// order by provider + model for stable db-level ordering; cost-desc sort
|
||||
// within each agent's sub-rows is done client-side in the ui memo.
|
||||
return db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
model: costEvents.model,
|
||||
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(
|
||||
costEvents.agentId,
|
||||
agents.name,
|
||||
costEvents.provider,
|
||||
costEvents.biller,
|
||||
costEvents.billingType,
|
||||
costEvents.model,
|
||||
)
|
||||
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
|
||||
},
|
||||
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
const issueIdAsText = sql<string>`${issues.id}::text`;
|
||||
const runProjectLinks = db
|
||||
.selectDistinctOn([activityLog.runId, issues.projectId], {
|
||||
runId: activityLog.runId,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(activityLog)
|
||||
.innerJoin(
|
||||
issues,
|
||||
and(
|
||||
eq(activityLog.entityType, "issue"),
|
||||
eq(activityLog.entityId, issueIdAsText),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(issues.companyId, companyId),
|
||||
isNotNull(activityLog.runId),
|
||||
isNotNull(issues.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
|
||||
.as("run_project_links");
|
||||
|
||||
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
const costCentsExpr = sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`;
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectId: effectiveProjectId,
|
||||
projectName: projects.name,
|
||||
costCents: costCentsExpr,
|
||||
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
|
||||
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
|
||||
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
|
||||
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
|
||||
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
|
||||
.groupBy(effectiveProjectId, projects.name)
|
||||
.orderBy(desc(costCentsExpr));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Lightweight cron expression parser and next-run calculator.
|
||||
*
|
||||
* Supports standard 5-field cron expressions:
|
||||
*
|
||||
* ┌────────────── minute (0–59)
|
||||
* │ ┌──────────── hour (0–23)
|
||||
* │ │ ┌────────── day of month (1–31)
|
||||
* │ │ │ ┌──────── month (1–12)
|
||||
* │ │ │ │ ┌────── day of week (0–6, Sun=0)
|
||||
* │ │ │ │ │
|
||||
* * * * * *
|
||||
*
|
||||
* Supported syntax per field:
|
||||
* - `*` — any value
|
||||
* - `N` — exact value
|
||||
* - `N-M` — range (inclusive)
|
||||
* - `N/S` — start at N, step S (within field bounds)
|
||||
* - `* /S` — every S (from field min) [no space — shown to avoid comment termination]
|
||||
* - `N-M/S` — range with step
|
||||
* - `N,M,...` — list of values, ranges, or steps
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A parsed cron schedule. Each field is a sorted array of valid integer values
|
||||
* for that field.
|
||||
*/
|
||||
export interface ParsedCron {
|
||||
minutes: number[];
|
||||
hours: number[];
|
||||
daysOfMonth: number[];
|
||||
months: number[];
|
||||
daysOfWeek: number[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field bounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldSpec {
|
||||
min: number;
|
||||
max: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FIELD_SPECS: FieldSpec[] = [
|
||||
{ min: 0, max: 59, name: "minute" },
|
||||
{ min: 0, max: 23, name: "hour" },
|
||||
{ min: 1, max: 31, name: "day of month" },
|
||||
{ min: 1, max: 12, name: "month" },
|
||||
{ min: 0, max: 6, name: "day of week" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a single cron field token (e.g. `"5"`, `"1-3"`, `"* /10"`, `"1,3,5"`).
|
||||
*
|
||||
* @returns Sorted deduplicated array of matching integer values within bounds.
|
||||
* @throws {Error} on invalid syntax or out-of-range values.
|
||||
*/
|
||||
function parseField(token: string, spec: FieldSpec): number[] {
|
||||
const values = new Set<number>();
|
||||
|
||||
// Split on commas first — each part can be a value, range, or step
|
||||
const parts = token.split(",");
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === "") {
|
||||
throw new Error(`Empty element in cron ${spec.name} field`);
|
||||
}
|
||||
|
||||
// Check for step syntax: "X/S" where X is "*" or a range or a number
|
||||
const slashIdx = trimmed.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
const base = trimmed.slice(0, slashIdx);
|
||||
const stepStr = trimmed.slice(slashIdx + 1);
|
||||
const step = parseInt(stepStr, 10);
|
||||
if (isNaN(step) || step <= 0) {
|
||||
throw new Error(
|
||||
`Invalid step "${stepStr}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
|
||||
let rangeStart = spec.min;
|
||||
let rangeEnd = spec.max;
|
||||
|
||||
if (base === "*") {
|
||||
// */S — every S from field min
|
||||
} else if (base.includes("-")) {
|
||||
// N-M/S — range with step
|
||||
const [a, b] = base.split("-").map((s) => parseInt(s, 10));
|
||||
if (isNaN(a!) || isNaN(b!)) {
|
||||
throw new Error(
|
||||
`Invalid range "${base}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
rangeStart = a!;
|
||||
rangeEnd = b!;
|
||||
} else {
|
||||
// N/S — start at N, step S
|
||||
const start = parseInt(base, 10);
|
||||
if (isNaN(start)) {
|
||||
throw new Error(
|
||||
`Invalid start "${base}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
rangeStart = start;
|
||||
}
|
||||
|
||||
validateBounds(rangeStart, spec);
|
||||
validateBounds(rangeEnd, spec);
|
||||
|
||||
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for range syntax: "N-M"
|
||||
if (trimmed.includes("-")) {
|
||||
const [aStr, bStr] = trimmed.split("-");
|
||||
const a = parseInt(aStr!, 10);
|
||||
const b = parseInt(bStr!, 10);
|
||||
if (isNaN(a) || isNaN(b)) {
|
||||
throw new Error(
|
||||
`Invalid range "${trimmed}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
validateBounds(a, spec);
|
||||
validateBounds(b, spec);
|
||||
if (a > b) {
|
||||
throw new Error(
|
||||
`Invalid range ${a}-${b} in cron ${spec.name} field (start > end)`,
|
||||
);
|
||||
}
|
||||
for (let i = a; i <= b; i++) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wildcard
|
||||
if (trimmed === "*") {
|
||||
for (let i = spec.min; i <= spec.max; i++) {
|
||||
values.add(i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single value
|
||||
const val = parseInt(trimmed, 10);
|
||||
if (isNaN(val)) {
|
||||
throw new Error(
|
||||
`Invalid value "${trimmed}" in cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
validateBounds(val, spec);
|
||||
values.add(val);
|
||||
}
|
||||
|
||||
if (values.size === 0) {
|
||||
throw new Error(`Empty result for cron ${spec.name} field`);
|
||||
}
|
||||
|
||||
return [...values].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function validateBounds(value: number, spec: FieldSpec): void {
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw new Error(
|
||||
`Value ${value} out of range [${spec.min}–${spec.max}] for cron ${spec.name} field`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a cron expression string into a structured {@link ParsedCron}.
|
||||
*
|
||||
* @param expression — A standard 5-field cron expression.
|
||||
* @returns Parsed cron with sorted valid values for each field.
|
||||
* @throws {Error} on invalid syntax.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const parsed = parseCron("0 * * * *"); // every hour at minute 0
|
||||
* // parsed.minutes === [0]
|
||||
* // parsed.hours === [0,1,2,...,23]
|
||||
* ```
|
||||
*/
|
||||
export function parseCron(expression: string): ParsedCron {
|
||||
const trimmed = expression.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Cron expression must not be empty");
|
||||
}
|
||||
|
||||
const tokens = trimmed.split(/\s+/);
|
||||
if (tokens.length !== 5) {
|
||||
throw new Error(
|
||||
`Cron expression must have exactly 5 fields, got ${tokens.length}: "${trimmed}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
minutes: parseField(tokens[0]!, FIELD_SPECS[0]!),
|
||||
hours: parseField(tokens[1]!, FIELD_SPECS[1]!),
|
||||
daysOfMonth: parseField(tokens[2]!, FIELD_SPECS[2]!),
|
||||
months: parseField(tokens[3]!, FIELD_SPECS[3]!),
|
||||
daysOfWeek: parseField(tokens[4]!, FIELD_SPECS[4]!),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression string. Returns `null` if valid, or an error
|
||||
* message string if invalid.
|
||||
*
|
||||
* @param expression — A cron expression string to validate.
|
||||
* @returns `null` on success, error message on failure.
|
||||
*/
|
||||
export function validateCron(expression: string): string | null {
|
||||
try {
|
||||
parseCron(expression);
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next run time after `after` for the given parsed cron schedule.
|
||||
*
|
||||
* Starts from the minute immediately following `after` and walks forward
|
||||
* until a matching minute is found (up to a safety limit of ~4 years to
|
||||
* prevent infinite loops on impossible schedules).
|
||||
*
|
||||
* @param cron — Parsed cron schedule.
|
||||
* @param after — The reference date. The returned date will be strictly after this.
|
||||
* @returns The next matching `Date`, or `null` if no match found within the search window.
|
||||
*/
|
||||
export function nextCronTick(cron: ParsedCron, after: Date): Date | null {
|
||||
// Work in local minutes — start from the minute after `after`
|
||||
const d = new Date(after.getTime());
|
||||
// Advance to the next whole minute
|
||||
d.setUTCSeconds(0, 0);
|
||||
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
||||
|
||||
// Safety: search up to 4 years worth of minutes (~2.1M iterations max).
|
||||
// Uses 366 to account for leap years.
|
||||
const MAX_CRON_SEARCH_YEARS = 4;
|
||||
const maxIterations = MAX_CRON_SEARCH_YEARS * 366 * 24 * 60;
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const month = d.getUTCMonth() + 1; // 1-12
|
||||
const dayOfMonth = d.getUTCDate(); // 1-31
|
||||
const dayOfWeek = d.getUTCDay(); // 0-6
|
||||
const hour = d.getUTCHours(); // 0-23
|
||||
const minute = d.getUTCMinutes(); // 0-59
|
||||
|
||||
// Check month
|
||||
if (!cron.months.includes(month)) {
|
||||
// Skip to the first day of the next matching month
|
||||
advanceToNextMonth(d, cron.months);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check day of month AND day of week (both must match)
|
||||
if (!cron.daysOfMonth.includes(dayOfMonth) || !cron.daysOfWeek.includes(dayOfWeek)) {
|
||||
// Advance one day
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check hour
|
||||
if (!cron.hours.includes(hour)) {
|
||||
// Advance to next matching hour within the day
|
||||
const nextHour = findNext(cron.hours, hour);
|
||||
if (nextHour !== null) {
|
||||
d.setUTCHours(nextHour, 0, 0, 0);
|
||||
} else {
|
||||
// No matching hour left today — advance to next day
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check minute
|
||||
if (!cron.minutes.includes(minute)) {
|
||||
const nextMin = findNext(cron.minutes, minute);
|
||||
if (nextMin !== null) {
|
||||
d.setUTCMinutes(nextMin, 0, 0);
|
||||
} else {
|
||||
// No matching minute left this hour — advance to next hour
|
||||
d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// All fields match!
|
||||
return new Date(d.getTime());
|
||||
}
|
||||
|
||||
// No match found within the search window
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: parse a cron expression and compute the next run time.
|
||||
*
|
||||
* @param expression — 5-field cron expression string.
|
||||
* @param after — Reference date (defaults to `new Date()`).
|
||||
* @returns The next matching Date, or `null` if no match within 4 years.
|
||||
* @throws {Error} if the cron expression is invalid.
|
||||
*/
|
||||
export function nextCronTickFromExpression(
|
||||
expression: string,
|
||||
after: Date = new Date(),
|
||||
): Date | null {
|
||||
const cron = parseCron(expression);
|
||||
return nextCronTick(cron, after);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the next value in `sortedValues` that is greater than `current`.
|
||||
* Returns `null` if no such value exists.
|
||||
*/
|
||||
function findNext(sortedValues: number[], current: number): number | null {
|
||||
for (const v of sortedValues) {
|
||||
if (v > current) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance `d` (mutated in place) to midnight UTC of the first day of the next
|
||||
* month whose 1-based month number is in `months`.
|
||||
*/
|
||||
function advanceToNextMonth(d: Date, months: number[]): void {
|
||||
let year = d.getUTCFullYear();
|
||||
let month = d.getUTCMonth() + 1; // 1-based
|
||||
|
||||
// Walk months forward until we find one in the set (max 48 iterations = 4 years)
|
||||
for (let i = 0; i < 48; i++) {
|
||||
month++;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year++;
|
||||
}
|
||||
if (months.includes(month)) {
|
||||
d.setUTCFullYear(year, month - 1, 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { and, eq, gte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, approvals, companies, costEvents, issues } from "@paperclipai/db";
|
||||
import { notFound } from "../errors.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
|
||||
export function dashboardService(db: Db) {
|
||||
const budgets = budgetService(db);
|
||||
return {
|
||||
summary: async (companyId: string) => {
|
||||
const company = await db
|
||||
.select()
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!company) throw notFound("Company not found");
|
||||
|
||||
const agentRows = await db
|
||||
.select({ status: agents.status, count: sql<number>`count(*)` })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId))
|
||||
.groupBy(agents.status);
|
||||
|
||||
const taskRows = await db
|
||||
.select({ status: issues.status, count: sql<number>`count(*)` })
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId))
|
||||
.groupBy(issues.status);
|
||||
|
||||
const pendingApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(approvals)
|
||||
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const agentCounts: Record<string, number> = {
|
||||
active: 0,
|
||||
running: 0,
|
||||
paused: 0,
|
||||
error: 0,
|
||||
};
|
||||
for (const row of agentRows) {
|
||||
const count = Number(row.count);
|
||||
// "idle" agents are operational — count them as active
|
||||
const bucket = row.status === "idle" ? "active" : row.status;
|
||||
agentCounts[bucket] = (agentCounts[bucket] ?? 0) + count;
|
||||
}
|
||||
|
||||
const taskCounts: Record<string, number> = {
|
||||
open: 0,
|
||||
inProgress: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
};
|
||||
for (const row of taskRows) {
|
||||
const count = Number(row.count);
|
||||
if (row.status === "in_progress") taskCounts.inProgress += count;
|
||||
if (row.status === "blocked") taskCounts.blocked += count;
|
||||
if (row.status === "done") taskCounts.done += count;
|
||||
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const [{ monthSpend }] = await db
|
||||
.select({
|
||||
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, monthStart),
|
||||
),
|
||||
);
|
||||
|
||||
const monthSpendCents = Number(monthSpend);
|
||||
const utilization =
|
||||
company.budgetMonthlyCents > 0
|
||||
? (monthSpendCents / company.budgetMonthlyCents) * 100
|
||||
: 0;
|
||||
const budgetOverview = await budgets.overview(companyId);
|
||||
|
||||
return {
|
||||
companyId,
|
||||
agents: {
|
||||
active: agentCounts.active,
|
||||
running: agentCounts.running,
|
||||
paused: agentCounts.paused,
|
||||
error: agentCounts.error,
|
||||
},
|
||||
tasks: taskCounts,
|
||||
costs: {
|
||||
monthSpendCents,
|
||||
monthBudgetCents: company.budgetMonthlyCents,
|
||||
monthUtilizationPercent: Number(utilization.toFixed(2)),
|
||||
},
|
||||
pendingApprovals,
|
||||
budgets: {
|
||||
activeIncidents: budgetOverview.activeIncidents.length,
|
||||
pendingApprovals: budgetOverview.pendingApprovalCount,
|
||||
pausedAgents: budgetOverview.pausedAgentCount,
|
||||
pausedProjects: budgetOverview.pausedProjectCount,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const DEFAULT_AGENT_BUNDLE_FILES = {
|
||||
default: ["AGENTS.md"],
|
||||
ceo: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"],
|
||||
} as const;
|
||||
|
||||
type DefaultAgentBundleRole = keyof typeof DEFAULT_AGENT_BUNDLE_FILES;
|
||||
|
||||
function resolveDefaultAgentBundleUrl(role: DefaultAgentBundleRole, fileName: string) {
|
||||
return new URL(`../onboarding-assets/${role}/${fileName}`, import.meta.url);
|
||||
}
|
||||
|
||||
export async function loadDefaultAgentInstructionsBundle(role: DefaultAgentBundleRole): Promise<Record<string, string>> {
|
||||
const fileNames = DEFAULT_AGENT_BUNDLE_FILES[role];
|
||||
const entries = await Promise.all(
|
||||
fileNames.map(async (fileName) => {
|
||||
const content = await fs.readFile(resolveDefaultAgentBundleUrl(role, fileName), "utf8");
|
||||
return [fileName, content] as const;
|
||||
}),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentInstructionsBundleRole(role: string): DefaultAgentBundleRole {
|
||||
return role === "ceo" ? "ceo" : "default";
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { and, asc, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { issueDocumentKeySchema } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
function normalizeDocumentKey(key: string) {
|
||||
const normalized = key.trim().toLowerCase();
|
||||
const parsed = issueDocumentKeySchema.safeParse(normalized);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable("Invalid document key", parsed.error.issues);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505";
|
||||
}
|
||||
|
||||
export function extractLegacyPlanBody(description: string | null | undefined) {
|
||||
if (!description) return null;
|
||||
const match = /<plan>\s*([\s\S]*?)\s*<\/plan>/i.exec(description);
|
||||
if (!match) return null;
|
||||
const body = match[1]?.trim();
|
||||
return body ? body : null;
|
||||
}
|
||||
|
||||
function mapIssueDocumentRow(
|
||||
row: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
includeBody: boolean,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
issueId: row.issueId,
|
||||
key: row.key,
|
||||
title: row.title,
|
||||
format: row.format,
|
||||
...(includeBody ? { body: row.latestBody } : {}),
|
||||
latestRevisionId: row.latestRevisionId ?? null,
|
||||
latestRevisionNumber: row.latestRevisionNumber,
|
||||
createdByAgentId: row.createdByAgentId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
updatedByAgentId: row.updatedByAgentId,
|
||||
updatedByUserId: row.updatedByUserId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function documentService(db: Db) {
|
||||
return {
|
||||
getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => {
|
||||
const [planDocument, documentSummaries] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issue.id))
|
||||
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt)),
|
||||
]);
|
||||
|
||||
const legacyPlanBody = planDocument ? null : extractLegacyPlanBody(issue.description);
|
||||
|
||||
return {
|
||||
planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null,
|
||||
documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)),
|
||||
legacyPlanDocument: legacyPlanBody
|
||||
? {
|
||||
key: "plan" as const,
|
||||
body: legacyPlanBody,
|
||||
source: "issue_description" as const,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
|
||||
listIssueDocuments: async (issueId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(eq(issueDocuments.issueId, issueId))
|
||||
.orderBy(asc(issueDocuments.key), desc(documents.updatedAt));
|
||||
return rows.map((row) => mapIssueDocumentRow(row, true));
|
||||
},
|
||||
|
||||
getIssueDocumentByKey: async (issueId: string, rawKey: string) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
const row = await db
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? mapIssueDocumentRow(row, true) : null;
|
||||
},
|
||||
|
||||
listIssueDocumentRevisions: async (issueId: string, rawKey: string) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
return db
|
||||
.select({
|
||||
id: documentRevisions.id,
|
||||
companyId: documentRevisions.companyId,
|
||||
documentId: documentRevisions.documentId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
changeSummary: documentRevisions.changeSummary,
|
||||
createdByAgentId: documentRevisions.createdByAgentId,
|
||||
createdByUserId: documentRevisions.createdByUserId,
|
||||
createdAt: documentRevisions.createdAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.innerJoin(documentRevisions, eq(documentRevisions.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
.orderBy(desc(documentRevisions.revisionNumber));
|
||||
},
|
||||
|
||||
upsertIssueDocument: async (input: {
|
||||
issueId: string;
|
||||
key: string;
|
||||
title?: string | null;
|
||||
format: string;
|
||||
body: string;
|
||||
changeSummary?: string | null;
|
||||
baseRevisionId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
}) => {
|
||||
const key = normalizeDocumentKey(input.key);
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
if (!input.baseRevisionId) {
|
||||
throw conflict("Document update requires baseRevisionId", {
|
||||
currentRevisionId: existing.latestRevisionId,
|
||||
});
|
||||
}
|
||||
if (input.baseRevisionId !== existing.latestRevisionId) {
|
||||
throw conflict("Document was updated by someone else", {
|
||||
currentRevisionId: existing.latestRevisionId,
|
||||
});
|
||||
}
|
||||
|
||||
const nextRevisionNumber = existing.latestRevisionNumber + 1;
|
||||
const [revision] = await tx
|
||||
.insert(documentRevisions)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
documentId: existing.id,
|
||||
revisionNumber: nextRevisionNumber,
|
||||
body: input.body,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({
|
||||
title: input.title ?? null,
|
||||
format: input.format,
|
||||
latestBody: input.body,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documents.id, existing.id));
|
||||
|
||||
await tx
|
||||
.update(issueDocuments)
|
||||
.set({ updatedAt: now })
|
||||
.where(eq(issueDocuments.documentId, existing.id));
|
||||
|
||||
return {
|
||||
created: false as const,
|
||||
document: {
|
||||
...existing,
|
||||
title: input.title ?? null,
|
||||
format: input.format,
|
||||
body: input.body,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (input.baseRevisionId) {
|
||||
throw conflict("Document does not exist yet", { key });
|
||||
}
|
||||
|
||||
const [document] = await tx
|
||||
.insert(documents)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
title: input.title ?? null,
|
||||
format: input.format,
|
||||
latestBody: input.body,
|
||||
latestRevisionId: null,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [revision] = await tx
|
||||
.insert(documentRevisions)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
documentId: document.id,
|
||||
revisionNumber: 1,
|
||||
body: input.body,
|
||||
changeSummary: input.changeSummary ?? null,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({ latestRevisionId: revision.id })
|
||||
.where(eq(documents.id, document.id));
|
||||
|
||||
await tx.insert(issueDocuments).values({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
documentId: document.id,
|
||||
key,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
created: true as const,
|
||||
document: {
|
||||
id: document.id,
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
key,
|
||||
title: document.title,
|
||||
format: document.format,
|
||||
body: document.latestBody,
|
||||
latestRevisionId: revision.id,
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: document.createdByAgentId,
|
||||
createdByUserId: document.createdByUserId,
|
||||
updatedByAgentId: document.updatedByAgentId,
|
||||
updatedByUserId: document.updatedByUserId,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw conflict("Document key already exists on this issue", { key });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteIssueDocument: async (issueId: string, rawKey: string) => {
|
||||
const key = normalizeDocumentKey(rawKey);
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: documents.id,
|
||||
companyId: documents.companyId,
|
||||
issueId: issueDocuments.issueId,
|
||||
key: issueDocuments.key,
|
||||
title: documents.title,
|
||||
format: documents.format,
|
||||
latestBody: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
createdByAgentId: documents.createdByAgentId,
|
||||
createdByUserId: documents.createdByUserId,
|
||||
updatedByAgentId: documents.updatedByAgentId,
|
||||
updatedByUserId: documents.updatedByUserId,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id));
|
||||
await tx.delete(documents).where(eq(documents.id, existing.id));
|
||||
|
||||
return {
|
||||
...existing,
|
||||
body: existing.latestBody,
|
||||
latestRevisionId: existing.latestRevisionId ?? null,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
ExecutionWorkspaceMode,
|
||||
ExecutionWorkspaceStrategy,
|
||||
IssueExecutionWorkspaceSettings,
|
||||
ProjectExecutionWorkspaceDefaultMode,
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { asString, parseObject } from "../adapters/utils.js";
|
||||
|
||||
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
|
||||
|
||||
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
|
||||
const parsed = parseObject(raw);
|
||||
const type = asString(parsed.type, "");
|
||||
if (type !== "project_primary" && type !== "git_worktree" && type !== "adapter_managed" && type !== "cloud_sandbox") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type,
|
||||
...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
|
||||
...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
|
||||
...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
|
||||
...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
|
||||
...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecutionWorkspacePolicy | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
|
||||
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
|
||||
const defaultMode = asString(parsed.defaultMode, "");
|
||||
const defaultProjectWorkspaceId =
|
||||
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
||||
const allowIssueOverride =
|
||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||
const normalizedDefaultMode = (() => {
|
||||
if (
|
||||
defaultMode === "shared_workspace" ||
|
||||
defaultMode === "isolated_workspace" ||
|
||||
defaultMode === "operator_branch" ||
|
||||
defaultMode === "adapter_default"
|
||||
) {
|
||||
return defaultMode as ProjectExecutionWorkspaceDefaultMode;
|
||||
}
|
||||
if (defaultMode === "project_primary") return "shared_workspace";
|
||||
if (defaultMode === "isolated") return "isolated_workspace";
|
||||
return undefined;
|
||||
})();
|
||||
return {
|
||||
enabled,
|
||||
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.branchPolicy && typeof parsed.branchPolicy === "object" && !Array.isArray(parsed.branchPolicy)
|
||||
? { branchPolicy: { ...(parsed.branchPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
|
||||
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.runtimePolicy && typeof parsed.runtimePolicy === "object" && !Array.isArray(parsed.runtimePolicy)
|
||||
? { runtimePolicy: { ...(parsed.runtimePolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
|
||||
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function gateProjectExecutionWorkspacePolicy(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
isolatedWorkspacesEnabled: boolean,
|
||||
): ProjectExecutionWorkspacePolicy | null {
|
||||
if (!isolatedWorkspacesEnabled) return null;
|
||||
return projectPolicy;
|
||||
}
|
||||
|
||||
export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecutionWorkspaceSettings | null {
|
||||
const parsed = parseObject(raw);
|
||||
if (Object.keys(parsed).length === 0) return null;
|
||||
const workspaceStrategy = parseExecutionWorkspaceStrategy(parsed.workspaceStrategy);
|
||||
const mode = asString(parsed.mode, "");
|
||||
const normalizedMode = (() => {
|
||||
if (
|
||||
mode === "inherit" ||
|
||||
mode === "shared_workspace" ||
|
||||
mode === "isolated_workspace" ||
|
||||
mode === "operator_branch" ||
|
||||
mode === "reuse_existing" ||
|
||||
mode === "agent_default"
|
||||
) {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "project_primary") return "shared_workspace";
|
||||
if (mode === "isolated") return "isolated_workspace";
|
||||
return "";
|
||||
})();
|
||||
return {
|
||||
...(normalizedMode
|
||||
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
||||
: {}),
|
||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||
): IssueExecutionWorkspaceSettings | null {
|
||||
if (!projectPolicy?.enabled) return null;
|
||||
return {
|
||||
mode:
|
||||
projectPolicy.defaultMode === "isolated_workspace"
|
||||
? "isolated_workspace"
|
||||
: projectPolicy.defaultMode === "operator_branch"
|
||||
? "operator_branch"
|
||||
: projectPolicy.defaultMode === "adapter_default"
|
||||
? "agent_default"
|
||||
: "shared_workspace",
|
||||
};
|
||||
}
|
||||
|
||||
export function issueExecutionWorkspaceModeForPersistedWorkspace(
|
||||
mode: string | null | undefined,
|
||||
): IssueExecutionWorkspaceSettings["mode"] {
|
||||
if (mode === null || mode === undefined) {
|
||||
return "agent_default";
|
||||
}
|
||||
if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "adapter_managed" || mode === "cloud_sandbox") {
|
||||
return "agent_default";
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
export function resolveExecutionWorkspaceMode(input: {
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
legacyUseProjectWorkspace: boolean | null;
|
||||
}): ParsedExecutionWorkspaceMode {
|
||||
const issueMode = input.issueSettings?.mode;
|
||||
if (issueMode && issueMode !== "inherit" && issueMode !== "reuse_existing") {
|
||||
return issueMode;
|
||||
}
|
||||
if (input.projectPolicy?.enabled) {
|
||||
if (input.projectPolicy.defaultMode === "isolated_workspace") return "isolated_workspace";
|
||||
if (input.projectPolicy.defaultMode === "operator_branch") return "operator_branch";
|
||||
if (input.projectPolicy.defaultMode === "adapter_default") return "agent_default";
|
||||
return "shared_workspace";
|
||||
}
|
||||
if (input.legacyUseProjectWorkspace === false) {
|
||||
return "agent_default";
|
||||
}
|
||||
return "shared_workspace";
|
||||
}
|
||||
|
||||
export function buildExecutionWorkspaceAdapterConfig(input: {
|
||||
agentConfig: Record<string, unknown>;
|
||||
projectPolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
issueSettings: IssueExecutionWorkspaceSettings | null;
|
||||
mode: ParsedExecutionWorkspaceMode;
|
||||
legacyUseProjectWorkspace: boolean | null;
|
||||
}): Record<string, unknown> {
|
||||
const nextConfig = { ...input.agentConfig };
|
||||
const projectHasPolicy = Boolean(input.projectPolicy?.enabled);
|
||||
const issueHasWorkspaceOverrides = Boolean(
|
||||
input.issueSettings?.mode ||
|
||||
input.issueSettings?.workspaceStrategy ||
|
||||
input.issueSettings?.workspaceRuntime,
|
||||
);
|
||||
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
|
||||
|
||||
if (hasWorkspaceControl) {
|
||||
if (input.mode === "isolated_workspace") {
|
||||
const strategy =
|
||||
input.issueSettings?.workspaceStrategy ??
|
||||
input.projectPolicy?.workspaceStrategy ??
|
||||
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
|
||||
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
|
||||
nextConfig.workspaceStrategy = strategy as unknown as Record<string, unknown>;
|
||||
} else {
|
||||
delete nextConfig.workspaceStrategy;
|
||||
}
|
||||
|
||||
if (input.mode === "agent_default") {
|
||||
delete nextConfig.workspaceRuntime;
|
||||
} else if (input.issueSettings?.workspaceRuntime) {
|
||||
nextConfig.workspaceRuntime = cloneRecord(input.issueSettings.workspaceRuntime) ?? undefined;
|
||||
} else if (input.projectPolicy?.workspaceRuntime) {
|
||||
nextConfig.workspaceRuntime = cloneRecord(input.projectPolicy.workspaceRuntime) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return nextConfig;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
|
||||
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
sourceIssueId: row.sourceIssueId ?? null,
|
||||
mode: row.mode as ExecutionWorkspace["mode"],
|
||||
strategyType: row.strategyType as ExecutionWorkspace["strategyType"],
|
||||
name: row.name,
|
||||
status: row.status as ExecutionWorkspace["status"],
|
||||
cwd: row.cwd ?? null,
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
baseRef: row.baseRef ?? null,
|
||||
branchName: row.branchName ?? null,
|
||||
providerType: row.providerType as ExecutionWorkspace["providerType"],
|
||||
providerRef: row.providerRef ?? null,
|
||||
derivedFromExecutionWorkspaceId: row.derivedFromExecutionWorkspaceId ?? null,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
openedAt: row.openedAt,
|
||||
closedAt: row.closedAt ?? null,
|
||||
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
|
||||
cleanupReason: row.cleanupReason ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function executionWorkspaceService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: {
|
||||
projectId?: string;
|
||||
projectWorkspaceId?: string;
|
||||
issueId?: string;
|
||||
status?: string;
|
||||
reuseEligible?: boolean;
|
||||
}) => {
|
||||
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
||||
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
|
||||
if (filters?.projectWorkspaceId) {
|
||||
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
|
||||
}
|
||||
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
|
||||
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
|
||||
}
|
||||
if (filters?.reuseEligible) {
|
||||
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map(toExecutionWorkspace);
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
create: async (data: typeof executionWorkspaces.$inferInsert) => {
|
||||
const row = await db
|
||||
.insert(executionWorkspaces)
|
||||
.values(data)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => {
|
||||
const row = await db
|
||||
.update(executionWorkspaces)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toExecutionWorkspace };
|
||||
@@ -0,0 +1,134 @@
|
||||
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export interface FinanceDateRange {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
async function assertBelongsToCompany(
|
||||
db: Db,
|
||||
table: any,
|
||||
id: string,
|
||||
companyId: string,
|
||||
label: string,
|
||||
) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) throw notFound(`${label} not found`);
|
||||
if ((row as unknown as { companyId: string }).companyId !== companyId) {
|
||||
throw unprocessable(`${label} does not belong to company`);
|
||||
}
|
||||
}
|
||||
|
||||
function rangeConditions(companyId: string, range?: FinanceDateRange) {
|
||||
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
|
||||
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
|
||||
return conditions;
|
||||
}
|
||||
|
||||
export function financeService(db: Db) {
|
||||
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`;
|
||||
|
||||
return {
|
||||
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
|
||||
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
|
||||
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
|
||||
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
|
||||
if (data.goalId) await assertBelongsToCompany(db, goals, data.goalId, companyId, "Goal");
|
||||
if (data.heartbeatRunId) await assertBelongsToCompany(db, heartbeatRuns, data.heartbeatRunId, companyId, "Heartbeat run");
|
||||
if (data.costEventId) await assertBelongsToCompany(db, costEvents, data.costEventId, companyId, "Cost event");
|
||||
|
||||
const event = await db
|
||||
.insert(financeEvents)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
currency: data.currency ?? "USD",
|
||||
direction: data.direction ?? "debit",
|
||||
estimated: data.estimated ?? false,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return event;
|
||||
},
|
||||
|
||||
summary: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
const [row] = await db
|
||||
.select({
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions));
|
||||
|
||||
return {
|
||||
companyId,
|
||||
debitCents: Number(row?.debitCents ?? 0),
|
||||
creditCents: Number(row?.creditCents ?? 0),
|
||||
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
|
||||
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
|
||||
eventCount: Number(row?.eventCount ?? 0),
|
||||
};
|
||||
},
|
||||
|
||||
byBiller: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select({
|
||||
biller: financeEvents.biller,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.biller)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller);
|
||||
},
|
||||
|
||||
byKind: async (companyId: string, range?: FinanceDateRange) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select({
|
||||
eventKind: financeEvents.eventKind,
|
||||
debitCents: debitExpr,
|
||||
creditCents: creditExpr,
|
||||
estimatedDebitCents: estimatedDebitExpr,
|
||||
eventCount: sql<number>`count(*)::int`,
|
||||
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
|
||||
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
|
||||
})
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.groupBy(financeEvents.eventKind)
|
||||
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind);
|
||||
},
|
||||
|
||||
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
|
||||
const conditions = rangeConditions(companyId, range);
|
||||
return db
|
||||
.select()
|
||||
.from(financeEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { goals } from "@paperclipai/db";
|
||||
|
||||
type GoalReader = Pick<Db, "select">;
|
||||
|
||||
export async function getDefaultCompanyGoal(db: GoalReader, companyId: string) {
|
||||
const activeRootGoal = await db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(
|
||||
and(
|
||||
eq(goals.companyId, companyId),
|
||||
eq(goals.level, "company"),
|
||||
eq(goals.status, "active"),
|
||||
isNull(goals.parentId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (activeRootGoal) return activeRootGoal;
|
||||
|
||||
const anyRootGoal = await db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(
|
||||
and(
|
||||
eq(goals.companyId, companyId),
|
||||
eq(goals.level, "company"),
|
||||
isNull(goals.parentId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (anyRootGoal) return anyRootGoal;
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(and(eq(goals.companyId, companyId), eq(goals.level, "company")))
|
||||
.orderBy(asc(goals.createdAt))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
export function goalService(db: Db) {
|
||||
return {
|
||||
list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(eq(goals.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
getDefaultCompanyGoal: (companyId: string) => getDefaultCompanyGoal(db, companyId),
|
||||
|
||||
create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
|
||||
db
|
||||
.insert(goals)
|
||||
.values({ ...data, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]),
|
||||
|
||||
update: (id: string, data: Partial<typeof goals.$inferInsert>) =>
|
||||
db
|
||||
.update(goals)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(goals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(goals)
|
||||
.where(eq(goals.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
function truncateSummaryText(value: unknown, maxLength = 500) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? record[key] ?? null : undefined;
|
||||
}
|
||||
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary: Record<string, unknown> = {};
|
||||
const textFields = ["summary", "result", "message", "error"] as const;
|
||||
for (const key of textFields) {
|
||||
const value = truncateSummaryText(resultJson[key]);
|
||||
if (value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const numericFieldAliases = ["total_cost_usd", "cost_usd", "costUsd"] as const;
|
||||
for (const key of numericFieldAliases) {
|
||||
const value = readNumericField(resultJson, key);
|
||||
if (value !== undefined && value !== null) {
|
||||
summary[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,113 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import type { HireApprovedPayload } from "@paperclipai/adapter-utils";
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
const HIRE_APPROVED_MESSAGE =
|
||||
"Tell your user that your hire was approved, now they should assign you a task in Paperclip or ask you to create issues.";
|
||||
|
||||
export interface NotifyHireApprovedInput {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the adapter's onHireApproved hook when an agent is approved (join-request or hire_agent approval).
|
||||
* Failures are non-fatal: we log and write to activity, never throw.
|
||||
*/
|
||||
export async function notifyHireApproved(
|
||||
db: Db,
|
||||
input: NotifyHireApprovedInput,
|
||||
): Promise<void> {
|
||||
const { companyId, agentId, source, sourceId } = input;
|
||||
const approvedAt = input.approvedAt ?? new Date();
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!row) {
|
||||
logger.warn({ companyId, agentId, source, sourceId }, "hire hook: agent not found in company, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const adapterType = row.adapterType ?? "process";
|
||||
const adapter = findServerAdapter(adapterType);
|
||||
const onHireApproved = adapter?.onHireApproved;
|
||||
if (!onHireApproved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: HireApprovedPayload = {
|
||||
companyId,
|
||||
agentId,
|
||||
agentName: row.name,
|
||||
adapterType,
|
||||
source,
|
||||
sourceId,
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
message: HIRE_APPROVED_MESSAGE,
|
||||
};
|
||||
|
||||
const adapterConfig =
|
||||
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
|
||||
? (row.adapterConfig as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
try {
|
||||
const result = await onHireApproved(payload, adapterConfig);
|
||||
if (result.ok) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.succeeded",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ companyId, agentId, adapterType, source, sourceId, error: result.error, detail: result.detail },
|
||||
"hire hook: adapter returned failure",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.failed",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: { source, sourceId, adapterType, error: result.error, detail: result.detail },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, companyId, agentId, adapterType, source, sourceId },
|
||||
"hire hook: adapter threw",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "hire_hook",
|
||||
action: "hire_hook.error",
|
||||
entityType: "agent",
|
||||
entityId: agentId,
|
||||
details: {
|
||||
source,
|
||||
sourceId,
|
||||
adapterType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export { companyService } from "./companies.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
export { projectService } from "./projects.js";
|
||||
export { issueService, type IssueFilters } from "./issues.js";
|
||||
export { issueApprovalService } from "./issue-approvals.js";
|
||||
export { goalService } from "./goals.js";
|
||||
export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
export { budgetService } from "./budgets.js";
|
||||
export { secretService } from "./secrets.js";
|
||||
export { routineService } from "./routines.js";
|
||||
export { costService } from "./costs.js";
|
||||
export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
export { workspaceOperationService } from "./workspace-operations.js";
|
||||
export { workProductService } from "./work-products.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
import {
|
||||
instanceGeneralSettingsSchema,
|
||||
type InstanceGeneralSettings,
|
||||
instanceExperimentalSettingsSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type PatchInstanceGeneralSettings,
|
||||
type InstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
} from "@paperclipai/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const DEFAULT_SINGLETON_KEY = "default";
|
||||
|
||||
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
||||
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
censorUsernameInLogs: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
|
||||
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
||||
if (parsed.success) {
|
||||
return {
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
general: normalizeGeneralSettings(row.general),
|
||||
experimental: normalizeExperimentalSettings(row.experimental),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function instanceSettingsService(db: Db) {
|
||||
async function getOrCreateRow() {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.singletonKey, DEFAULT_SINGLETON_KEY))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing) return existing;
|
||||
|
||||
const now = new Date();
|
||||
const [created] = await db
|
||||
.insert(instanceSettings)
|
||||
.values({
|
||||
singletonKey: DEFAULT_SINGLETON_KEY,
|
||||
general: {},
|
||||
experimental: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [instanceSettings.singletonKey],
|
||||
set: {
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
return {
|
||||
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
|
||||
|
||||
getGeneral: async (): Promise<InstanceGeneralSettings> => {
|
||||
const row = await getOrCreateRow();
|
||||
return normalizeGeneralSettings(row.general);
|
||||
},
|
||||
|
||||
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
|
||||
const row = await getOrCreateRow();
|
||||
return normalizeExperimentalSettings(row.experimental);
|
||||
},
|
||||
|
||||
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const nextGeneral = normalizeGeneralSettings({
|
||||
...normalizeGeneralSettings(current.general),
|
||||
...patch,
|
||||
});
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
general: { ...nextGeneral },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
|
||||
const current = await getOrCreateRow();
|
||||
const nextExperimental = normalizeExperimentalSettings({
|
||||
...normalizeExperimentalSettings(current.experimental),
|
||||
...patch,
|
||||
});
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(instanceSettings)
|
||||
.set({
|
||||
experimental: { ...nextExperimental },
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(instanceSettings.id, current.id))
|
||||
.returning();
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
listCompanyIds: async (): Promise<string[]> =>
|
||||
db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.then((rows) => rows.map((row) => row.id)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvals, issueApprovals, issues } from "@paperclipai/db";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
|
||||
interface LinkActor {
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export function issueApprovalService(db: Db) {
|
||||
async function getIssue(issueId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getApproval(approvalId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(approvals)
|
||||
.where(eq(approvals.id, approvalId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertIssueAndApprovalSameCompany(issueId: string, approvalId: string) {
|
||||
const issue = await getIssue(issueId);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
if (issue.companyId !== approval.companyId) {
|
||||
throw unprocessable("Issue and approval must belong to the same company");
|
||||
}
|
||||
|
||||
return { issue, approval };
|
||||
}
|
||||
|
||||
return {
|
||||
listApprovalsForIssue: async (issueId: string) => {
|
||||
const issue = await getIssue(issueId);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
id: approvals.id,
|
||||
companyId: approvals.companyId,
|
||||
type: approvals.type,
|
||||
requestedByAgentId: approvals.requestedByAgentId,
|
||||
requestedByUserId: approvals.requestedByUserId,
|
||||
status: approvals.status,
|
||||
payload: approvals.payload,
|
||||
decisionNote: approvals.decisionNote,
|
||||
decidedByUserId: approvals.decidedByUserId,
|
||||
decidedAt: approvals.decidedAt,
|
||||
createdAt: approvals.createdAt,
|
||||
updatedAt: approvals.updatedAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(eq(issueApprovals.issueId, issueId))
|
||||
.orderBy(desc(issueApprovals.createdAt));
|
||||
return result.map((approval) => ({
|
||||
...approval,
|
||||
payload: redactEventPayload(approval.payload) ?? {},
|
||||
}));
|
||||
},
|
||||
|
||||
listIssuesForApproval: async (approvalId: string) => {
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
parentId: issues.parentId,
|
||||
title: issues.title,
|
||||
description: issues.description,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
issueNumber: issues.issueNumber,
|
||||
identifier: issues.identifier,
|
||||
requestDepth: issues.requestDepth,
|
||||
billingCode: issues.billingCode,
|
||||
startedAt: issues.startedAt,
|
||||
completedAt: issues.completedAt,
|
||||
cancelledAt: issues.cancelledAt,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
})
|
||||
.from(issueApprovals)
|
||||
.innerJoin(issues, eq(issueApprovals.issueId, issues.id))
|
||||
.where(eq(issueApprovals.approvalId, approvalId))
|
||||
.orderBy(desc(issueApprovals.createdAt));
|
||||
},
|
||||
|
||||
link: async (issueId: string, approvalId: string, actor?: LinkActor) => {
|
||||
const { issue } = await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
||||
|
||||
await db
|
||||
.insert(issueApprovals)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: actor?.agentId ?? null,
|
||||
linkedByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(issueApprovals)
|
||||
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
unlink: async (issueId: string, approvalId: string) => {
|
||||
await assertIssueAndApprovalSameCompany(issueId, approvalId);
|
||||
await db
|
||||
.delete(issueApprovals)
|
||||
.where(and(eq(issueApprovals.issueId, issueId), eq(issueApprovals.approvalId, approvalId)));
|
||||
},
|
||||
|
||||
linkManyForApproval: async (approvalId: string, issueIds: string[], actor?: LinkActor) => {
|
||||
if (issueIds.length === 0) return;
|
||||
|
||||
const approval = await getApproval(approvalId);
|
||||
if (!approval) throw notFound("Approval not found");
|
||||
|
||||
const uniqueIssueIds = Array.from(new Set(issueIds));
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(inArray(issues.id, uniqueIssueIds));
|
||||
|
||||
if (rows.length !== uniqueIssueIds.length) {
|
||||
throw notFound("One or more issues not found");
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.companyId !== approval.companyId) {
|
||||
throw unprocessable("Issue and approval must belong to the same company");
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(issueApprovals)
|
||||
.values(
|
||||
uniqueIssueIds.map((issueId) => ({
|
||||
companyId: approval.companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: actor?.agentId ?? null,
|
||||
linkedByUserId: actor?.userId ?? null,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
type WakeupTriggerDetail = "manual" | "ping" | "callback" | "system";
|
||||
type WakeupSource = "timer" | "assignment" | "on_demand" | "automation";
|
||||
|
||||
export interface IssueAssignmentWakeupDeps {
|
||||
wakeup: (
|
||||
agentId: string,
|
||||
opts: {
|
||||
source?: WakeupSource;
|
||||
triggerDetail?: WakeupTriggerDetail;
|
||||
reason?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function queueIssueAssignmentWakeup(input: {
|
||||
heartbeat: IssueAssignmentWakeupDeps;
|
||||
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||
reason: string;
|
||||
mutation: string;
|
||||
contextSource: string;
|
||||
requestedByActorType?: "user" | "agent" | "system";
|
||||
requestedByActorId?: string | null;
|
||||
rethrowOnError?: boolean;
|
||||
}) {
|
||||
if (!input.issue.assigneeAgentId || input.issue.status === "backlog") return;
|
||||
|
||||
return input.heartbeat
|
||||
.wakeup(input.issue.assigneeAgentId, {
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: input.reason,
|
||||
payload: { issueId: input.issue.id, mutation: input.mutation },
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
requestedByActorId: input.requestedByActorId ?? null,
|
||||
contextSnapshot: { issueId: input.issue.id, source: input.contextSource },
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn({ err, issueId: input.issue.id }, "failed to wake assignee on issue assignment");
|
||||
if (input.rethrowOnError) throw err;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
type MaybeId = string | null | undefined;
|
||||
|
||||
export function resolveIssueGoalId(input: {
|
||||
projectId: MaybeId;
|
||||
goalId: MaybeId;
|
||||
projectGoalId?: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
if (input.goalId) return input.goalId;
|
||||
if (input.projectId) return input.projectGoalId ?? null;
|
||||
return input.defaultGoalId ?? null;
|
||||
}
|
||||
|
||||
export function resolveNextIssueGoalId(input: {
|
||||
currentProjectId: MaybeId;
|
||||
currentGoalId: MaybeId;
|
||||
currentProjectGoalId?: MaybeId;
|
||||
projectId?: MaybeId;
|
||||
goalId?: MaybeId;
|
||||
projectGoalId?: MaybeId;
|
||||
defaultGoalId: MaybeId;
|
||||
}): string | null {
|
||||
const projectId =
|
||||
input.projectId !== undefined ? input.projectId : input.currentProjectId;
|
||||
const projectGoalId =
|
||||
input.projectGoalId !== undefined
|
||||
? input.projectGoalId
|
||||
: projectId
|
||||
? input.currentProjectGoalId
|
||||
: null;
|
||||
|
||||
const resolveFallbackGoalId = (targetProjectId: MaybeId, targetProjectGoalId: MaybeId) => {
|
||||
if (targetProjectId) return targetProjectGoalId ?? null;
|
||||
return input.defaultGoalId ?? null;
|
||||
};
|
||||
|
||||
if (input.goalId !== undefined) {
|
||||
return input.goalId ?? resolveFallbackGoalId(projectId, projectGoalId);
|
||||
}
|
||||
|
||||
const currentFallbackGoalId = resolveFallbackGoalId(
|
||||
input.currentProjectId,
|
||||
input.currentProjectGoalId,
|
||||
);
|
||||
const nextFallbackGoalId = resolveFallbackGoalId(projectId, projectGoalId);
|
||||
|
||||
if (!input.currentGoalId) {
|
||||
return nextFallbackGoalId;
|
||||
}
|
||||
|
||||
if (input.currentGoalId === currentFallbackGoalId) {
|
||||
return nextFallbackGoalId;
|
||||
}
|
||||
|
||||
return input.currentGoalId;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,54 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { LiveEvent, LiveEventType } from "@paperclipai/shared";
|
||||
|
||||
type LiveEventPayload = Record<string, unknown>;
|
||||
type LiveEventListener = (event: LiveEvent) => void;
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(0);
|
||||
|
||||
let nextEventId = 0;
|
||||
|
||||
function toLiveEvent(input: {
|
||||
companyId: string;
|
||||
type: LiveEventType;
|
||||
payload?: LiveEventPayload;
|
||||
}): LiveEvent {
|
||||
nextEventId += 1;
|
||||
return {
|
||||
id: nextEventId,
|
||||
companyId: input.companyId,
|
||||
type: input.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: input.payload ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function publishLiveEvent(input: {
|
||||
companyId: string;
|
||||
type: LiveEventType;
|
||||
payload?: LiveEventPayload;
|
||||
}) {
|
||||
const event = toLiveEvent(input);
|
||||
emitter.emit(input.companyId, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function publishGlobalLiveEvent(input: {
|
||||
type: LiveEventType;
|
||||
payload?: LiveEventPayload;
|
||||
}) {
|
||||
const event = toLiveEvent({ companyId: "*", type: input.type, payload: input.payload });
|
||||
emitter.emit("*", event);
|
||||
return event;
|
||||
}
|
||||
|
||||
export function subscribeCompanyLiveEvents(companyId: string, listener: LiveEventListener) {
|
||||
emitter.on(companyId, listener);
|
||||
return () => emitter.off(companyId, listener);
|
||||
}
|
||||
|
||||
export function subscribeGlobalLiveEvents(listener: LiveEventListener) {
|
||||
emitter.on("*", listener);
|
||||
return () => emitter.off("*", listener);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* PluginCapabilityValidator — enforces the capability model at both
|
||||
* install-time and runtime.
|
||||
*
|
||||
* Every plugin declares the capabilities it requires in its manifest
|
||||
* (`manifest.capabilities`). This service checks those declarations
|
||||
* against a mapping of operations → required capabilities so that:
|
||||
*
|
||||
* 1. **Install-time validation** — `validateManifestCapabilities()`
|
||||
* ensures that declared features (tools, jobs, webhooks, UI slots)
|
||||
* have matching capability entries, giving operators clear feedback
|
||||
* before a plugin is activated.
|
||||
*
|
||||
* 2. **Runtime gating** — `checkOperation()` / `assertOperation()` are
|
||||
* called on every worker→host bridge call to enforce least-privilege
|
||||
* access. If a plugin attempts an operation it did not declare, the
|
||||
* call is rejected with a 403 error.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
* @see host-client-factory.ts — SDK-side capability gating
|
||||
*/
|
||||
import type {
|
||||
PluginCapability,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginUiSlotType,
|
||||
PluginLauncherPlacementZone,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability requirement mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps high-level operations to the capabilities they require.
|
||||
*
|
||||
* When the bridge receives a call from a plugin worker, the host looks up
|
||||
* the operation in this map and checks the plugin's declared capabilities.
|
||||
* If any required capability is missing, the call is rejected.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
// Data read operations
|
||||
"companies.list": ["companies.read"],
|
||||
"companies.get": ["companies.read"],
|
||||
"projects.list": ["projects.read"],
|
||||
"projects.get": ["projects.read"],
|
||||
"project.workspaces.list": ["project.workspaces.read"],
|
||||
"project.workspaces.get": ["project.workspaces.read"],
|
||||
"issues.list": ["issues.read"],
|
||||
"issues.get": ["issues.read"],
|
||||
"issue.comments.list": ["issue.comments.read"],
|
||||
"issue.comments.get": ["issue.comments.read"],
|
||||
"agents.list": ["agents.read"],
|
||||
"agents.get": ["agents.read"],
|
||||
"goals.list": ["goals.read"],
|
||||
"goals.get": ["goals.read"],
|
||||
"activity.list": ["activity.read"],
|
||||
"activity.get": ["activity.read"],
|
||||
"costs.list": ["costs.read"],
|
||||
"costs.get": ["costs.read"],
|
||||
|
||||
// Data write operations
|
||||
"issues.create": ["issues.create"],
|
||||
"issues.update": ["issues.update"],
|
||||
"issue.comments.create": ["issue.comments.create"],
|
||||
"activity.log": ["activity.log.write"],
|
||||
"metrics.write": ["metrics.write"],
|
||||
|
||||
// Plugin state operations
|
||||
"plugin.state.get": ["plugin.state.read"],
|
||||
"plugin.state.list": ["plugin.state.read"],
|
||||
"plugin.state.set": ["plugin.state.write"],
|
||||
"plugin.state.delete": ["plugin.state.write"],
|
||||
|
||||
// Runtime / Integration operations
|
||||
"events.subscribe": ["events.subscribe"],
|
||||
"events.emit": ["events.emit"],
|
||||
"jobs.schedule": ["jobs.schedule"],
|
||||
"jobs.cancel": ["jobs.schedule"],
|
||||
"webhooks.receive": ["webhooks.receive"],
|
||||
"http.request": ["http.outbound"],
|
||||
"secrets.resolve": ["secrets.read-ref"],
|
||||
|
||||
// Agent tools
|
||||
"agent.tools.register": ["agent.tools.register"],
|
||||
"agent.tools.execute": ["agent.tools.register"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps UI slot types to the capability required to register them.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
sidebar: "ui.sidebar.register",
|
||||
sidebarPanel: "ui.sidebar.register",
|
||||
projectSidebarItem: "ui.sidebar.register",
|
||||
page: "ui.page.register",
|
||||
detailTab: "ui.detailTab.register",
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
globalToolbarButton: "ui.action.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
};
|
||||
|
||||
/**
|
||||
* Launcher placement zones align with host UI surfaces and therefore inherit
|
||||
* the same capability requirements as the equivalent slot type.
|
||||
*/
|
||||
const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||||
PluginLauncherPlacementZone,
|
||||
PluginCapability
|
||||
> = {
|
||||
page: "ui.page.register",
|
||||
detailTab: "ui.detailTab.register",
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
sidebar: "ui.sidebar.register",
|
||||
sidebarPanel: "ui.sidebar.register",
|
||||
projectSidebarItem: "ui.sidebar.register",
|
||||
globalToolbarButton: "ui.action.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps feature declarations in the manifest to their required capabilities.
|
||||
*/
|
||||
const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
|
||||
tools: "agent.tools.register",
|
||||
jobs: "jobs.schedule",
|
||||
webhooks: "webhooks.receive",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of a capability check. When `allowed` is false, `missing` contains
|
||||
* the capabilities that the plugin does not declare but the operation requires.
|
||||
*/
|
||||
export interface CapabilityCheckResult {
|
||||
allowed: boolean;
|
||||
missing: PluginCapability[];
|
||||
operation?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginCapabilityValidator interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginCapabilityValidator {
|
||||
/**
|
||||
* Check whether a plugin has a specific capability.
|
||||
*/
|
||||
hasCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capability: PluginCapability,
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Check whether a plugin has all of the specified capabilities.
|
||||
*/
|
||||
hasAllCapabilities(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capabilities: PluginCapability[],
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Check whether a plugin has at least one of the specified capabilities.
|
||||
*/
|
||||
hasAnyCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capabilities: PluginCapability[],
|
||||
): boolean;
|
||||
|
||||
/**
|
||||
* Check whether a plugin is allowed to perform the named operation.
|
||||
*
|
||||
* Operations are mapped to required capabilities via OPERATION_CAPABILITIES.
|
||||
* Unknown operations are rejected by default.
|
||||
*/
|
||||
checkOperation(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Assert that a plugin is allowed to perform an operation.
|
||||
* Throws a 403 HttpError if the capability check fails.
|
||||
*/
|
||||
assertOperation(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Assert that a plugin has a specific capability.
|
||||
* Throws a 403 HttpError if the capability is missing.
|
||||
*/
|
||||
assertCapability(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
capability: PluginCapability,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Check whether a plugin can register the given UI slot type.
|
||||
*/
|
||||
checkUiSlot(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
slotType: PluginUiSlotType,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Validate that a manifest's declared capabilities are consistent with its
|
||||
* declared features (tools, jobs, webhooks, UI slots).
|
||||
*
|
||||
* Returns all missing capabilities rather than failing on the first one.
|
||||
* This is useful for install-time validation to give comprehensive feedback.
|
||||
*/
|
||||
validateManifestCapabilities(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
): CapabilityCheckResult;
|
||||
|
||||
/**
|
||||
* Get the capabilities required for a named operation.
|
||||
* Returns an empty array if the operation is unknown.
|
||||
*/
|
||||
getRequiredCapabilities(operation: string): readonly PluginCapability[];
|
||||
|
||||
/**
|
||||
* Get the capability required for a UI slot type.
|
||||
*/
|
||||
getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginCapabilityValidator.
|
||||
*
|
||||
* This service enforces capability gates for plugin operations. The host
|
||||
* uses it to verify that a plugin's declared capabilities permit the
|
||||
* operation it is attempting, both at install time (manifest validation)
|
||||
* and at runtime (bridge call gating).
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const validator = pluginCapabilityValidator();
|
||||
*
|
||||
* // Runtime: gate a bridge call
|
||||
* validator.assertOperation(plugin.manifestJson, "issues.create");
|
||||
*
|
||||
* // Install time: validate manifest consistency
|
||||
* const result = validator.validateManifestCapabilities(manifest);
|
||||
* if (!result.allowed) {
|
||||
* throw badRequest("Missing capabilities", result.missing);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function pluginCapabilityValidator(): PluginCapabilityValidator {
|
||||
const log = logger.child({ service: "plugin-capability-validator" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function capabilitySet(manifest: PaperclipPluginManifestV1): Set<PluginCapability> {
|
||||
return new Set(manifest.capabilities);
|
||||
}
|
||||
|
||||
function buildForbiddenMessage(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
operation: string,
|
||||
missing: PluginCapability[],
|
||||
): string {
|
||||
return (
|
||||
`Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` +
|
||||
`Missing required capabilities: ${missing.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
hasCapability(manifest, capability) {
|
||||
return manifest.capabilities.includes(capability);
|
||||
},
|
||||
|
||||
hasAllCapabilities(manifest, capabilities) {
|
||||
const declared = capabilitySet(manifest);
|
||||
const missing = capabilities.filter((cap) => !declared.has(cap));
|
||||
return {
|
||||
allowed: missing.length === 0,
|
||||
missing,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
hasAnyCapability(manifest, capabilities) {
|
||||
const declared = capabilitySet(manifest);
|
||||
return capabilities.some((cap) => declared.has(cap));
|
||||
},
|
||||
|
||||
checkOperation(manifest, operation) {
|
||||
const required = OPERATION_CAPABILITIES[operation];
|
||||
|
||||
if (!required) {
|
||||
log.warn(
|
||||
{ pluginId: manifest.id, operation },
|
||||
"capability check for unknown operation – rejecting by default",
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
missing: [],
|
||||
operation,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
}
|
||||
|
||||
const declared = capabilitySet(manifest);
|
||||
const missing = required.filter((cap) => !declared.has(cap));
|
||||
|
||||
if (missing.length > 0) {
|
||||
log.debug(
|
||||
{ pluginId: manifest.id, operation, missing },
|
||||
"capability check failed",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: missing.length === 0,
|
||||
missing,
|
||||
operation,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
assertOperation(manifest, operation) {
|
||||
const result = this.checkOperation(manifest, operation);
|
||||
if (!result.allowed) {
|
||||
const msg = result.missing.length > 0
|
||||
? buildForbiddenMessage(manifest, operation, result.missing)
|
||||
: `Plugin '${manifest.id}' attempted unknown operation '${operation}'`;
|
||||
throw forbidden(msg);
|
||||
}
|
||||
},
|
||||
|
||||
assertCapability(manifest, capability) {
|
||||
if (!this.hasCapability(manifest, capability)) {
|
||||
throw forbidden(
|
||||
`Plugin '${manifest.id}' lacks required capability '${capability}'`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
checkUiSlot(manifest, slotType) {
|
||||
const required = UI_SLOT_CAPABILITIES[slotType];
|
||||
if (!required) {
|
||||
return {
|
||||
allowed: false,
|
||||
missing: [],
|
||||
operation: `ui.${slotType}.register`,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
}
|
||||
|
||||
const has = manifest.capabilities.includes(required);
|
||||
return {
|
||||
allowed: has,
|
||||
missing: has ? [] : [required],
|
||||
operation: `ui.${slotType}.register`,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
validateManifestCapabilities(manifest) {
|
||||
const declared = capabilitySet(manifest);
|
||||
const allMissing: PluginCapability[] = [];
|
||||
|
||||
// Check feature declarations → required capabilities
|
||||
for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) {
|
||||
const featureValue = manifest[feature as keyof PaperclipPluginManifestV1];
|
||||
if (Array.isArray(featureValue) && featureValue.length > 0) {
|
||||
if (!declared.has(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check UI slots → required capabilities
|
||||
const uiSlots = manifest.ui?.slots ?? [];
|
||||
if (uiSlots.length > 0) {
|
||||
for (const slot of uiSlots) {
|
||||
const requiredCap = UI_SLOT_CAPABILITIES[slot.type];
|
||||
if (requiredCap && !declared.has(requiredCap)) {
|
||||
if (!allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check launcher declarations → required capabilities
|
||||
const launchers = [
|
||||
...(manifest.launchers ?? []),
|
||||
...(manifest.ui?.launchers ?? []),
|
||||
];
|
||||
if (launchers.length > 0) {
|
||||
for (const launcher of launchers) {
|
||||
const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone];
|
||||
if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
|
||||
allMissing.push(requiredCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: allMissing.length === 0,
|
||||
missing: allMissing,
|
||||
pluginId: manifest.id,
|
||||
};
|
||||
},
|
||||
|
||||
getRequiredCapabilities(operation) {
|
||||
return OPERATION_CAPABILITIES[operation] ?? [];
|
||||
},
|
||||
|
||||
getUiSlotCapability(slotType) {
|
||||
return UI_SLOT_CAPABILITIES[slotType];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @fileoverview Validates plugin instance configuration against its JSON Schema.
|
||||
*
|
||||
* Uses Ajv to validate `configJson` values against the `instanceConfigSchema`
|
||||
* declared in a plugin's manifest. This ensures that invalid configuration is
|
||||
* rejected at the API boundary, not discovered later at worker startup.
|
||||
*
|
||||
* @module server/services/plugin-config-validator
|
||||
*/
|
||||
|
||||
import Ajv, { type ErrorObject } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import type { JsonSchema } from "@paperclipai/shared";
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
valid: boolean;
|
||||
errors?: { field: string; message: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a config object against a JSON Schema.
|
||||
*
|
||||
* @param configJson - The configuration values to validate.
|
||||
* @param schema - The JSON Schema from the plugin manifest's `instanceConfigSchema`.
|
||||
* @returns Validation result with structured field errors on failure.
|
||||
*/
|
||||
export function validateInstanceConfig(
|
||||
configJson: Record<string, unknown>,
|
||||
schema: JsonSchema,
|
||||
): ConfigValidationResult {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AjvCtor = (Ajv as any).default ?? Ajv;
|
||||
const ajv = new AjvCtor({ allErrors: true });
|
||||
// ajv-formats v3 default export is a FormatsPlugin object; call it as a plugin.
|
||||
const applyFormats = (addFormats as any).default ?? addFormats;
|
||||
applyFormats(ajv);
|
||||
// Register the secret-ref format used by plugin manifests to mark fields that
|
||||
// hold a Paperclip secret UUID rather than a raw value. The format is a UI
|
||||
// hint only — UUID validation happens in the secrets handler at resolve time.
|
||||
ajv.addFormat("secret-ref", { validate: () => true });
|
||||
const validate = ajv.compile(schema);
|
||||
const valid = validate(configJson);
|
||||
|
||||
if (valid) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const errors = (validate.errors ?? []).map((err: ErrorObject) => ({
|
||||
field: err.instancePath || "/",
|
||||
message: err.message ?? "validation failed",
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* PluginDevWatcher — watches local-path plugin directories for file changes
|
||||
* and triggers worker restarts so plugin authors get a fast rebuild-and-reload
|
||||
* cycle without manually restarting the server.
|
||||
*
|
||||
* Only plugins installed from a local path (i.e. those with a non-null
|
||||
* `packagePath` in the DB) are watched. File changes in the plugin's package
|
||||
* directory trigger a debounced worker restart via the lifecycle manager.
|
||||
*
|
||||
* Uses chokidar rather than raw fs.watch so we get a production-grade watcher
|
||||
* backend across platforms and avoid exhausting file descriptors as quickly in
|
||||
* large dev workspaces.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
*/
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
|
||||
const log = logger.child({ service: "plugin-dev-watcher" });
|
||||
|
||||
/** Debounce interval for file changes (ms). */
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
export interface PluginDevWatcher {
|
||||
/** Start watching a local-path plugin directory. */
|
||||
watch(pluginId: string, packagePath: string): void;
|
||||
/** Stop watching a specific plugin. */
|
||||
unwatch(pluginId: string): void;
|
||||
/** Stop all watchers and clean up. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export type ResolvePluginPackagePath = (
|
||||
pluginId: string,
|
||||
) => Promise<string | null | undefined>;
|
||||
|
||||
export interface PluginDevWatcherFsDeps {
|
||||
existsSync?: typeof existsSync;
|
||||
readFileSync?: typeof readFileSync;
|
||||
readdirSync?: typeof readdirSync;
|
||||
statSync?: typeof statSync;
|
||||
}
|
||||
|
||||
type PluginWatchTarget = {
|
||||
path: string;
|
||||
recursive: boolean;
|
||||
kind: "file" | "dir";
|
||||
};
|
||||
|
||||
type PluginPackageJson = {
|
||||
paperclipPlugin?: {
|
||||
manifest?: string;
|
||||
worker?: string;
|
||||
ui?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function shouldIgnorePath(filename: string | null | undefined): boolean {
|
||||
if (!filename) return false;
|
||||
const normalized = filename.replace(/\\/g, "/");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment === "node_modules" ||
|
||||
segment === ".git" ||
|
||||
segment === ".vite" ||
|
||||
segment === ".paperclip-sdk" ||
|
||||
segment.startsWith("."),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePluginWatchTargets(
|
||||
packagePath: string,
|
||||
fsDeps?: Pick<PluginDevWatcherFsDeps, "existsSync" | "readFileSync" | "readdirSync" | "statSync">,
|
||||
): PluginWatchTarget[] {
|
||||
const fileExists = fsDeps?.existsSync ?? existsSync;
|
||||
const readFile = fsDeps?.readFileSync ?? readFileSync;
|
||||
const readDir = fsDeps?.readdirSync ?? readdirSync;
|
||||
const statFile = fsDeps?.statSync ?? statSync;
|
||||
const absPath = path.resolve(packagePath);
|
||||
const targets = new Map<string, PluginWatchTarget>();
|
||||
|
||||
function addWatchTarget(targetPath: string, recursive: boolean, kind?: "file" | "dir"): void {
|
||||
const resolved = path.resolve(targetPath);
|
||||
if (!fileExists(resolved)) return;
|
||||
const inferredKind = kind ?? (statFile(resolved).isDirectory() ? "dir" : "file");
|
||||
|
||||
const existing = targets.get(resolved);
|
||||
if (existing) {
|
||||
existing.recursive = existing.recursive || recursive;
|
||||
return;
|
||||
}
|
||||
|
||||
targets.set(resolved, { path: resolved, recursive, kind: inferredKind });
|
||||
}
|
||||
|
||||
function addRuntimeFilesFromDir(dirPath: string): void {
|
||||
if (!fileExists(dirPath)) return;
|
||||
|
||||
for (const entry of readDir(dirPath, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
addRuntimeFilesFromDir(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".css")) continue;
|
||||
addWatchTarget(entryPath, false, "file");
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(absPath, "package.json");
|
||||
addWatchTarget(packageJsonPath, false, "file");
|
||||
if (!fileExists(packageJsonPath)) {
|
||||
return [...targets.values()];
|
||||
}
|
||||
|
||||
let packageJson: PluginPackageJson | null = null;
|
||||
try {
|
||||
packageJson = JSON.parse(readFile(packageJsonPath, "utf8")) as PluginPackageJson;
|
||||
} catch {
|
||||
packageJson = null;
|
||||
}
|
||||
|
||||
const entrypointPaths = [
|
||||
packageJson?.paperclipPlugin?.manifest,
|
||||
packageJson?.paperclipPlugin?.worker,
|
||||
packageJson?.paperclipPlugin?.ui,
|
||||
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
||||
|
||||
if (entrypointPaths.length === 0) {
|
||||
addRuntimeFilesFromDir(path.join(absPath, "dist"));
|
||||
return [...targets.values()];
|
||||
}
|
||||
|
||||
for (const relativeEntrypoint of entrypointPaths) {
|
||||
const resolvedEntrypoint = path.resolve(absPath, relativeEntrypoint);
|
||||
if (!fileExists(resolvedEntrypoint)) continue;
|
||||
|
||||
const stat = statFile(resolvedEntrypoint);
|
||||
if (stat.isDirectory()) {
|
||||
addRuntimeFilesFromDir(resolvedEntrypoint);
|
||||
} else {
|
||||
addWatchTarget(resolvedEntrypoint, false, "file");
|
||||
}
|
||||
}
|
||||
|
||||
return [...targets.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginDevWatcher that monitors local plugin directories and
|
||||
* restarts workers on file changes.
|
||||
*/
|
||||
export function createPluginDevWatcher(
|
||||
lifecycle: PluginLifecycleManager,
|
||||
resolvePluginPackagePath?: ResolvePluginPackagePath,
|
||||
fsDeps?: PluginDevWatcherFsDeps,
|
||||
): PluginDevWatcher {
|
||||
const watchers = new Map<string, FSWatcher>();
|
||||
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const fileExists = fsDeps?.existsSync ?? existsSync;
|
||||
|
||||
function watchPlugin(pluginId: string, packagePath: string): void {
|
||||
// Don't double-watch
|
||||
if (watchers.has(pluginId)) return;
|
||||
|
||||
const absPath = path.resolve(packagePath);
|
||||
if (!fileExists(absPath)) {
|
||||
log.warn(
|
||||
{ pluginId, packagePath: absPath },
|
||||
"plugin-dev-watcher: package path does not exist, skipping watch",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const watcherTargets = resolvePluginWatchTargets(absPath, fsDeps);
|
||||
if (watcherTargets.length === 0) {
|
||||
log.warn(
|
||||
{ pluginId, packagePath: absPath },
|
||||
"plugin-dev-watcher: no valid watch targets found, skipping watch",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(
|
||||
watcherTargets.map((target) => target.path),
|
||||
{
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 200,
|
||||
pollInterval: 100,
|
||||
},
|
||||
ignored: (watchedPath) => {
|
||||
const relativePath = path.relative(absPath, watchedPath);
|
||||
return shouldIgnorePath(relativePath);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
watcher.on("all", (_eventName, changedPath) => {
|
||||
const relativePath = path.relative(absPath, changedPath);
|
||||
if (shouldIgnorePath(relativePath)) return;
|
||||
|
||||
const existing = debounceTimers.get(pluginId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
debounceTimers.set(
|
||||
pluginId,
|
||||
setTimeout(() => {
|
||||
debounceTimers.delete(pluginId);
|
||||
log.info(
|
||||
{ pluginId, changedFile: relativePath || path.basename(changedPath) },
|
||||
"plugin-dev-watcher: file change detected, restarting worker",
|
||||
);
|
||||
|
||||
lifecycle.restartWorker(pluginId).catch((err) => {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to restart worker after file change",
|
||||
);
|
||||
});
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: watcher error, stopping watch for this plugin",
|
||||
);
|
||||
unwatchPlugin(pluginId);
|
||||
});
|
||||
|
||||
watchers.set(pluginId, watcher);
|
||||
log.info(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
watchTargets: watcherTargets.map((target) => ({
|
||||
path: target.path,
|
||||
kind: target.kind,
|
||||
})),
|
||||
},
|
||||
"plugin-dev-watcher: watching local plugin for changes",
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
packagePath: absPath,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to start file watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function unwatchPlugin(pluginId: string): void {
|
||||
const pluginWatcher = watchers.get(pluginId);
|
||||
if (pluginWatcher) {
|
||||
void pluginWatcher.close();
|
||||
watchers.delete(pluginId);
|
||||
}
|
||||
const timer = debounceTimers.get(pluginId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
lifecycle.off("plugin.loaded", handlePluginLoaded);
|
||||
lifecycle.off("plugin.enabled", handlePluginEnabled);
|
||||
lifecycle.off("plugin.disabled", handlePluginDisabled);
|
||||
lifecycle.off("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
for (const [pluginId] of watchers) {
|
||||
unwatchPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
async function watchLocalPluginById(pluginId: string): Promise<void> {
|
||||
if (!resolvePluginPackagePath) return;
|
||||
|
||||
try {
|
||||
const packagePath = await resolvePluginPackagePath(pluginId);
|
||||
if (!packagePath) return;
|
||||
watchPlugin(pluginId, packagePath);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"plugin-dev-watcher: failed to resolve plugin package path",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePluginLoaded(payload: { pluginId: string }): void {
|
||||
void watchLocalPluginById(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginEnabled(payload: { pluginId: string }): void {
|
||||
void watchLocalPluginById(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginDisabled(payload: { pluginId: string }): void {
|
||||
unwatchPlugin(payload.pluginId);
|
||||
}
|
||||
|
||||
function handlePluginUnloaded(payload: { pluginId: string }): void {
|
||||
unwatchPlugin(payload.pluginId);
|
||||
}
|
||||
|
||||
lifecycle.on("plugin.loaded", handlePluginLoaded);
|
||||
lifecycle.on("plugin.enabled", handlePluginEnabled);
|
||||
lifecycle.on("plugin.disabled", handlePluginDisabled);
|
||||
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
return {
|
||||
watch: watchPlugin,
|
||||
unwatch: unwatchPlugin,
|
||||
close,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* PluginEventBus — typed in-process event bus for the Paperclip plugin system.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Deliver core domain events to subscribing plugin workers (server-side).
|
||||
* - Apply `EventFilter` server-side so filtered-out events never reach the handler.
|
||||
* - Namespace plugin-emitted events as `plugin.<pluginId>.<eventName>`.
|
||||
* - Guard the core namespace: plugins may not emit events with the `plugin.` prefix.
|
||||
* - Isolate subscriptions per plugin — a plugin cannot enumerate or interfere with
|
||||
* another plugin's subscriptions.
|
||||
* - Support wildcard subscriptions via prefix matching (e.g. `plugin.acme.linear.*`).
|
||||
*
|
||||
* The bus operates in-process. In the full out-of-process architecture the host
|
||||
* calls `bus.emit()` after receiving events from the DB/queue layer, and the bus
|
||||
* forwards to handlers that proxy the call to the relevant worker process via IPC.
|
||||
* That IPC layer is separate; this module only handles routing and filtering.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §16 — Event System
|
||||
* @see PLUGIN_SPEC.md §16.1 — Event Filtering
|
||||
* @see PLUGIN_SPEC.md §16.2 — Plugin-to-Plugin Events
|
||||
*/
|
||||
|
||||
import type { PluginEventType } from "@paperclipai/shared";
|
||||
import type { PluginEvent, EventFilter } from "@paperclipai/plugin-sdk";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A registered subscription record stored per plugin.
|
||||
*/
|
||||
interface Subscription {
|
||||
/** The event name or prefix pattern this subscription matches. */
|
||||
eventPattern: string;
|
||||
/** Optional server-side filter applied before delivery. */
|
||||
filter: EventFilter | null;
|
||||
/** Async handler to invoke when a matching event passes the filter. */
|
||||
handler: (event: PluginEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pattern matching helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the event type matches the subscription pattern.
|
||||
*
|
||||
* Matching rules:
|
||||
* - Exact match: `"issue.created"` matches `"issue.created"`.
|
||||
* - Wildcard suffix: `"plugin.acme.*"` matches any event type that starts with
|
||||
* `"plugin.acme."`. The wildcard `*` is only supported as a trailing token.
|
||||
*
|
||||
* No full glob syntax is supported — only trailing `*` after a `.` separator.
|
||||
*/
|
||||
function matchesPattern(eventType: string, pattern: string): boolean {
|
||||
if (pattern === eventType) return true;
|
||||
|
||||
// Trailing wildcard: "plugin.foo.*" → prefix is "plugin.foo."
|
||||
if (pattern.endsWith(".*")) {
|
||||
const prefix = pattern.slice(0, -1); // remove the trailing "*", keep the "."
|
||||
return eventType.startsWith(prefix);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the event passes all fields of the filter.
|
||||
* A `null` or empty filter object passes all events.
|
||||
*
|
||||
* **Resolution strategy per field:**
|
||||
*
|
||||
* - `projectId` — checked against `event.entityId` when `entityType === "project"`,
|
||||
* otherwise against `payload.projectId`. This covers both direct project events
|
||||
* (e.g. `project.created`) and secondary events that embed a project reference in
|
||||
* their payload (e.g. `issue.created` with `payload.projectId`).
|
||||
*
|
||||
* - `companyId` — always resolved from `payload.companyId`. Core domain events that
|
||||
* belong to a company embed the company ID in their payload.
|
||||
*
|
||||
* - `agentId` — checked against `event.entityId` when `entityType === "agent"`,
|
||||
* otherwise against `payload.agentId`. Covers both direct agent lifecycle events
|
||||
* (e.g. `agent.created`) and run-level events with `payload.agentId` (e.g.
|
||||
* `agent.run.started`).
|
||||
*
|
||||
* Multiple filter fields are ANDed — all specified fields must match.
|
||||
*/
|
||||
function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
|
||||
if (!filter) return true;
|
||||
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
|
||||
if (filter.projectId !== undefined) {
|
||||
const projectId = event.entityType === "project"
|
||||
? event.entityId
|
||||
: (typeof payload?.projectId === "string" ? payload.projectId : undefined);
|
||||
if (projectId !== filter.projectId) return false;
|
||||
}
|
||||
|
||||
if (filter.companyId !== undefined) {
|
||||
if (event.companyId !== filter.companyId) return false;
|
||||
}
|
||||
|
||||
if (filter.agentId !== undefined) {
|
||||
const agentId = event.entityType === "agent"
|
||||
? event.entityId
|
||||
: (typeof payload?.agentId === "string" ? payload.agentId : undefined);
|
||||
if (agentId !== filter.agentId) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event bus factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates and returns a new `PluginEventBus` instance.
|
||||
*
|
||||
* A single bus instance should be shared across the server process. Each
|
||||
* plugin interacts with the bus through a scoped handle obtained via
|
||||
* {@link PluginEventBus.forPlugin}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const bus = createPluginEventBus();
|
||||
*
|
||||
* // Give the Linear plugin a scoped handle
|
||||
* const linearBus = bus.forPlugin("acme.linear");
|
||||
*
|
||||
* // Subscribe from the plugin's perspective
|
||||
* linearBus.subscribe("issue.created", async (event) => {
|
||||
* // handle event
|
||||
* });
|
||||
*
|
||||
* // Emit a core domain event (called by the host, not the plugin)
|
||||
* await bus.emit({
|
||||
* eventId: "evt-1",
|
||||
* eventType: "issue.created",
|
||||
* occurredAt: new Date().toISOString(),
|
||||
* entityId: "iss-1",
|
||||
* entityType: "issue",
|
||||
* payload: { title: "Fix login bug", projectId: "proj-1" },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPluginEventBus(): PluginEventBus {
|
||||
// Subscription registry: pluginKey → list of subscriptions
|
||||
const registry = new Map<string, Subscription[]>();
|
||||
|
||||
/**
|
||||
* Retrieve or create the subscription list for a plugin.
|
||||
*/
|
||||
function subsFor(pluginId: string): Subscription[] {
|
||||
let subs = registry.get(pluginId);
|
||||
if (!subs) {
|
||||
subs = [];
|
||||
registry.set(pluginId, subs);
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event envelope to all matching subscribers across all plugins.
|
||||
*
|
||||
* Subscribers are called concurrently (Promise.all). Each handler's errors
|
||||
* are caught individually and collected in the returned `errors` array so a
|
||||
* single misbehaving plugin cannot interrupt delivery to other plugins.
|
||||
*/
|
||||
async function emit(event: PluginEvent): Promise<PluginEventBusEmitResult> {
|
||||
const errors: Array<{ pluginId: string; error: unknown }> = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [pluginId, subs] of registry) {
|
||||
for (const sub of subs) {
|
||||
if (!matchesPattern(event.eventType, sub.eventPattern)) continue;
|
||||
if (!passesFilter(event, sub.filter)) continue;
|
||||
|
||||
// Use Promise.resolve().then() so that synchronous throws from handlers
|
||||
// are also caught inside the promise chain. Calling
|
||||
// Promise.resolve(syncThrowingFn()) does NOT catch sync throws — the
|
||||
// throw escapes before Promise.resolve() can wrap it. Using .then()
|
||||
// ensures the call is deferred into the microtask queue where all
|
||||
// exceptions become rejections. Each .catch() swallows the rejection
|
||||
// and records it — the promise always resolves, so Promise.all never rejects.
|
||||
promises.push(
|
||||
Promise.resolve().then(() => sub.handler(event)).catch((error: unknown) => {
|
||||
errors.push({ pluginId, error });
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return { errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscriptions for a plugin (e.g. on worker shutdown or uninstall).
|
||||
*/
|
||||
function clearPlugin(pluginId: string): void {
|
||||
registry.delete(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a scoped handle for a specific plugin. The handle exposes only the
|
||||
* plugin's own subscription list and enforces the plugin namespace on `emit`.
|
||||
*/
|
||||
function forPlugin(pluginId: string): ScopedPluginEventBus {
|
||||
return {
|
||||
/**
|
||||
* Subscribe to a core domain event or a plugin-namespaced event.
|
||||
*
|
||||
* For wildcard subscriptions use a trailing `.*` pattern, e.g.
|
||||
* `"plugin.acme.linear.*"`.
|
||||
*
|
||||
* Requires the `events.subscribe` capability (capability enforcement is
|
||||
* done by the host layer before calling this method).
|
||||
*/
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
fnOrFilter: EventFilter | ((event: PluginEvent) => Promise<void>),
|
||||
maybeFn?: (event: PluginEvent) => Promise<void>,
|
||||
): void {
|
||||
let filter: EventFilter | null = null;
|
||||
let handler: (event: PluginEvent) => Promise<void>;
|
||||
|
||||
if (typeof fnOrFilter === "function") {
|
||||
handler = fnOrFilter;
|
||||
} else {
|
||||
filter = fnOrFilter;
|
||||
if (!maybeFn) throw new Error("Handler function is required when a filter is provided");
|
||||
handler = maybeFn;
|
||||
}
|
||||
|
||||
subsFor(pluginId).push({ eventPattern, filter, handler });
|
||||
},
|
||||
|
||||
/**
|
||||
* Emit a plugin-namespaced event. The event type is automatically
|
||||
* prefixed with `plugin.<pluginId>.` so:
|
||||
* - `emit("sync-done", payload)` becomes `"plugin.acme.linear.sync-done"`.
|
||||
*
|
||||
* Requires the `events.emit` capability (enforced by the host layer).
|
||||
*
|
||||
* @throws {Error} if `name` already contains the `plugin.` prefix
|
||||
* (prevents cross-namespace spoofing).
|
||||
*/
|
||||
async emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult> {
|
||||
if (!name || name.trim() === "") {
|
||||
throw new Error(`Plugin "${pluginId}" must provide a non-empty event name.`);
|
||||
}
|
||||
|
||||
if (!companyId || companyId.trim() === "") {
|
||||
throw new Error(`Plugin "${pluginId}" must provide a companyId when emitting events.`);
|
||||
}
|
||||
|
||||
if (name.startsWith("plugin.")) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" must not include the "plugin." prefix when emitting events. ` +
|
||||
`Emit the bare event name (e.g. "sync-done") and the bus will namespace it automatically.`,
|
||||
);
|
||||
}
|
||||
|
||||
const eventType = `plugin.${pluginId}.${name}` as const;
|
||||
const event: PluginEvent = {
|
||||
eventId: crypto.randomUUID(),
|
||||
eventType,
|
||||
companyId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actorType: "plugin",
|
||||
actorId: pluginId,
|
||||
payload,
|
||||
};
|
||||
|
||||
return emit(event);
|
||||
},
|
||||
|
||||
/** Remove all subscriptions registered by this plugin. */
|
||||
clear(): void {
|
||||
clearPlugin(pluginId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
emit,
|
||||
forPlugin,
|
||||
clearPlugin,
|
||||
/** Expose subscription count for a plugin (useful for tests and diagnostics). */
|
||||
subscriptionCount(pluginId?: string): number {
|
||||
if (pluginId !== undefined) {
|
||||
return registry.get(pluginId)?.length ?? 0;
|
||||
}
|
||||
let total = 0;
|
||||
for (const subs of registry.values()) total += subs.length;
|
||||
return total;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result returned from `emit()`. Handler errors are collected and returned
|
||||
* rather than thrown so a single misbehaving plugin cannot block delivery to
|
||||
* other plugins.
|
||||
*/
|
||||
export interface PluginEventBusEmitResult {
|
||||
/** Errors thrown by individual handlers, keyed by the plugin that failed. */
|
||||
errors: Array<{ pluginId: string; error: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full event bus — held by the host process.
|
||||
*
|
||||
* Call `forPlugin(id)` to obtain a `ScopedPluginEventBus` for each plugin worker.
|
||||
*/
|
||||
export interface PluginEventBus {
|
||||
/**
|
||||
* Emit a typed domain event to all matching subscribers.
|
||||
*
|
||||
* Called by the host when a domain event occurs (e.g. from the DB layer or
|
||||
* message queue). All registered subscriptions across all plugins are checked.
|
||||
*/
|
||||
emit(event: PluginEvent): Promise<PluginEventBusEmitResult>;
|
||||
|
||||
/**
|
||||
* Get a scoped handle for a specific plugin worker.
|
||||
*
|
||||
* The scoped handle isolates the plugin's subscriptions and enforces the
|
||||
* plugin namespace on outbound events.
|
||||
*/
|
||||
forPlugin(pluginId: string): ScopedPluginEventBus;
|
||||
|
||||
/**
|
||||
* Remove all subscriptions for a plugin (called on worker shutdown/uninstall).
|
||||
*/
|
||||
clearPlugin(pluginId: string): void;
|
||||
|
||||
/**
|
||||
* Return the total number of active subscriptions, or the count for a
|
||||
* specific plugin if `pluginId` is provided.
|
||||
*/
|
||||
subscriptionCount(pluginId?: string): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin-scoped view of the event bus. Handed to the plugin worker (or its
|
||||
* host-side proxy) during initialisation.
|
||||
*
|
||||
* Plugins use this to:
|
||||
* 1. Subscribe to domain events (with optional server-side filter).
|
||||
* 2. Emit plugin-namespaced events for other plugins to consume.
|
||||
*
|
||||
* Note: `subscribe` overloads mirror the `PluginEventsClient.on()` interface
|
||||
* from the SDK. `emit` intentionally returns `PluginEventBusEmitResult` rather
|
||||
* than `void` so the host layer can inspect handler errors; the SDK-facing
|
||||
* `PluginEventsClient.emit()` wraps this and returns `void`.
|
||||
*/
|
||||
export interface ScopedPluginEventBus {
|
||||
/**
|
||||
* Subscribe to a core domain event or a plugin-namespaced event.
|
||||
*
|
||||
* **Pattern syntax:**
|
||||
* - Exact match: `"issue.created"` — receives only that event type.
|
||||
* - Wildcard suffix: `"plugin.acme.linear.*"` — receives all events emitted by
|
||||
* the `acme.linear` plugin. The `*` is supported only as a trailing token after
|
||||
* a `.` separator; no other glob syntax is supported.
|
||||
* - Top-level plugin wildcard: `"plugin.*"` — receives all plugin-emitted events
|
||||
* regardless of which plugin emitted them.
|
||||
*
|
||||
* Wildcards apply only to the `plugin.*` namespace. Core domain events must be
|
||||
* subscribed to by exact name (e.g. `"issue.created"`, not `"issue.*"`).
|
||||
*
|
||||
* An optional `EventFilter` can be passed as the second argument to perform
|
||||
* server-side pre-filtering; filtered-out events are never delivered to the handler.
|
||||
*/
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
fn: (event: PluginEvent) => Promise<void>,
|
||||
): void;
|
||||
subscribe(
|
||||
eventPattern: PluginEventType | `plugin.${string}`,
|
||||
filter: EventFilter,
|
||||
fn: (event: PluginEvent) => Promise<void>,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Emit a plugin-namespaced event. The bus automatically prepends
|
||||
* `plugin.<pluginId>.` to the `name`, so passing `"sync-done"` from plugin
|
||||
* `"acme.linear"` produces the event type `"plugin.acme.linear.sync-done"`.
|
||||
*
|
||||
* @param name Bare event name (e.g. `"sync-done"`). Must be non-empty and
|
||||
* must not include the `plugin.` prefix — the bus adds that automatically.
|
||||
* @param companyId UUID of the company this event belongs to.
|
||||
* @param payload Arbitrary JSON-serializable data to attach to the event.
|
||||
*
|
||||
* @throws {Error} if `name` is empty or whitespace-only.
|
||||
* @throws {Error} if `name` starts with `"plugin."` (namespace spoofing guard).
|
||||
*/
|
||||
emit(name: string, companyId: string, payload: unknown): Promise<PluginEventBusEmitResult>;
|
||||
|
||||
/**
|
||||
* Remove all subscriptions registered by this plugin.
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
|
||||
type LifecycleLike = Pick<PluginLifecycleManager, "on" | "off">;
|
||||
|
||||
export interface PluginWorkerRuntimeEvent {
|
||||
type: "plugin.worker.crashed" | "plugin.worker.restarted";
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface PluginHostServiceCleanupController {
|
||||
handleWorkerEvent(event: PluginWorkerRuntimeEvent): void;
|
||||
disposeAll(): void;
|
||||
teardown(): void;
|
||||
}
|
||||
|
||||
export function createPluginHostServiceCleanup(
|
||||
lifecycle: LifecycleLike,
|
||||
disposers: Map<string, () => void>,
|
||||
): PluginHostServiceCleanupController {
|
||||
const runDispose = (pluginId: string, remove = false) => {
|
||||
const dispose = disposers.get(pluginId);
|
||||
if (!dispose) return;
|
||||
dispose();
|
||||
if (remove) {
|
||||
disposers.delete(pluginId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkerStopped = ({ pluginId }: { pluginId: string }) => {
|
||||
runDispose(pluginId);
|
||||
};
|
||||
|
||||
const handlePluginUnloaded = ({ pluginId }: { pluginId: string }) => {
|
||||
runDispose(pluginId, true);
|
||||
};
|
||||
|
||||
lifecycle.on("plugin.worker_stopped", handleWorkerStopped);
|
||||
lifecycle.on("plugin.unloaded", handlePluginUnloaded);
|
||||
|
||||
return {
|
||||
handleWorkerEvent(event) {
|
||||
if (event.type === "plugin.worker.crashed") {
|
||||
runDispose(event.pluginId);
|
||||
}
|
||||
},
|
||||
|
||||
disposeAll() {
|
||||
for (const dispose of disposers.values()) {
|
||||
dispose();
|
||||
}
|
||||
disposers.clear();
|
||||
},
|
||||
|
||||
teardown() {
|
||||
lifecycle.off("plugin.worker_stopped", handleWorkerStopped);
|
||||
lifecycle.off("plugin.unloaded", handlePluginUnloaded);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* PluginJobCoordinator — bridges the plugin lifecycle manager with the
|
||||
* job scheduler and job store.
|
||||
*
|
||||
* This service listens to lifecycle events and performs the corresponding
|
||||
* scheduler and job store operations:
|
||||
*
|
||||
* - **plugin.loaded** → sync job declarations from manifest, then register
|
||||
* the plugin with the scheduler (computes `nextRunAt` for active jobs).
|
||||
*
|
||||
* - **plugin.disabled / plugin.unloaded** → unregister the plugin from the
|
||||
* scheduler (cancels in-flight runs, clears tracking state).
|
||||
*
|
||||
* ## Why a separate coordinator?
|
||||
*
|
||||
* The lifecycle manager, scheduler, and job store are independent services
|
||||
* with clean single-responsibility boundaries. The coordinator provides
|
||||
* the "glue" between them without adding coupling. This pattern is used
|
||||
* throughout Paperclip (e.g. heartbeat service coordinates timers + runs).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see ./plugin-job-scheduler.ts — Scheduler service
|
||||
* @see ./plugin-job-store.ts — Persistence layer
|
||||
* @see ./plugin-lifecycle.ts — Plugin state machine
|
||||
*/
|
||||
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
import type { PluginJobScheduler } from "./plugin-job-scheduler.js";
|
||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for creating a PluginJobCoordinator.
|
||||
*/
|
||||
export interface PluginJobCoordinatorOptions {
|
||||
/** Drizzle database instance. */
|
||||
db: Db;
|
||||
/** The plugin lifecycle manager to listen to. */
|
||||
lifecycle: PluginLifecycleManager;
|
||||
/** The job scheduler to register/unregister plugins with. */
|
||||
scheduler: PluginJobScheduler;
|
||||
/** The job store for syncing declarations. */
|
||||
jobStore: PluginJobStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public interface of the job coordinator.
|
||||
*/
|
||||
export interface PluginJobCoordinator {
|
||||
/**
|
||||
* Start listening to lifecycle events.
|
||||
*
|
||||
* This wires up the `plugin.loaded`, `plugin.disabled`, and
|
||||
* `plugin.unloaded` event handlers.
|
||||
*/
|
||||
start(): void;
|
||||
|
||||
/**
|
||||
* Stop listening to lifecycle events.
|
||||
*
|
||||
* Removes all event subscriptions added by `start()`.
|
||||
*/
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginJobCoordinator.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const coordinator = createPluginJobCoordinator({
|
||||
* db,
|
||||
* lifecycle,
|
||||
* scheduler,
|
||||
* jobStore,
|
||||
* });
|
||||
*
|
||||
* // Start listening to lifecycle events
|
||||
* coordinator.start();
|
||||
*
|
||||
* // On server shutdown
|
||||
* coordinator.stop();
|
||||
* ```
|
||||
*/
|
||||
export function createPluginJobCoordinator(
|
||||
options: PluginJobCoordinatorOptions,
|
||||
): PluginJobCoordinator {
|
||||
const { db, lifecycle, scheduler, jobStore } = options;
|
||||
const log = logger.child({ service: "plugin-job-coordinator" });
|
||||
const registry = pluginRegistryService(db);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When a plugin is loaded (transitions to `ready`):
|
||||
* 1. Look up the manifest from the registry
|
||||
* 2. Sync job declarations from the manifest into the DB
|
||||
* 3. Register the plugin with the scheduler (computes nextRunAt)
|
||||
*/
|
||||
async function onPluginLoaded(payload: { pluginId: string; pluginKey: string }): Promise<void> {
|
||||
const { pluginId, pluginKey } = payload;
|
||||
log.info({ pluginId, pluginKey }, "plugin loaded — syncing jobs and registering with scheduler");
|
||||
|
||||
try {
|
||||
// Get the manifest from the registry
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin?.manifestJson) {
|
||||
log.warn({ pluginId, pluginKey }, "plugin loaded but no manifest found — skipping job sync");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync job declarations from the manifest
|
||||
const manifest = plugin.manifestJson;
|
||||
const jobDeclarations = manifest.jobs ?? [];
|
||||
|
||||
if (jobDeclarations.length > 0) {
|
||||
log.info(
|
||||
{ pluginId, pluginKey, jobCount: jobDeclarations.length },
|
||||
"syncing job declarations from manifest",
|
||||
);
|
||||
await jobStore.syncJobDeclarations(pluginId, jobDeclarations);
|
||||
}
|
||||
|
||||
// Register with the scheduler (computes nextRunAt for active jobs)
|
||||
await scheduler.registerPlugin(pluginId);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to sync jobs or register plugin with scheduler",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a plugin is disabled (transitions to `error` with "disabled by
|
||||
* operator" or genuine error): unregister from the scheduler.
|
||||
*/
|
||||
async function onPluginDisabled(payload: {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
reason?: string;
|
||||
}): Promise<void> {
|
||||
const { pluginId, pluginKey, reason } = payload;
|
||||
log.info(
|
||||
{ pluginId, pluginKey, reason },
|
||||
"plugin disabled — unregistering from scheduler",
|
||||
);
|
||||
|
||||
try {
|
||||
await scheduler.unregisterPlugin(pluginId);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to unregister plugin from scheduler",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a plugin is unloaded (uninstalled): unregister from the scheduler.
|
||||
*/
|
||||
async function onPluginUnloaded(payload: {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
removeData: boolean;
|
||||
}): Promise<void> {
|
||||
const { pluginId, pluginKey, removeData } = payload;
|
||||
log.info(
|
||||
{ pluginId, pluginKey, removeData },
|
||||
"plugin unloaded — unregistering from scheduler",
|
||||
);
|
||||
|
||||
try {
|
||||
await scheduler.unregisterPlugin(pluginId);
|
||||
|
||||
// If data is being purged, also delete all job definitions and runs
|
||||
if (removeData) {
|
||||
log.info({ pluginId, pluginKey }, "purging job data for uninstalled plugin");
|
||||
await jobStore.deleteAllJobs(pluginId);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"failed to unregister plugin from scheduler during unload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let attached = false;
|
||||
|
||||
// We need stable references for on/off since the lifecycle manager
|
||||
// uses them for matching. We wrap the async handlers in sync wrappers
|
||||
// that fire-and-forget (swallowing unhandled rejections via the try/catch
|
||||
// inside each handler).
|
||||
const boundOnLoaded = (payload: { pluginId: string; pluginKey: string }) => {
|
||||
void onPluginLoaded(payload);
|
||||
};
|
||||
const boundOnDisabled = (payload: { pluginId: string; pluginKey: string; reason?: string }) => {
|
||||
void onPluginDisabled(payload);
|
||||
};
|
||||
const boundOnUnloaded = (payload: { pluginId: string; pluginKey: string; removeData: boolean }) => {
|
||||
void onPluginUnloaded(payload);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (attached) return;
|
||||
attached = true;
|
||||
|
||||
lifecycle.on("plugin.loaded", boundOnLoaded);
|
||||
lifecycle.on("plugin.disabled", boundOnDisabled);
|
||||
lifecycle.on("plugin.unloaded", boundOnUnloaded);
|
||||
|
||||
log.info("plugin job coordinator started — listening to lifecycle events");
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (!attached) return;
|
||||
attached = false;
|
||||
|
||||
lifecycle.off("plugin.loaded", boundOnLoaded);
|
||||
lifecycle.off("plugin.disabled", boundOnDisabled);
|
||||
lifecycle.off("plugin.unloaded", boundOnUnloaded);
|
||||
|
||||
log.info("plugin job coordinator stopped");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* PluginJobScheduler — tick-based scheduler for plugin scheduled jobs.
|
||||
*
|
||||
* The scheduler is the central coordinator for all plugin cron jobs. It
|
||||
* periodically ticks (default every 30 seconds), queries the `plugin_jobs`
|
||||
* table for jobs whose `nextRunAt` has passed, dispatches `runJob` RPC calls
|
||||
* to the appropriate worker processes, records each execution in the
|
||||
* `plugin_job_runs` table, and advances the scheduling pointer.
|
||||
*
|
||||
* ## Responsibilities
|
||||
*
|
||||
* 1. **Tick loop** — A `setInterval`-based loop fires every `tickIntervalMs`
|
||||
* (default 30s). Each tick scans for due jobs and dispatches them.
|
||||
*
|
||||
* 2. **Cron parsing & next-run calculation** — Uses the lightweight built-in
|
||||
* cron parser ({@link parseCron}, {@link nextCronTick}) to compute the
|
||||
* `nextRunAt` timestamp after each run or when a new job is registered.
|
||||
*
|
||||
* 3. **Overlap prevention** — Before dispatching a job, the scheduler checks
|
||||
* for an existing `running` run for the same job. If one exists, the job
|
||||
* is skipped for that tick.
|
||||
*
|
||||
* 4. **Job run recording** — Every execution creates a `plugin_job_runs` row:
|
||||
* `queued` → `running` → `succeeded` | `failed`. Duration and error are
|
||||
* captured.
|
||||
*
|
||||
* 5. **Lifecycle integration** — The scheduler exposes `registerPlugin()` and
|
||||
* `unregisterPlugin()` so the host lifecycle manager can wire up job
|
||||
* scheduling when plugins start/stop. On registration, the scheduler
|
||||
* computes `nextRunAt` for all active jobs that don't already have one.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see ./plugin-job-store.ts — Persistence layer
|
||||
* @see ./cron.ts — Cron parsing utilities
|
||||
*/
|
||||
|
||||
import { and, eq, lte, or } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||
import type { PluginJobStore } from "./plugin-job-store.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { parseCron, nextCronTick, validateCron } from "./cron.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default interval between scheduler ticks (30 seconds). */
|
||||
const DEFAULT_TICK_INTERVAL_MS = 30_000;
|
||||
|
||||
/** Default timeout for a runJob RPC call (5 minutes). */
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
|
||||
/** Maximum number of concurrent job executions across all plugins. */
|
||||
const DEFAULT_MAX_CONCURRENT_JOBS = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for creating a PluginJobScheduler.
|
||||
*/
|
||||
export interface PluginJobSchedulerOptions {
|
||||
/** Drizzle database instance. */
|
||||
db: Db;
|
||||
/** Persistence layer for jobs and runs. */
|
||||
jobStore: PluginJobStore;
|
||||
/** Worker process manager for RPC calls. */
|
||||
workerManager: PluginWorkerManager;
|
||||
/** Interval between scheduler ticks in ms (default: 30s). */
|
||||
tickIntervalMs?: number;
|
||||
/** Timeout for individual job RPC calls in ms (default: 5min). */
|
||||
jobTimeoutMs?: number;
|
||||
/** Maximum number of concurrent job executions (default: 10). */
|
||||
maxConcurrentJobs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a manual job trigger.
|
||||
*/
|
||||
export interface TriggerJobResult {
|
||||
/** The created run ID. */
|
||||
runId: string;
|
||||
/** The job ID that was triggered. */
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic information about the scheduler.
|
||||
*/
|
||||
export interface SchedulerDiagnostics {
|
||||
/** Whether the tick loop is running. */
|
||||
running: boolean;
|
||||
/** Number of jobs currently executing. */
|
||||
activeJobCount: number;
|
||||
/** Set of job IDs currently in-flight. */
|
||||
activeJobIds: string[];
|
||||
/** Total number of ticks executed since start. */
|
||||
tickCount: number;
|
||||
/** Timestamp of the last tick (ISO 8601). */
|
||||
lastTickAt: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The public interface of the job scheduler.
|
||||
*/
|
||||
export interface PluginJobScheduler {
|
||||
/**
|
||||
* Start the scheduler tick loop.
|
||||
*
|
||||
* Safe to call multiple times — subsequent calls are no-ops.
|
||||
*/
|
||||
start(): void;
|
||||
|
||||
/**
|
||||
* Stop the scheduler tick loop.
|
||||
*
|
||||
* In-flight job runs are NOT cancelled — they are allowed to finish
|
||||
* naturally. The tick loop simply stops firing.
|
||||
*/
|
||||
stop(): void;
|
||||
|
||||
/**
|
||||
* Register a plugin with the scheduler.
|
||||
*
|
||||
* Computes `nextRunAt` for all active jobs that are missing it. This is
|
||||
* typically called after a plugin's worker process starts and
|
||||
* `syncJobDeclarations()` has been called.
|
||||
*
|
||||
* @param pluginId - UUID of the plugin
|
||||
*/
|
||||
registerPlugin(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unregister a plugin from the scheduler.
|
||||
*
|
||||
* Cancels any in-flight runs for the plugin and removes tracking state.
|
||||
*
|
||||
* @param pluginId - UUID of the plugin
|
||||
*/
|
||||
unregisterPlugin(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Manually trigger a specific job (outside of the cron schedule).
|
||||
*
|
||||
* Creates a run with `trigger: "manual"` and dispatches immediately,
|
||||
* respecting the overlap prevention check.
|
||||
*
|
||||
* @param jobId - UUID of the job to trigger
|
||||
* @param trigger - What triggered this run (default: "manual")
|
||||
* @returns The created run info
|
||||
* @throws {Error} if the job is not found, not active, or already running
|
||||
*/
|
||||
triggerJob(jobId: string, trigger?: "manual" | "retry"): Promise<TriggerJobResult>;
|
||||
|
||||
/**
|
||||
* Run a single scheduler tick immediately (for testing).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
tick(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get diagnostic information about the scheduler state.
|
||||
*/
|
||||
diagnostics(): SchedulerDiagnostics;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new PluginJobScheduler.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const scheduler = createPluginJobScheduler({
|
||||
* db,
|
||||
* jobStore,
|
||||
* workerManager,
|
||||
* });
|
||||
*
|
||||
* // Start the tick loop
|
||||
* scheduler.start();
|
||||
*
|
||||
* // When a plugin comes online, register it
|
||||
* await scheduler.registerPlugin(pluginId);
|
||||
*
|
||||
* // Manually trigger a job
|
||||
* const { runId } = await scheduler.triggerJob(jobId);
|
||||
*
|
||||
* // On server shutdown
|
||||
* scheduler.stop();
|
||||
* ```
|
||||
*/
|
||||
export function createPluginJobScheduler(
|
||||
options: PluginJobSchedulerOptions,
|
||||
): PluginJobScheduler {
|
||||
const {
|
||||
db,
|
||||
jobStore,
|
||||
workerManager,
|
||||
tickIntervalMs = DEFAULT_TICK_INTERVAL_MS,
|
||||
jobTimeoutMs = DEFAULT_JOB_TIMEOUT_MS,
|
||||
maxConcurrentJobs = DEFAULT_MAX_CONCURRENT_JOBS,
|
||||
} = options;
|
||||
|
||||
const log = logger.child({ service: "plugin-job-scheduler" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Timer handle for the tick loop. */
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Whether the scheduler is running. */
|
||||
let running = false;
|
||||
|
||||
/** Set of job IDs currently being executed (for overlap prevention). */
|
||||
const activeJobs = new Set<string>();
|
||||
|
||||
/** Total number of ticks since start. */
|
||||
let tickCount = 0;
|
||||
|
||||
/** Timestamp of the last tick. */
|
||||
let lastTickAt: Date | null = null;
|
||||
|
||||
/** Guard against concurrent tick execution. */
|
||||
let tickInProgress = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: tick
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single scheduler tick. Queries for due jobs and dispatches them.
|
||||
*/
|
||||
async function tick(): Promise<void> {
|
||||
// Prevent overlapping ticks (in case a tick takes longer than the interval)
|
||||
if (tickInProgress) {
|
||||
log.debug("skipping tick — previous tick still in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
tickInProgress = true;
|
||||
tickCount++;
|
||||
lastTickAt = new Date();
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// Query for jobs whose nextRunAt has passed and are active.
|
||||
// We include jobs with null nextRunAt since they may have just been
|
||||
// registered and need their first run calculated.
|
||||
const dueJobs = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobs.status, "active"),
|
||||
lte(pluginJobs.nextRunAt, now),
|
||||
),
|
||||
);
|
||||
|
||||
if (dueJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug({ count: dueJobs.length }, "found due jobs");
|
||||
|
||||
// Dispatch each due job (respecting concurrency limits)
|
||||
const dispatches: Promise<void>[] = [];
|
||||
|
||||
for (const job of dueJobs) {
|
||||
// Concurrency limit
|
||||
if (activeJobs.size >= maxConcurrentJobs) {
|
||||
log.warn(
|
||||
{ maxConcurrentJobs, activeJobCount: activeJobs.size },
|
||||
"max concurrent jobs reached, deferring remaining jobs",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Overlap prevention: skip if this job is already running
|
||||
if (activeJobs.has(job.id)) {
|
||||
log.debug(
|
||||
{ jobId: job.id, jobKey: job.jobKey, pluginId: job.pluginId },
|
||||
"skipping job — already running (overlap prevention)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the worker is available
|
||||
if (!workerManager.isRunning(job.pluginId)) {
|
||||
log.debug(
|
||||
{ jobId: job.id, pluginId: job.pluginId },
|
||||
"skipping job — worker not running",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate cron expression before dispatching
|
||||
if (!job.schedule) {
|
||||
log.warn(
|
||||
{ jobId: job.id, jobKey: job.jobKey },
|
||||
"skipping job — no schedule defined",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
dispatches.push(dispatchJob(job));
|
||||
}
|
||||
|
||||
if (dispatches.length > 0) {
|
||||
await Promise.allSettled(dispatches);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"scheduler tick error",
|
||||
);
|
||||
} finally {
|
||||
tickInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: dispatch a single job
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dispatch a single job run — create the run record, call the worker,
|
||||
* record the result, and advance the schedule pointer.
|
||||
*/
|
||||
async function dispatchJob(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
): Promise<void> {
|
||||
const { id: jobId, pluginId, jobKey, schedule } = job;
|
||||
const jobLog = log.child({ jobId, pluginId, jobKey });
|
||||
|
||||
// Mark as active (overlap prevention)
|
||||
activeJobs.add(jobId);
|
||||
|
||||
let runId: string | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// 1. Create run record
|
||||
const run = await jobStore.createRun({
|
||||
jobId,
|
||||
pluginId,
|
||||
trigger: "schedule",
|
||||
});
|
||||
runId = run.id;
|
||||
|
||||
jobLog.info({ runId }, "dispatching scheduled job");
|
||||
|
||||
// 2. Mark run as running
|
||||
await jobStore.markRunning(runId);
|
||||
|
||||
// 3. Call worker via RPC
|
||||
await workerManager.call(
|
||||
pluginId,
|
||||
"runJob",
|
||||
{
|
||||
job: {
|
||||
jobKey,
|
||||
runId,
|
||||
trigger: "schedule" as const,
|
||||
scheduledAt: (job.nextRunAt ?? new Date()).toISOString(),
|
||||
},
|
||||
},
|
||||
jobTimeoutMs,
|
||||
);
|
||||
|
||||
// 4. Mark run as succeeded
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "succeeded",
|
||||
durationMs,
|
||||
});
|
||||
|
||||
jobLog.info({ runId, durationMs }, "job completed successfully");
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
jobLog.error(
|
||||
{ runId, durationMs, err: errorMessage },
|
||||
"job execution failed",
|
||||
);
|
||||
|
||||
// Record the failure
|
||||
if (runId) {
|
||||
try {
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
durationMs,
|
||||
});
|
||||
} catch (completeErr) {
|
||||
jobLog.error(
|
||||
{
|
||||
runId,
|
||||
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||
},
|
||||
"failed to record job failure",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Remove from active set
|
||||
activeJobs.delete(jobId);
|
||||
|
||||
// 5. Always advance the schedule pointer (even on failure)
|
||||
try {
|
||||
await advanceSchedulePointer(job);
|
||||
} catch (err) {
|
||||
jobLog.error(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
"failed to advance schedule pointer",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core: manual trigger
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function triggerJob(
|
||||
jobId: string,
|
||||
trigger: "manual" | "retry" = "manual",
|
||||
): Promise<TriggerJobResult> {
|
||||
const job = await jobStore.getJobById(jobId);
|
||||
if (!job) {
|
||||
throw new Error(`Job not found: ${jobId}`);
|
||||
}
|
||||
|
||||
if (job.status !== "active") {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" is not active (status: ${job.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Overlap prevention
|
||||
if (activeJobs.has(jobId)) {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" is already running — cannot trigger while in progress`,
|
||||
);
|
||||
}
|
||||
|
||||
// Also check DB for running runs (defensive — covers multi-instance)
|
||||
const existingRuns = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobRuns.jobId, jobId),
|
||||
eq(pluginJobRuns.status, "running"),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingRuns.length > 0) {
|
||||
throw new Error(
|
||||
`Job "${job.jobKey}" already has a running execution — cannot trigger while in progress`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check worker availability
|
||||
if (!workerManager.isRunning(job.pluginId)) {
|
||||
throw new Error(
|
||||
`Worker for plugin "${job.pluginId}" is not running — cannot trigger job`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the run and dispatch (non-blocking)
|
||||
const run = await jobStore.createRun({
|
||||
jobId,
|
||||
pluginId: job.pluginId,
|
||||
trigger,
|
||||
});
|
||||
|
||||
// Dispatch in background — don't block the caller
|
||||
void dispatchManualRun(job, run.id, trigger);
|
||||
|
||||
return { runId: run.id, jobId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a manually triggered job run.
|
||||
*/
|
||||
async function dispatchManualRun(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
runId: string,
|
||||
trigger: "manual" | "retry",
|
||||
): Promise<void> {
|
||||
const { id: jobId, pluginId, jobKey } = job;
|
||||
const jobLog = log.child({ jobId, pluginId, jobKey, runId, trigger });
|
||||
|
||||
activeJobs.add(jobId);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await jobStore.markRunning(runId);
|
||||
|
||||
await workerManager.call(
|
||||
pluginId,
|
||||
"runJob",
|
||||
{
|
||||
job: {
|
||||
jobKey,
|
||||
runId,
|
||||
trigger,
|
||||
scheduledAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
jobTimeoutMs,
|
||||
);
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "succeeded",
|
||||
durationMs,
|
||||
});
|
||||
|
||||
jobLog.info({ durationMs }, "manual job completed successfully");
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
jobLog.error({ durationMs, err: errorMessage }, "manual job failed");
|
||||
|
||||
try {
|
||||
await jobStore.completeRun(runId, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
durationMs,
|
||||
});
|
||||
} catch (completeErr) {
|
||||
jobLog.error(
|
||||
{
|
||||
err: completeErr instanceof Error ? completeErr.message : String(completeErr),
|
||||
},
|
||||
"failed to record manual job failure",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Schedule pointer management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the `lastRunAt` and `nextRunAt` timestamps on a job after a run.
|
||||
*/
|
||||
async function advanceSchedulePointer(
|
||||
job: typeof pluginJobs.$inferSelect,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
let nextRunAt: Date | null = null;
|
||||
|
||||
if (job.schedule) {
|
||||
const validationError = validateCron(job.schedule);
|
||||
if (validationError) {
|
||||
log.warn(
|
||||
{ jobId: job.id, schedule: job.schedule, error: validationError },
|
||||
"invalid cron schedule — cannot compute next run",
|
||||
);
|
||||
} else {
|
||||
const cron = parseCron(job.schedule);
|
||||
nextRunAt = nextCronTick(cron, now);
|
||||
}
|
||||
}
|
||||
|
||||
await jobStore.updateRunTimestamps(job.id, now, nextRunAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all active jobs for a plugin have a `nextRunAt` value.
|
||||
* Called when a plugin is registered with the scheduler.
|
||||
*/
|
||||
async function ensureNextRunTimestamps(pluginId: string): Promise<void> {
|
||||
const jobs = await jobStore.listJobs(pluginId, "active");
|
||||
|
||||
for (const job of jobs) {
|
||||
// Skip jobs that already have a valid nextRunAt in the future
|
||||
if (job.nextRunAt && job.nextRunAt.getTime() > Date.now()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip jobs without a schedule
|
||||
if (!job.schedule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationError = validateCron(job.schedule);
|
||||
if (validationError) {
|
||||
log.warn(
|
||||
{ jobId: job.id, jobKey: job.jobKey, schedule: job.schedule, error: validationError },
|
||||
"skipping job with invalid cron schedule",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cron = parseCron(job.schedule);
|
||||
const nextRunAt = nextCronTick(cron, new Date());
|
||||
|
||||
if (nextRunAt) {
|
||||
await jobStore.updateRunTimestamps(
|
||||
job.id,
|
||||
job.lastRunAt ?? new Date(0),
|
||||
nextRunAt,
|
||||
);
|
||||
log.debug(
|
||||
{ jobId: job.id, jobKey: job.jobKey, nextRunAt: nextRunAt.toISOString() },
|
||||
"computed nextRunAt for job",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Plugin registration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function registerPlugin(pluginId: string): Promise<void> {
|
||||
log.info({ pluginId }, "registering plugin with job scheduler");
|
||||
await ensureNextRunTimestamps(pluginId);
|
||||
}
|
||||
|
||||
async function unregisterPlugin(pluginId: string): Promise<void> {
|
||||
log.info({ pluginId }, "unregistering plugin from job scheduler");
|
||||
|
||||
// Cancel any in-flight run records for this plugin that are still
|
||||
// queued or running. Active jobs in-memory will finish naturally.
|
||||
try {
|
||||
const runningRuns = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobRuns.pluginId, pluginId),
|
||||
or(
|
||||
eq(pluginJobRuns.status, "running"),
|
||||
eq(pluginJobRuns.status, "queued"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const run of runningRuns) {
|
||||
await jobStore.completeRun(run.id, {
|
||||
status: "cancelled",
|
||||
error: "Plugin unregistered",
|
||||
durationMs: run.startedAt
|
||||
? Date.now() - run.startedAt.getTime()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
pluginId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"error cancelling in-flight runs during unregister",
|
||||
);
|
||||
}
|
||||
|
||||
// Remove any active tracking for jobs owned by this plugin
|
||||
const jobs = await jobStore.listJobs(pluginId);
|
||||
for (const job of jobs) {
|
||||
activeJobs.delete(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle: start / stop
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function start(): void {
|
||||
if (running) {
|
||||
log.debug("scheduler already running");
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
tickTimer = setInterval(() => {
|
||||
void tick();
|
||||
}, tickIntervalMs);
|
||||
|
||||
log.info(
|
||||
{ tickIntervalMs, maxConcurrentJobs },
|
||||
"plugin job scheduler started",
|
||||
);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
// Always clear the timer defensively, even if `running` is already false,
|
||||
// to prevent leaked interval timers.
|
||||
if (tickTimer !== null) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
|
||||
if (!running) return;
|
||||
running = false;
|
||||
|
||||
log.info(
|
||||
{ activeJobCount: activeJobs.size },
|
||||
"plugin job scheduler stopped",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Diagnostics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function diagnostics(): SchedulerDiagnostics {
|
||||
return {
|
||||
running,
|
||||
activeJobCount: activeJobs.size,
|
||||
activeJobIds: [...activeJobs],
|
||||
tickCount,
|
||||
lastTickAt: lastTickAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
registerPlugin,
|
||||
unregisterPlugin,
|
||||
triggerJob,
|
||||
tick,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Plugin Job Store — persistence layer for scheduled plugin jobs and their
|
||||
* execution history.
|
||||
*
|
||||
* This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is
|
||||
* the server-side backing store for the `ctx.jobs` SDK surface exposed to
|
||||
* plugin workers.
|
||||
*
|
||||
* ## Responsibilities
|
||||
*
|
||||
* 1. **Sync job declarations** — When a plugin is installed or started, the
|
||||
* host calls `syncJobDeclarations()` to upsert the manifest's declared jobs
|
||||
* into the `plugin_jobs` table. Jobs removed from the manifest are marked
|
||||
* `paused` (not deleted) to preserve history.
|
||||
*
|
||||
* 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin.
|
||||
*
|
||||
* 3. **Run lifecycle** — Create job run records, update their status, and
|
||||
* record results (duration, errors, logs).
|
||||
*
|
||||
* 4. **Next-run calculation** — After a run completes the host should call
|
||||
* `updateNextRunAt()` with the next cron tick so the scheduler knows when
|
||||
* to fire next.
|
||||
*
|
||||
* The capability check (`jobs.schedule`) is enforced upstream by the host
|
||||
* client factory and manifest validator — this store trusts that the caller
|
||||
* has already been authorised.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables
|
||||
*/
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db";
|
||||
import type {
|
||||
PluginJobDeclaration,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginJobRecord,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
/**
|
||||
* The statuses used for job *definitions* in the `plugin_jobs` table.
|
||||
* Aliased from `PluginJobRecord` to keep the store API aligned with
|
||||
* the domain type (`"active" | "paused" | "failed"`).
|
||||
*/
|
||||
type JobDefinitionStatus = PluginJobRecord["status"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input for creating a job run record.
|
||||
*/
|
||||
export interface CreateJobRunInput {
|
||||
/** FK to the plugin_jobs row. */
|
||||
jobId: string;
|
||||
/** FK to the plugins row. */
|
||||
pluginId: string;
|
||||
/** What triggered this run. */
|
||||
trigger: PluginJobRunTrigger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for completing (or failing) a job run.
|
||||
*/
|
||||
export interface CompleteJobRunInput {
|
||||
/** Final run status. */
|
||||
status: PluginJobRunStatus;
|
||||
/** Error message if the run failed. */
|
||||
error?: string | null;
|
||||
/** Run duration in milliseconds. */
|
||||
durationMs?: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a PluginJobStore backed by the given Drizzle database instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const jobStore = pluginJobStore(db);
|
||||
*
|
||||
* // On plugin install/start — sync declared jobs into the DB
|
||||
* await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []);
|
||||
*
|
||||
* // Before dispatching a runJob RPC — create a run record
|
||||
* const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" });
|
||||
*
|
||||
* // After the RPC completes — record the result
|
||||
* await jobStore.completeRun(run.id, {
|
||||
* status: "succeeded",
|
||||
* durationMs: Date.now() - startedAt,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function pluginJobStore(db: Db) {
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function assertPluginExists(pluginId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ id: plugins.id })
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
if (rows.length === 0) {
|
||||
throw notFound(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// =====================================================================
|
||||
// Job declarations (plugin_jobs)
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Sync declared jobs from a plugin manifest into the `plugin_jobs` table.
|
||||
*
|
||||
* This is called at plugin install and on each worker startup so the DB
|
||||
* always reflects the manifest's declared jobs:
|
||||
*
|
||||
* - **New jobs** are inserted with status `active`.
|
||||
* - **Existing jobs** have their `schedule` updated if it changed.
|
||||
* - **Removed jobs** (present in DB but absent from the manifest) are
|
||||
* set to `paused` so their history is preserved.
|
||||
*
|
||||
* The unique constraint `(pluginId, jobKey)` is used for conflict
|
||||
* resolution.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param declarations - Job declarations from the plugin manifest
|
||||
*/
|
||||
async syncJobDeclarations(
|
||||
pluginId: string,
|
||||
declarations: PluginJobDeclaration[],
|
||||
): Promise<void> {
|
||||
await assertPluginExists(pluginId);
|
||||
|
||||
// Fetch existing jobs for this plugin
|
||||
const existingJobs = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId));
|
||||
|
||||
const existingByKey = new Map(
|
||||
existingJobs.map((j) => [j.jobKey, j]),
|
||||
);
|
||||
|
||||
const declaredKeys = new Set<string>();
|
||||
|
||||
// Upsert each declared job
|
||||
for (const decl of declarations) {
|
||||
declaredKeys.add(decl.jobKey);
|
||||
|
||||
const existing = existingByKey.get(decl.jobKey);
|
||||
const schedule = decl.schedule ?? "";
|
||||
|
||||
if (existing) {
|
||||
// Update schedule if it changed; re-activate if it was paused
|
||||
const updates: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existing.schedule !== schedule) {
|
||||
updates.schedule = schedule;
|
||||
}
|
||||
if (existing.status === "paused") {
|
||||
updates.status = "active";
|
||||
}
|
||||
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set(updates)
|
||||
.where(eq(pluginJobs.id, existing.id));
|
||||
} else {
|
||||
// Insert new job
|
||||
await db.insert(pluginJobs).values({
|
||||
pluginId,
|
||||
jobKey: decl.jobKey,
|
||||
schedule,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pause jobs that are no longer declared in the manifest
|
||||
for (const existing of existingJobs) {
|
||||
if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({ status: "paused", updatedAt: new Date() })
|
||||
.where(eq(pluginJobs.id, existing.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List all jobs for a plugin, optionally filtered by status.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param status - Optional status filter
|
||||
*/
|
||||
async listJobs(
|
||||
pluginId: string,
|
||||
status?: JobDefinitionStatus,
|
||||
): Promise<(typeof pluginJobs.$inferSelect)[]> {
|
||||
const conditions = [eq(pluginJobs.pluginId, pluginId)];
|
||||
if (status) {
|
||||
conditions.push(eq(pluginJobs.status, status));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(...conditions));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single job by its composite key `(pluginId, jobKey)`.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param jobKey - Stable job identifier from the manifest
|
||||
* @returns The job row, or `null` if not found
|
||||
*/
|
||||
async getJobByKey(
|
||||
pluginId: string,
|
||||
jobKey: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginJobs.pluginId, pluginId),
|
||||
eq(pluginJobs.jobKey, jobKey),
|
||||
),
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single job by its primary key (UUID).
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @returns The job row, or `null` if not found
|
||||
*/
|
||||
async getJobById(
|
||||
jobId: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single job by ID, scoped to a specific plugin.
|
||||
*
|
||||
* Returns `null` if the job does not exist or does not belong to the
|
||||
* given plugin — callers should treat both cases as "not found".
|
||||
*/
|
||||
async getJobByIdForPlugin(
|
||||
pluginId: string,
|
||||
jobId: string,
|
||||
): Promise<(typeof pluginJobs.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId)));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a job's status.
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @param status - New status
|
||||
*/
|
||||
async updateJobStatus(
|
||||
jobId: string,
|
||||
status: JobDefinitionStatus,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the `lastRunAt` and `nextRunAt` timestamps on a job.
|
||||
*
|
||||
* Called by the scheduler after a run completes to advance the
|
||||
* scheduling pointer.
|
||||
*
|
||||
* @param jobId - UUID of the job row
|
||||
* @param lastRunAt - When the last run started
|
||||
* @param nextRunAt - When the next run should fire
|
||||
*/
|
||||
async updateRunTimestamps(
|
||||
jobId: string,
|
||||
lastRunAt: Date,
|
||||
nextRunAt: Date | null,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobs)
|
||||
.set({
|
||||
lastRunAt,
|
||||
nextRunAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobs.id, jobId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all jobs (and cascaded runs) owned by a plugin.
|
||||
*
|
||||
* Called during plugin uninstall when `removeData = true`.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
*/
|
||||
async deleteAllJobs(pluginId: string): Promise<void> {
|
||||
await db
|
||||
.delete(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId));
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Job runs (plugin_job_runs)
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Create a new job run record with status `queued`.
|
||||
*
|
||||
* The caller should create the run record *before* dispatching the
|
||||
* `runJob` RPC to the worker, then update it to `running` once the
|
||||
* worker begins execution.
|
||||
*
|
||||
* @param input - Job run input (jobId, pluginId, trigger)
|
||||
* @returns The newly created run row
|
||||
*/
|
||||
async createRun(
|
||||
input: CreateJobRunInput,
|
||||
): Promise<typeof pluginJobRuns.$inferSelect> {
|
||||
const rows = await db
|
||||
.insert(pluginJobRuns)
|
||||
.values({
|
||||
jobId: input.jobId,
|
||||
pluginId: input.pluginId,
|
||||
trigger: input.trigger,
|
||||
status: "queued",
|
||||
})
|
||||
.returning();
|
||||
|
||||
return rows[0]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark a run as `running` and set its `startedAt` timestamp.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
*/
|
||||
async markRunning(runId: string): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobRuns)
|
||||
.set({
|
||||
status: "running" as PluginJobRunStatus,
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a run — set its final status, error, duration, and
|
||||
* `finishedAt` timestamp.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
* @param input - Completion details
|
||||
*/
|
||||
async completeRun(
|
||||
runId: string,
|
||||
input: CompleteJobRunInput,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(pluginJobRuns)
|
||||
.set({
|
||||
status: input.status,
|
||||
error: input.error ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
finishedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a run by its primary key.
|
||||
*
|
||||
* @param runId - UUID of the run row
|
||||
* @returns The run row, or `null` if not found
|
||||
*/
|
||||
async getRunById(
|
||||
runId: string,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect) | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(eq(pluginJobRuns.id, runId));
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* List runs for a specific job, ordered by creation time descending.
|
||||
*
|
||||
* @param jobId - UUID of the job
|
||||
* @param limit - Maximum number of rows to return (default: 50)
|
||||
*/
|
||||
async listRunsByJob(
|
||||
jobId: string,
|
||||
limit = 50,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(eq(pluginJobRuns.jobId, jobId))
|
||||
.orderBy(desc(pluginJobRuns.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
|
||||
/**
|
||||
* List runs for a plugin, optionally filtered by status.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param status - Optional status filter
|
||||
* @param limit - Maximum number of rows to return (default: 50)
|
||||
*/
|
||||
async listRunsByPlugin(
|
||||
pluginId: string,
|
||||
status?: PluginJobRunStatus,
|
||||
limit = 50,
|
||||
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
|
||||
const conditions = [eq(pluginJobRuns.pluginId, pluginId)];
|
||||
if (status) {
|
||||
conditions.push(eq(pluginJobRuns.status, status));
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(pluginJobRuns)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pluginJobRuns.createdAt))
|
||||
.limit(limit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Type alias for the return value of `pluginJobStore()`. */
|
||||
export type PluginJobStore = ReturnType<typeof pluginJobStore>;
|
||||
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* PluginLifecycleManager — state-machine controller for plugin status
|
||||
* transitions and worker process coordination.
|
||||
*
|
||||
* Each plugin moves through a well-defined state machine:
|
||||
*
|
||||
* ```
|
||||
* installed ──→ ready ──→ disabled
|
||||
* │ │ │
|
||||
* │ ├──→ error│
|
||||
* │ ↓ │
|
||||
* │ upgrade_pending │
|
||||
* │ │ │
|
||||
* ↓ ↓ ↓
|
||||
* uninstalled
|
||||
* ```
|
||||
*
|
||||
* The lifecycle manager:
|
||||
*
|
||||
* 1. **Validates transitions** — Only transitions defined in
|
||||
* `VALID_TRANSITIONS` are allowed; invalid transitions throw.
|
||||
*
|
||||
* 2. **Coordinates workers** — When a plugin moves to `ready`, its
|
||||
* worker process is started. When it moves out of `ready`, the
|
||||
* worker is stopped gracefully.
|
||||
*
|
||||
* 3. **Emits events** — `plugin.loaded`, `plugin.enabled`,
|
||||
* `plugin.disabled`, `plugin.unloaded`, `plugin.status_changed`
|
||||
* events are emitted so that other services (job coordinator,
|
||||
* tool dispatcher, event bus) can react accordingly.
|
||||
*
|
||||
* 4. **Persists state** — Status changes are written to the database
|
||||
* through the plugin registry service.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||
*/
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
PluginStatus,
|
||||
PluginRecord,
|
||||
PaperclipPluginManifestV1,
|
||||
} from "@paperclipai/shared";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { pluginLoader, type PluginLoader } from "./plugin-loader.js";
|
||||
import type { PluginWorkerManager, WorkerStartOptions } from "./plugin-worker-manager.js";
|
||||
import { badRequest, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Valid state transitions for the plugin lifecycle.
|
||||
*
|
||||
* installed → ready (initial load succeeds)
|
||||
* installed → error (initial load fails)
|
||||
* installed → uninstalled (abort installation)
|
||||
*
|
||||
* ready → disabled (operator disables plugin)
|
||||
* ready → error (runtime failure)
|
||||
* ready → upgrade_pending (upgrade with new capabilities)
|
||||
* ready → uninstalled (uninstall)
|
||||
*
|
||||
* disabled → ready (operator re-enables plugin)
|
||||
* disabled → uninstalled (uninstall while disabled)
|
||||
*
|
||||
* error → ready (retry / recovery)
|
||||
* error → uninstalled (give up and uninstall)
|
||||
*
|
||||
* upgrade_pending → ready (operator approves new capabilities)
|
||||
* upgrade_pending → error (upgrade worker fails)
|
||||
* upgrade_pending → uninstalled (reject upgrade and uninstall)
|
||||
*
|
||||
* uninstalled → installed (reinstall)
|
||||
*/
|
||||
const VALID_TRANSITIONS: Record<string, readonly PluginStatus[]> = {
|
||||
installed: ["ready", "error", "uninstalled"],
|
||||
ready: ["ready", "disabled", "error", "upgrade_pending", "uninstalled"],
|
||||
disabled: ["ready", "uninstalled"],
|
||||
error: ["ready", "uninstalled"],
|
||||
upgrade_pending: ["ready", "error", "uninstalled"],
|
||||
uninstalled: ["installed"], // reinstall
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a transition from `from` → `to` is valid.
|
||||
*/
|
||||
function isValidTransition(from: PluginStatus, to: PluginStatus): boolean {
|
||||
return VALID_TRANSITIONS[from]?.includes(to) ?? false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Events emitted by the PluginLifecycleManager.
|
||||
* Consumers can subscribe to these for routing-table updates, UI refresh
|
||||
* notifications, and observability.
|
||||
*/
|
||||
export interface PluginLifecycleEvents {
|
||||
/** Emitted after a plugin is loaded (installed → ready). */
|
||||
"plugin.loaded": { pluginId: string; pluginKey: string };
|
||||
/** Emitted after a plugin transitions to ready (enabled). */
|
||||
"plugin.enabled": { pluginId: string; pluginKey: string };
|
||||
/** Emitted after a plugin is disabled (ready → disabled). */
|
||||
"plugin.disabled": { pluginId: string; pluginKey: string; reason?: string };
|
||||
/** Emitted after a plugin is unloaded (any → uninstalled). */
|
||||
"plugin.unloaded": { pluginId: string; pluginKey: string; removeData: boolean };
|
||||
/** Emitted on any status change. */
|
||||
"plugin.status_changed": {
|
||||
pluginId: string;
|
||||
pluginKey: string;
|
||||
previousStatus: PluginStatus;
|
||||
newStatus: PluginStatus;
|
||||
};
|
||||
/** Emitted when a plugin enters an error state. */
|
||||
"plugin.error": { pluginId: string; pluginKey: string; error: string };
|
||||
/** Emitted when a plugin enters upgrade_pending. */
|
||||
"plugin.upgrade_pending": { pluginId: string; pluginKey: string };
|
||||
/** Emitted when a plugin worker process has been started. */
|
||||
"plugin.worker_started": { pluginId: string; pluginKey: string };
|
||||
/** Emitted when a plugin worker process has been stopped. */
|
||||
"plugin.worker_stopped": { pluginId: string; pluginKey: string };
|
||||
}
|
||||
|
||||
type LifecycleEventName = keyof PluginLifecycleEvents;
|
||||
type LifecycleEventPayload<K extends LifecycleEventName> = PluginLifecycleEvents[K];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginLifecycleManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginLifecycleManager {
|
||||
/**
|
||||
* Load a newly installed plugin – transitions `installed` → `ready`.
|
||||
*
|
||||
* This is called after the registry has persisted the initial install record.
|
||||
* The caller should have already spawned the worker and performed health
|
||||
* checks before calling this. If the worker fails, call `markError` instead.
|
||||
*/
|
||||
load(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Enable a plugin that is in `disabled`, `error`, or `upgrade_pending` state.
|
||||
* Transitions → `ready`.
|
||||
*/
|
||||
enable(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Disable a running plugin.
|
||||
* Transitions `ready` → `disabled`.
|
||||
*/
|
||||
disable(pluginId: string, reason?: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Unload (uninstall) a plugin from any active state.
|
||||
* Transitions → `uninstalled`.
|
||||
*
|
||||
* When `removeData` is true, the plugin row and cascaded config are
|
||||
* hard-deleted. Otherwise a soft-delete sets status to `uninstalled`.
|
||||
*/
|
||||
unload(pluginId: string, removeData?: boolean): Promise<PluginRecord | null>;
|
||||
|
||||
/**
|
||||
* Mark a plugin as errored (e.g. worker crash, health-check failure).
|
||||
* Transitions → `error`.
|
||||
*/
|
||||
markError(pluginId: string, error: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Mark a plugin as requiring upgrade approval.
|
||||
* Transitions `ready` → `upgrade_pending`.
|
||||
*/
|
||||
markUpgradePending(pluginId: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Upgrade a plugin to a newer version.
|
||||
* This is a placeholder that handles the lifecycle state transition.
|
||||
* The actual package installation is handled by plugin-loader.
|
||||
*
|
||||
* If the upgrade adds new capabilities, transitions to `upgrade_pending`.
|
||||
* Otherwise, transitions to `ready` directly.
|
||||
*/
|
||||
upgrade(pluginId: string, version?: string): Promise<PluginRecord>;
|
||||
|
||||
/**
|
||||
* Start the worker process for a plugin that is already in `ready` state.
|
||||
*
|
||||
* This is used by the server startup orchestration to start workers for
|
||||
* plugins that were persisted as `ready`. It requires a `PluginWorkerManager`
|
||||
* to have been provided at construction time.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to start
|
||||
* @param options - Worker start options (entrypoint path, config, etc.)
|
||||
* @throws if no worker manager is configured or the plugin is not ready
|
||||
*/
|
||||
startWorker(pluginId: string, options: WorkerStartOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Stop the worker process for a plugin without changing lifecycle state.
|
||||
*
|
||||
* This is used during server shutdown to gracefully stop all workers.
|
||||
* It does not transition the plugin state — plugins remain in their
|
||||
* current status so they can be restarted on next server boot.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to stop
|
||||
*/
|
||||
stopWorker(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Restart the worker process for a running plugin.
|
||||
*
|
||||
* Stops and re-starts the worker process. The plugin remains in `ready`
|
||||
* state throughout. This is typically called after a config change.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to restart
|
||||
* @throws if no worker manager is configured or the plugin is not ready
|
||||
*/
|
||||
restartWorker(pluginId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current lifecycle state for a plugin.
|
||||
*/
|
||||
getStatus(pluginId: string): Promise<PluginStatus | null>;
|
||||
|
||||
/**
|
||||
* Check whether a transition is allowed from the plugin's current state.
|
||||
*/
|
||||
canTransition(pluginId: string, to: PluginStatus): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Subscribe to lifecycle events.
|
||||
*/
|
||||
on<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribe from lifecycle events.
|
||||
*/
|
||||
off<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Subscribe to a lifecycle event once.
|
||||
*/
|
||||
once<K extends LifecycleEventName>(
|
||||
event: K,
|
||||
listener: (payload: LifecycleEventPayload<K>) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for constructing a PluginLifecycleManager.
|
||||
*/
|
||||
export interface PluginLifecycleManagerOptions {
|
||||
/** Plugin loader instance. Falls back to the default if omitted. */
|
||||
loader?: PluginLoader;
|
||||
|
||||
/**
|
||||
* Worker process manager. When provided, lifecycle transitions that bring
|
||||
* a plugin online (load, enable, upgrade-to-ready) will start the worker
|
||||
* process, and transitions that take a plugin offline (disable, unload,
|
||||
* markError) will stop it.
|
||||
*
|
||||
* When omitted the lifecycle manager operates in state-only mode — the
|
||||
* caller is responsible for managing worker processes externally.
|
||||
*/
|
||||
workerManager?: PluginWorkerManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginLifecycleManager.
|
||||
*
|
||||
* This service orchestrates plugin state transitions on top of the
|
||||
* `pluginRegistryService` (which handles raw DB persistence). It enforces
|
||||
* the lifecycle state machine, emits events for downstream consumers
|
||||
* (routing tables, UI, observability), and manages worker processes via
|
||||
* the `PluginWorkerManager` when one is provided.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const lifecycle = pluginLifecycleManager(db, {
|
||||
* workerManager: createPluginWorkerManager(),
|
||||
* });
|
||||
* lifecycle.on("plugin.enabled", ({ pluginId }) => { ... });
|
||||
* await lifecycle.load(pluginId);
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugins.status` column
|
||||
* @see PLUGIN_SPEC.md §12 — Process Model
|
||||
*/
|
||||
export function pluginLifecycleManager(
|
||||
db: Db,
|
||||
options?: PluginLoader | PluginLifecycleManagerOptions,
|
||||
): PluginLifecycleManager {
|
||||
// Support the legacy signature: pluginLifecycleManager(db, loader)
|
||||
// as well as the new options object form.
|
||||
let loaderArg: PluginLoader | undefined;
|
||||
let workerManager: PluginWorkerManager | undefined;
|
||||
|
||||
if (options && typeof options === "object" && "discoverAll" in options) {
|
||||
// Legacy: second arg is a PluginLoader directly
|
||||
loaderArg = options as PluginLoader;
|
||||
} else if (options && typeof options === "object") {
|
||||
const opts = options as PluginLifecycleManagerOptions;
|
||||
loaderArg = opts.loader;
|
||||
workerManager = opts.workerManager;
|
||||
}
|
||||
|
||||
const registry = pluginRegistryService(db);
|
||||
const pluginLoaderInstance = loaderArg ?? pluginLoader(db);
|
||||
const emitter = new EventEmitter();
|
||||
emitter.setMaxListeners(100); // plugins may have many listeners; 100 is a safe upper bound
|
||||
|
||||
const log = logger.child({ service: "plugin-lifecycle" });
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function requirePlugin(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin) throw notFound(`Plugin not found: ${pluginId}`);
|
||||
return plugin as PluginRecord;
|
||||
}
|
||||
|
||||
function assertTransition(plugin: PluginRecord, to: PluginStatus): void {
|
||||
if (!isValidTransition(plugin.status, to)) {
|
||||
throw badRequest(
|
||||
`Invalid lifecycle transition: ${plugin.status} → ${to} for plugin ${plugin.pluginKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(
|
||||
pluginId: string,
|
||||
to: PluginStatus,
|
||||
lastError: string | null = null,
|
||||
existingPlugin?: PluginRecord,
|
||||
): Promise<PluginRecord> {
|
||||
const plugin = existingPlugin ?? await requirePlugin(pluginId);
|
||||
assertTransition(plugin, to);
|
||||
|
||||
const previousStatus = plugin.status;
|
||||
|
||||
const updated = await registry.updateStatus(pluginId, {
|
||||
status: to,
|
||||
lastError,
|
||||
});
|
||||
|
||||
if (!updated) throw notFound(`Plugin not found after status update: ${pluginId}`);
|
||||
const result = updated as PluginRecord;
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: result.pluginKey, from: previousStatus, to },
|
||||
`plugin lifecycle: ${previousStatus} → ${to}`,
|
||||
);
|
||||
|
||||
// Emit the generic status_changed event
|
||||
emitter.emit("plugin.status_changed", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
previousStatus,
|
||||
newStatus: to,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function emitDomain(
|
||||
event: LifecycleEventName,
|
||||
payload: PluginLifecycleEvents[LifecycleEventName],
|
||||
): void {
|
||||
emitter.emit(event, payload);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Worker management helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stop the worker for a plugin if one is running.
|
||||
* This is a best-effort operation — if no worker manager is configured
|
||||
* or no worker is running, it silently succeeds.
|
||||
*/
|
||||
async function stopWorkerIfRunning(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
): Promise<void> {
|
||||
if (!workerManager) return;
|
||||
if (!workerManager.isRunning(pluginId) && !workerManager.getWorker(pluginId)) return;
|
||||
|
||||
try {
|
||||
await workerManager.stopWorker(pluginId);
|
||||
log.info({ pluginId, pluginKey }, "plugin lifecycle: worker stopped");
|
||||
emitDomain("plugin.worker_stopped", { pluginId, pluginKey });
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ pluginId, pluginKey, err: err instanceof Error ? err.message : String(err) },
|
||||
"plugin lifecycle: failed to stop worker (best-effort)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateReadyPlugin(pluginId: string): Promise<void> {
|
||||
const supportsRuntimeActivation =
|
||||
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||
&& typeof pluginLoaderInstance.loadSingle === "function";
|
||||
if (!supportsRuntimeActivation || !pluginLoaderInstance.hasRuntimeServices()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadResult = await pluginLoaderInstance.loadSingle(pluginId);
|
||||
if (!loadResult.success) {
|
||||
throw new Error(
|
||||
loadResult.error
|
||||
?? `Failed to activate plugin ${loadResult.plugin.pluginKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivatePluginRuntime(
|
||||
pluginId: string,
|
||||
pluginKey: string,
|
||||
): Promise<void> {
|
||||
const supportsRuntimeDeactivation =
|
||||
typeof pluginLoaderInstance.hasRuntimeServices === "function"
|
||||
&& typeof pluginLoaderInstance.unloadSingle === "function";
|
||||
|
||||
if (supportsRuntimeDeactivation && pluginLoaderInstance.hasRuntimeServices()) {
|
||||
await pluginLoaderInstance.unloadSingle(pluginId, pluginKey);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopWorkerIfRunning(pluginId, pluginKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// -- load -------------------------------------------------------------
|
||||
/**
|
||||
* load — Transitions a plugin to 'ready' status and starts its worker.
|
||||
*
|
||||
* This method is called after a plugin has been successfully installed and
|
||||
* validated. It marks the plugin as ready in the database and immediately
|
||||
* triggers the plugin loader to start the worker process.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to load.
|
||||
* @returns The updated plugin record.
|
||||
*/
|
||||
async load(pluginId: string): Promise<PluginRecord> {
|
||||
const result = await transition(pluginId, "ready");
|
||||
await activateReadyPlugin(pluginId);
|
||||
|
||||
emitDomain("plugin.loaded", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- enable -----------------------------------------------------------
|
||||
/**
|
||||
* enable — Re-enables a plugin that was previously in an error or upgrade state.
|
||||
*
|
||||
* Similar to load(), this method transitions the plugin to 'ready' and starts
|
||||
* its worker, but it specifically targets plugins that are currently disabled.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to enable.
|
||||
* @returns The updated plugin record.
|
||||
*/
|
||||
async enable(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Only allow enabling from disabled, error, or upgrade_pending states
|
||||
if (plugin.status !== "disabled" && plugin.status !== "error" && plugin.status !== "upgrade_pending") {
|
||||
throw badRequest(
|
||||
`Cannot enable plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'disabled', 'error', or 'upgrade_pending' status to be enabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await transition(pluginId, "ready", null, plugin);
|
||||
await activateReadyPlugin(pluginId);
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- disable ----------------------------------------------------------
|
||||
async disable(pluginId: string, reason?: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Only allow disabling from ready state
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot disable plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status to be disabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "disabled", reason ?? null, plugin);
|
||||
emitDomain("plugin.disabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
reason,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- unload -----------------------------------------------------------
|
||||
async unload(
|
||||
pluginId: string,
|
||||
removeData = false,
|
||||
): Promise<PluginRecord | null> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// If already uninstalled and removeData, hard-delete
|
||||
if (plugin.status === "uninstalled") {
|
||||
if (removeData) {
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
const deleted = await registry.uninstall(pluginId, true);
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: hard-deleted already-uninstalled plugin",
|
||||
);
|
||||
emitDomain("plugin.unloaded", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
removeData: true,
|
||||
});
|
||||
return deleted as PluginRecord | null;
|
||||
}
|
||||
throw badRequest(
|
||||
`Plugin ${plugin.pluginKey} is already uninstalled. ` +
|
||||
`Use removeData=true to permanently delete it.`,
|
||||
);
|
||||
}
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
await pluginLoaderInstance.cleanupInstallArtifacts(plugin);
|
||||
|
||||
// Perform the uninstall via registry (handles soft/hard delete)
|
||||
const result = await registry.uninstall(pluginId, removeData);
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, removeData },
|
||||
`plugin lifecycle: ${plugin.status} → uninstalled${removeData ? " (hard delete)" : ""}`,
|
||||
);
|
||||
|
||||
emitter.emit("plugin.status_changed", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
previousStatus: plugin.status,
|
||||
newStatus: "uninstalled" as PluginStatus,
|
||||
});
|
||||
|
||||
emitDomain("plugin.unloaded", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
removeData,
|
||||
});
|
||||
|
||||
return result as PluginRecord | null;
|
||||
},
|
||||
|
||||
// -- markError --------------------------------------------------------
|
||||
async markError(pluginId: string, error: string): Promise<PluginRecord> {
|
||||
// Stop the worker — the plugin is in an error state and should not
|
||||
// continue running. The worker manager's auto-restart is disabled
|
||||
// because we are intentionally taking the plugin offline.
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "error", error, plugin);
|
||||
emitDomain("plugin.error", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
error,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- markUpgradePending -----------------------------------------------
|
||||
async markUpgradePending(pluginId: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
const result = await transition(pluginId, "upgrade_pending", null, plugin);
|
||||
emitDomain("plugin.upgrade_pending", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
// -- upgrade ----------------------------------------------------------
|
||||
/**
|
||||
* Upgrade a plugin to a newer version by performing a package update and
|
||||
* managing the lifecycle state transition.
|
||||
*
|
||||
* Following PLUGIN_SPEC.md §25.3, the upgrade process:
|
||||
* 1. Stops the current worker process (if running).
|
||||
* 2. Fetches and validates the new plugin package via the `PluginLoader`.
|
||||
* 3. Compares the capabilities declared in the new manifest against the old one.
|
||||
* 4. If new capabilities are added, transitions the plugin to `upgrade_pending`
|
||||
* to await operator approval (worker stays stopped).
|
||||
* 5. If no new capabilities are added, transitions the plugin back to `ready`
|
||||
* with the updated version and manifest metadata.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin to upgrade.
|
||||
* @param version - Optional target version specifier.
|
||||
* @returns The updated `PluginRecord`.
|
||||
* @throws {BadRequest} If the plugin is not in a ready or upgrade_pending state.
|
||||
*/
|
||||
async upgrade(pluginId: string, version?: string): Promise<PluginRecord> {
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
|
||||
// Can only upgrade plugins that are ready or already in upgrade_pending
|
||||
if (plugin.status !== "ready" && plugin.status !== "upgrade_pending") {
|
||||
throw badRequest(
|
||||
`Cannot upgrade plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' or 'upgrade_pending' status to be upgraded.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, targetVersion: version },
|
||||
"plugin lifecycle: upgrade requested",
|
||||
);
|
||||
|
||||
await deactivatePluginRuntime(pluginId, plugin.pluginKey);
|
||||
|
||||
// 1. Download and validate new package via loader
|
||||
const { oldManifest, newManifest, discovered } =
|
||||
await pluginLoaderInstance.upgradePlugin(pluginId, { version });
|
||||
|
||||
log.info(
|
||||
{
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
oldVersion: oldManifest.version,
|
||||
newVersion: newManifest.version,
|
||||
},
|
||||
"plugin lifecycle: package upgraded on disk",
|
||||
);
|
||||
|
||||
// 2. Compare capabilities
|
||||
const addedCaps = newManifest.capabilities.filter(
|
||||
(cap) => !oldManifest.capabilities.includes(cap),
|
||||
);
|
||||
|
||||
// 3. Transition state
|
||||
if (addedCaps.length > 0) {
|
||||
// New capabilities require operator approval — worker stays stopped
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey, addedCaps },
|
||||
"plugin lifecycle: new capabilities detected, transitioning to upgrade_pending",
|
||||
);
|
||||
// Skip the inner stopWorkerIfRunning since we already stopped above
|
||||
const result = await transition(pluginId, "upgrade_pending", null, plugin);
|
||||
emitDomain("plugin.upgrade_pending", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
const result = await transition(pluginId, "ready", null, {
|
||||
...plugin,
|
||||
version: discovered.version,
|
||||
manifestJson: newManifest,
|
||||
} as PluginRecord);
|
||||
await activateReadyPlugin(pluginId);
|
||||
|
||||
emitDomain("plugin.loaded", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
emitDomain("plugin.enabled", {
|
||||
pluginId,
|
||||
pluginKey: result.pluginKey,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
// -- startWorker ------------------------------------------------------
|
||||
async startWorker(
|
||||
pluginId: string,
|
||||
options: WorkerStartOptions,
|
||||
): Promise<void> {
|
||||
if (!workerManager) {
|
||||
throw badRequest(
|
||||
"Cannot start worker: no PluginWorkerManager is configured. " +
|
||||
"Provide a workerManager option when constructing the lifecycle manager.",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot start worker for plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: starting worker",
|
||||
);
|
||||
|
||||
await workerManager.startWorker(pluginId, options);
|
||||
emitDomain("plugin.worker_started", {
|
||||
pluginId,
|
||||
pluginKey: plugin.pluginKey,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: worker started",
|
||||
);
|
||||
},
|
||||
|
||||
// -- stopWorker -------------------------------------------------------
|
||||
async stopWorker(pluginId: string): Promise<void> {
|
||||
if (!workerManager) return; // No worker manager — nothing to stop
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
await stopWorkerIfRunning(pluginId, plugin.pluginKey);
|
||||
},
|
||||
|
||||
// -- restartWorker ----------------------------------------------------
|
||||
async restartWorker(pluginId: string): Promise<void> {
|
||||
if (!workerManager) {
|
||||
throw badRequest(
|
||||
"Cannot restart worker: no PluginWorkerManager is configured.",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = await requirePlugin(pluginId);
|
||||
if (plugin.status !== "ready") {
|
||||
throw badRequest(
|
||||
`Cannot restart worker for plugin in status '${plugin.status}'. ` +
|
||||
`Plugin must be in 'ready' status.`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle = workerManager.getWorker(pluginId);
|
||||
if (!handle) {
|
||||
throw badRequest(
|
||||
`Cannot restart worker for plugin "${plugin.pluginKey}": no worker is running.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: restarting worker",
|
||||
);
|
||||
|
||||
await handle.restart();
|
||||
|
||||
emitDomain("plugin.worker_stopped", { pluginId, pluginKey: plugin.pluginKey });
|
||||
emitDomain("plugin.worker_started", { pluginId, pluginKey: plugin.pluginKey });
|
||||
|
||||
log.info(
|
||||
{ pluginId, pluginKey: plugin.pluginKey },
|
||||
"plugin lifecycle: worker restarted",
|
||||
);
|
||||
},
|
||||
|
||||
// -- getStatus --------------------------------------------------------
|
||||
async getStatus(pluginId: string): Promise<PluginStatus | null> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
return plugin?.status ?? null;
|
||||
},
|
||||
|
||||
// -- canTransition ----------------------------------------------------
|
||||
async canTransition(pluginId: string, to: PluginStatus): Promise<boolean> {
|
||||
const plugin = await registry.getById(pluginId);
|
||||
if (!plugin) return false;
|
||||
return isValidTransition(plugin.status, to);
|
||||
},
|
||||
|
||||
// -- Event subscriptions ----------------------------------------------
|
||||
on(event, listener) {
|
||||
emitter.on(event, listener);
|
||||
},
|
||||
|
||||
off(event, listener) {
|
||||
emitter.off(event, listener);
|
||||
},
|
||||
|
||||
once(event, listener) {
|
||||
emitter.once(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
||||
import { lt, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginLogs } from "@paperclipai/db";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
/** Default retention period: 7 days. */
|
||||
const DEFAULT_RETENTION_DAYS = 7;
|
||||
|
||||
/** Maximum rows to delete per sweep to avoid long-running transactions. */
|
||||
const DELETE_BATCH_SIZE = 5_000;
|
||||
|
||||
/** Maximum number of batches per sweep to guard against unbounded loops. */
|
||||
const MAX_ITERATIONS = 100;
|
||||
|
||||
/**
|
||||
* Delete plugin log rows older than `retentionDays`.
|
||||
*
|
||||
* Deletes in batches of `DELETE_BATCH_SIZE` to keep transaction sizes
|
||||
* bounded and avoid holding locks for extended periods.
|
||||
*
|
||||
* @returns The total number of rows deleted.
|
||||
*/
|
||||
export async function prunePluginLogs(
|
||||
db: Db,
|
||||
retentionDays: number = DEFAULT_RETENTION_DAYS,
|
||||
): Promise<number> {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - retentionDays);
|
||||
|
||||
let totalDeleted = 0;
|
||||
let iterations = 0;
|
||||
|
||||
// Delete in batches to avoid long-running transactions
|
||||
while (iterations < MAX_ITERATIONS) {
|
||||
const deleted = await db
|
||||
.delete(pluginLogs)
|
||||
.where(lt(pluginLogs.createdAt, cutoff))
|
||||
.returning({ id: pluginLogs.id })
|
||||
.then((rows) => rows.length);
|
||||
|
||||
totalDeleted += deleted;
|
||||
iterations++;
|
||||
|
||||
if (deleted < DELETE_BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
if (iterations >= MAX_ITERATIONS) {
|
||||
logger.warn(
|
||||
{ totalDeleted, iterations, cutoffDate: cutoff },
|
||||
"Plugin log retention hit iteration limit; some logs may remain",
|
||||
);
|
||||
}
|
||||
|
||||
if (totalDeleted > 0) {
|
||||
logger.info({ totalDeleted, retentionDays }, "Pruned expired plugin logs");
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a periodic plugin log cleanup interval.
|
||||
*
|
||||
* @param db - Database connection
|
||||
* @param intervalMs - How often to run (default: 1 hour)
|
||||
* @param retentionDays - How many days of logs to keep (default: 7)
|
||||
* @returns A cleanup function that stops the interval
|
||||
*/
|
||||
export function startPluginLogRetention(
|
||||
db: Db,
|
||||
intervalMs: number = 60 * 60 * 1_000,
|
||||
retentionDays: number = DEFAULT_RETENTION_DAYS,
|
||||
): () => void {
|
||||
const timer = setInterval(() => {
|
||||
prunePluginLogs(db, retentionDays).catch((err) => {
|
||||
logger.warn({ err }, "Plugin log retention sweep failed");
|
||||
});
|
||||
}, intervalMs);
|
||||
|
||||
// Run once immediately on startup
|
||||
prunePluginLogs(db, retentionDays).catch((err) => {
|
||||
logger.warn({ err }, "Initial plugin log retention sweep failed");
|
||||
});
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* PluginManifestValidator — schema validation for plugin manifest files.
|
||||
*
|
||||
* Uses the shared Zod schema (`pluginManifestV1Schema`) to validate
|
||||
* manifest payloads. Provides both a safe `parse()` variant (returns
|
||||
* a result union) and a throwing `parseOrThrow()` for HTTP error
|
||||
* propagation at install time.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10 — Plugin Manifest
|
||||
* @see packages/shared/src/validators/plugin.ts — Zod schema definition
|
||||
*/
|
||||
import { pluginManifestV1Schema } from "@paperclipai/shared";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { PLUGIN_API_VERSION } from "@paperclipai/shared";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supported manifest API versions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The set of plugin API versions this host can accept.
|
||||
* When a new API version is introduced, add it here. Old versions should be
|
||||
* retained until the host drops support for them.
|
||||
*/
|
||||
const SUPPORTED_VERSIONS = [PLUGIN_API_VERSION] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Successful parse result.
|
||||
*/
|
||||
export interface ManifestParseSuccess {
|
||||
success: true;
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Failed parse result. `errors` is a human-readable description of what went
|
||||
* wrong; `details` is the raw Zod error list for programmatic inspection.
|
||||
*/
|
||||
export interface ManifestParseFailure {
|
||||
success: false;
|
||||
errors: string;
|
||||
details: Array<{ path: (string | number)[]; message: string }>;
|
||||
}
|
||||
|
||||
/** Union of parse outcomes. */
|
||||
export type ManifestParseResult = ManifestParseSuccess | ManifestParseFailure;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManifestValidator interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Service for parsing and validating plugin manifests.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10 — Plugin Manifest
|
||||
*/
|
||||
export interface PluginManifestValidator {
|
||||
/**
|
||||
* Try to parse `input` as a plugin manifest.
|
||||
*
|
||||
* Returns a {@link ManifestParseSuccess} when the input passes all
|
||||
* validation rules, or a {@link ManifestParseFailure} with human-readable
|
||||
* error messages when it does not.
|
||||
*
|
||||
* This is the "safe" variant — it never throws.
|
||||
*/
|
||||
parse(input: unknown): ManifestParseResult;
|
||||
|
||||
/**
|
||||
* Parse `input` as a plugin manifest, throwing a 400 HttpError on failure.
|
||||
*
|
||||
* Use this at install time when an invalid manifest should surface as an
|
||||
* HTTP error to the caller.
|
||||
*
|
||||
* @throws {HttpError} 400 Bad Request if the manifest is invalid.
|
||||
*/
|
||||
parseOrThrow(input: unknown): PaperclipPluginManifestV1;
|
||||
|
||||
/**
|
||||
* Return the list of plugin API versions supported by this host.
|
||||
*
|
||||
* Callers can use this to present the supported version range to operators
|
||||
* or to decide whether a candidate plugin can be installed.
|
||||
*/
|
||||
getSupportedVersions(): readonly number[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a {@link PluginManifestValidator}.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const validator = pluginManifestValidator();
|
||||
*
|
||||
* // Safe parse — inspect the result
|
||||
* const result = validator.parse(rawManifest);
|
||||
* if (!result.success) {
|
||||
* console.error(result.errors);
|
||||
* return;
|
||||
* }
|
||||
* const manifest = result.manifest;
|
||||
*
|
||||
* // Throwing parse — use at install time
|
||||
* const manifest = validator.parseOrThrow(rawManifest);
|
||||
*
|
||||
* // Check supported versions
|
||||
* const versions = validator.getSupportedVersions(); // [1]
|
||||
* ```
|
||||
*/
|
||||
export function pluginManifestValidator(): PluginManifestValidator {
|
||||
return {
|
||||
parse(input: unknown): ManifestParseResult {
|
||||
const result = pluginManifestV1Schema.safeParse(input);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
manifest: result.data as PaperclipPluginManifestV1,
|
||||
};
|
||||
}
|
||||
|
||||
const details = result.error.errors.map((issue) => ({
|
||||
path: issue.path,
|
||||
message: issue.message,
|
||||
}));
|
||||
|
||||
const errors = details
|
||||
.map(({ path, message }) =>
|
||||
path.length > 0 ? `${path.join(".")}: ${message}` : message,
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
details,
|
||||
};
|
||||
},
|
||||
|
||||
parseOrThrow(input: unknown): PaperclipPluginManifestV1 {
|
||||
const result = this.parse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw badRequest(`Invalid plugin manifest: ${result.errors}`, result.details);
|
||||
}
|
||||
|
||||
return result.manifest;
|
||||
},
|
||||
|
||||
getSupportedVersions(): readonly number[] {
|
||||
return SUPPORTED_VERSIONS;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { asc, eq, ne, sql, and } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
plugins,
|
||||
pluginConfig,
|
||||
pluginEntities,
|
||||
pluginJobs,
|
||||
pluginJobRuns,
|
||||
pluginWebhookDeliveries,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginStatus,
|
||||
InstallPlugin,
|
||||
UpdatePluginStatus,
|
||||
UpsertPluginConfig,
|
||||
PatchPluginConfig,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
PluginJobStatus,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginWebhookDeliveryStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect if a Postgres error is a unique-constraint violation on the
|
||||
* `plugins_plugin_key_idx` unique index.
|
||||
*/
|
||||
function isPluginKeyConflict(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
||||
const constraint = err.constraint ?? err.constraint_name;
|
||||
return err.code === "23505" && constraint === "plugins_plugin_key_idx";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PluginRegistry – CRUD operations for the `plugins` and `plugin_config`
|
||||
* tables. Follows the same factory-function pattern used by the rest of
|
||||
* the Paperclip service layer.
|
||||
*
|
||||
* This is the lowest-level persistence layer for plugins. Higher-level
|
||||
* concerns such as lifecycle state-machine enforcement and capability
|
||||
* gating are handled by {@link pluginLifecycleManager} and
|
||||
* {@link pluginCapabilityValidator} respectively.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — Required Tables
|
||||
*/
|
||||
export function pluginRegistryService(db: Db) {
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getByKey(pluginKey: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.pluginKey, pluginKey))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function nextInstallOrder(): Promise<number> {
|
||||
const result = await db
|
||||
.select({ maxOrder: sql<number>`coalesce(max(${plugins.installOrder}), 0)` })
|
||||
.from(plugins);
|
||||
return (result[0]?.maxOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// ----- Read -----------------------------------------------------------
|
||||
|
||||
/** List all registered plugins ordered by install order. */
|
||||
list: () =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/**
|
||||
* List installed plugins (excludes soft-deleted/uninstalled).
|
||||
* Use for Plugin Manager and default API list so uninstalled plugins do not appear.
|
||||
*/
|
||||
listInstalled: () =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(ne(plugins.status, "uninstalled"))
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/** List plugins filtered by status. */
|
||||
listByStatus: (status: PluginStatus) =>
|
||||
db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.status, status))
|
||||
.orderBy(asc(plugins.installOrder)),
|
||||
|
||||
/** Get a single plugin by primary key. */
|
||||
getById,
|
||||
|
||||
/** Get a single plugin by its unique `pluginKey`. */
|
||||
getByKey,
|
||||
|
||||
// ----- Install / Register --------------------------------------------
|
||||
|
||||
/**
|
||||
* Register (install) a new plugin.
|
||||
*
|
||||
* The caller is expected to have already resolved and validated the
|
||||
* manifest from the package. This method persists the plugin row and
|
||||
* assigns the next install order.
|
||||
*/
|
||||
install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => {
|
||||
const existing = await getByKey(manifest.id);
|
||||
if (existing) {
|
||||
if (existing.status !== "uninstalled") {
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
}
|
||||
|
||||
// Reinstall after soft-delete: reactivate the existing row so plugin-scoped
|
||||
// data and references remain stable across uninstall/reinstall cycles.
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
packageName: input.packageName,
|
||||
packagePath: input.packagePath ?? null,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "installed" as PluginStatus,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
const installOrder = await nextInstallOrder();
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(plugins)
|
||||
.values({
|
||||
pluginKey: manifest.id,
|
||||
packageName: input.packageName,
|
||||
version: manifest.version,
|
||||
apiVersion: manifest.apiVersion,
|
||||
categories: manifest.categories,
|
||||
manifestJson: manifest,
|
||||
status: "installed" as PluginStatus,
|
||||
installOrder,
|
||||
packagePath: input.packagePath ?? null,
|
||||
})
|
||||
.returning();
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
if (isPluginKeyConflict(error)) {
|
||||
throw conflict(`Plugin already installed: ${manifest.id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Update ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update a plugin's manifest and version (e.g. on upgrade).
|
||||
* The plugin must already exist.
|
||||
*/
|
||||
update: async (
|
||||
id: string,
|
||||
data: {
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
manifest?: PaperclipPluginManifestV1;
|
||||
},
|
||||
) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const setClause: Partial<typeof plugins.$inferInsert> & { updatedAt: Date } = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.packageName !== undefined) setClause.packageName = data.packageName;
|
||||
if (data.version !== undefined) setClause.version = data.version;
|
||||
if (data.manifest !== undefined) {
|
||||
setClause.manifestJson = data.manifest;
|
||||
setClause.apiVersion = data.manifest.apiVersion;
|
||||
setClause.categories = data.manifest.categories;
|
||||
}
|
||||
|
||||
return db
|
||||
.update(plugins)
|
||||
.set(setClause)
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Status ---------------------------------------------------------
|
||||
|
||||
/** Update a plugin's lifecycle status and optional error message. */
|
||||
updateStatus: async (id: string, input: UpdatePluginStatus) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
status: input.status,
|
||||
lastError: input.lastError ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Uninstall / Remove --------------------------------------------
|
||||
|
||||
/**
|
||||
* Uninstall a plugin.
|
||||
*
|
||||
* When `removeData` is true the plugin row (and cascaded config) is
|
||||
* hard-deleted. Otherwise the status is set to `"uninstalled"` for
|
||||
* a soft-delete that preserves the record.
|
||||
*/
|
||||
uninstall: async (id: string, removeData = false) => {
|
||||
const plugin = await getById(id);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
if (removeData) {
|
||||
// Hard delete – plugin_config cascades via FK onDelete
|
||||
return db
|
||||
.delete(plugins)
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
// Soft delete – mark as uninstalled
|
||||
return db
|
||||
.update(plugins)
|
||||
.set({
|
||||
status: "uninstalled" as PluginStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(plugins.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Config ---------------------------------------------------------
|
||||
|
||||
/** Retrieve a plugin's instance configuration. */
|
||||
getConfig: (pluginId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Create or fully replace a plugin's instance configuration.
|
||||
* If a config row already exists for the plugin it is replaced;
|
||||
* otherwise a new row is inserted.
|
||||
*/
|
||||
upsertConfig: async (pluginId: string, input: UpsertPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginConfig)
|
||||
.set({
|
||||
configJson: input.configJson,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Partially update a plugin's instance configuration via shallow merge.
|
||||
* If no config row exists yet one is created with the supplied values.
|
||||
*/
|
||||
patchConfig: async (pluginId: string, input: PatchPluginConfig) => {
|
||||
const plugin = await getById(pluginId);
|
||||
if (!plugin) throw notFound("Plugin not found");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
const merged = { ...existing.configJson, ...input.configJson };
|
||||
return db
|
||||
.update(pluginConfig)
|
||||
.set({
|
||||
configJson: merged,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginConfig)
|
||||
.values({
|
||||
pluginId,
|
||||
configJson: input.configJson,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Record an error against a plugin's config (e.g. validation failure
|
||||
* against the plugin's instanceConfigSchema).
|
||||
*/
|
||||
setConfigError: async (pluginId: string, lastError: string | null) => {
|
||||
const rows = await db
|
||||
.update(pluginConfig)
|
||||
.set({ lastError, updatedAt: new Date() })
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning();
|
||||
|
||||
if (rows.length === 0) throw notFound("Plugin config not found");
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
/** Delete a plugin's config row. */
|
||||
deleteConfig: async (pluginId: string) => {
|
||||
const rows = await db
|
||||
.delete(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.returning();
|
||||
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
// ----- Entities -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List persistent entity mappings owned by a specific plugin, with filtering and pagination.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param query - Optional filters (type, externalId) and pagination (limit, offset).
|
||||
* @returns A list of matching `PluginEntityRecord` objects.
|
||||
*/
|
||||
listEntities: (pluginId: string, query?: PluginEntityQuery) => {
|
||||
const conditions = [eq(pluginEntities.pluginId, pluginId)];
|
||||
if (query?.entityType) conditions.push(eq(pluginEntities.entityType, query.entityType));
|
||||
if (query?.externalId) conditions.push(eq(pluginEntities.externalId, query.externalId));
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(pluginEntities.createdAt))
|
||||
.limit(query?.limit ?? 100)
|
||||
.offset(query?.offset ?? 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up a plugin-owned entity mapping by its external identifier.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param entityType - The type of entity (e.g., 'project', 'issue').
|
||||
* @param externalId - The identifier in the external system.
|
||||
* @returns The matching `PluginEntityRecord` or null.
|
||||
*/
|
||||
getEntityByExternalId: (
|
||||
pluginId: string,
|
||||
entityType: string,
|
||||
externalId: string,
|
||||
) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, entityType),
|
||||
eq(pluginEntities.externalId, externalId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Create or update a persistent mapping between a Paperclip object and an
|
||||
* external entity.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param input - The entity data to persist.
|
||||
* @returns The newly created or updated `PluginEntityRecord`.
|
||||
*/
|
||||
upsertEntity: async (
|
||||
pluginId: string,
|
||||
input: Omit<typeof pluginEntities.$inferInsert, "id" | "pluginId" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
// Drizzle doesn't support pg-specific onConflictDoUpdate easily in the insert() call
|
||||
// with complex where clauses, so we do it manually.
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginEntities)
|
||||
.where(
|
||||
and(
|
||||
eq(pluginEntities.pluginId, pluginId),
|
||||
eq(pluginEntities.entityType, input.entityType),
|
||||
eq(pluginEntities.externalId, input.externalId ?? ""),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginEntities)
|
||||
.set({
|
||||
...input,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginEntities.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginEntities)
|
||||
.values({
|
||||
...input,
|
||||
pluginId,
|
||||
} as any)
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a specific plugin-owned entity mapping by its internal UUID.
|
||||
*
|
||||
* @param id - The UUID of the entity record.
|
||||
* @returns The deleted record, or null if not found.
|
||||
*/
|
||||
deleteEntity: async (id: string) => {
|
||||
const rows = await db
|
||||
.delete(pluginEntities)
|
||||
.where(eq(pluginEntities.id, id))
|
||||
.returning();
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
// ----- Jobs -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all scheduled jobs registered for a specific plugin.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @returns A list of `PluginJobRecord` objects.
|
||||
*/
|
||||
listJobs: (pluginId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(eq(pluginJobs.pluginId, pluginId))
|
||||
.orderBy(asc(pluginJobs.jobKey)),
|
||||
|
||||
/**
|
||||
* Look up a plugin job by its unique job key.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobKey - The key defined in the plugin manifest.
|
||||
* @returns The matching `PluginJobRecord` or null.
|
||||
*/
|
||||
getJobByKey: (pluginId: string, jobKey: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
/**
|
||||
* Register or update a scheduled job for a plugin.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobKey - The unique key for the job.
|
||||
* @param input - The schedule (cron) and optional status.
|
||||
* @returns The updated or created `PluginJobRecord`.
|
||||
*/
|
||||
upsertJob: async (
|
||||
pluginId: string,
|
||||
jobKey: string,
|
||||
input: { schedule: string; status?: PluginJobStatus },
|
||||
) => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(pluginJobs)
|
||||
.where(and(eq(pluginJobs.pluginId, pluginId), eq(pluginJobs.jobKey, jobKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existing) {
|
||||
return db
|
||||
.update(pluginJobs)
|
||||
.set({
|
||||
schedule: input.schedule,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginJobs.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(pluginJobs)
|
||||
.values({
|
||||
pluginId,
|
||||
jobKey,
|
||||
schedule: input.schedule,
|
||||
status: input.status ?? "active",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Record the start of a specific job execution.
|
||||
*
|
||||
* @param pluginId - The UUID of the plugin.
|
||||
* @param jobId - The UUID of the parent job record.
|
||||
* @param trigger - What triggered this run (e.g., 'schedule', 'manual').
|
||||
* @returns The newly created `PluginJobRunRecord` in 'pending' status.
|
||||
*/
|
||||
createJobRun: async (
|
||||
pluginId: string,
|
||||
jobId: string,
|
||||
trigger: PluginJobRunTrigger,
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginJobRuns)
|
||||
.values({
|
||||
pluginId,
|
||||
jobId,
|
||||
trigger,
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the status, duration, and logs of a job execution record.
|
||||
*
|
||||
* @param runId - The UUID of the job run.
|
||||
* @param input - The update fields (status, error, duration, etc.).
|
||||
* @returns The updated `PluginJobRunRecord`.
|
||||
*/
|
||||
updateJobRun: async (
|
||||
runId: string,
|
||||
input: {
|
||||
status: PluginJobRunStatus;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
logs?: string[];
|
||||
startedAt?: Date;
|
||||
finishedAt?: Date;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.update(pluginJobRuns)
|
||||
.set(input)
|
||||
.where(eq(pluginJobRuns.id, runId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
// ----- Webhooks -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a record for an incoming webhook delivery.
|
||||
*
|
||||
* @param pluginId - The UUID of the receiving plugin.
|
||||
* @param webhookKey - The endpoint key defined in the manifest.
|
||||
* @param input - The payload, headers, and optional external ID.
|
||||
* @returns The newly created `PluginWebhookDeliveryRecord` in 'pending' status.
|
||||
*/
|
||||
createWebhookDelivery: async (
|
||||
pluginId: string,
|
||||
webhookKey: string,
|
||||
input: {
|
||||
externalId?: string;
|
||||
payload: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.insert(pluginWebhookDeliveries)
|
||||
.values({
|
||||
pluginId,
|
||||
webhookKey,
|
||||
externalId: input.externalId,
|
||||
payload: input.payload,
|
||||
headers: input.headers ?? {},
|
||||
status: "pending",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the status and processing metrics of a webhook delivery.
|
||||
*
|
||||
* @param deliveryId - The UUID of the delivery record.
|
||||
* @param input - The update fields (status, error, duration, etc.).
|
||||
* @returns The updated `PluginWebhookDeliveryRecord`.
|
||||
*/
|
||||
updateWebhookDelivery: async (
|
||||
deliveryId: string,
|
||||
input: {
|
||||
status: PluginWebhookDeliveryStatus;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
startedAt?: Date;
|
||||
finishedAt?: Date;
|
||||
},
|
||||
) => {
|
||||
return db
|
||||
.update(pluginWebhookDeliveries)
|
||||
.set(input)
|
||||
.where(eq(pluginWebhookDeliveries.id, deliveryId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginCapabilityValidator } from "./plugin-capability-validator.js";
|
||||
|
||||
export class PluginSandboxError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PluginSandboxError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox runtime options used when loading a plugin worker module.
|
||||
*
|
||||
* `allowedModuleSpecifiers` controls which bare module specifiers are permitted.
|
||||
* `allowedModules` provides concrete host-provided bindings for those specifiers.
|
||||
*/
|
||||
export interface PluginSandboxOptions {
|
||||
entrypointPath: string;
|
||||
allowedModuleSpecifiers?: ReadonlySet<string>;
|
||||
allowedModules?: Readonly<Record<string, Record<string, unknown>>>;
|
||||
allowedGlobals?: Record<string, unknown>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation-level runtime gate for plugin host API calls.
|
||||
* Every host operation must be checked against manifest capabilities before execution.
|
||||
*/
|
||||
export interface CapabilityScopedInvoker {
|
||||
invoke<T>(operation: string, fn: () => Promise<T> | T): Promise<T>;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
namespace: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2_000;
|
||||
const MODULE_PATH_SUFFIXES = ["", ".js", ".mjs", ".cjs", "/index.js", "/index.mjs", "/index.cjs"];
|
||||
const DEFAULT_GLOBALS: Record<string, unknown> = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval,
|
||||
clearInterval,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
};
|
||||
|
||||
export function createCapabilityScopedInvoker(
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
validator: PluginCapabilityValidator,
|
||||
): CapabilityScopedInvoker {
|
||||
return {
|
||||
async invoke<T>(operation: string, fn: () => Promise<T> | T): Promise<T> {
|
||||
validator.assertOperation(manifest, operation);
|
||||
return await fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a CommonJS plugin module in a VM context with explicit module import allow-listing.
|
||||
*
|
||||
* Security properties:
|
||||
* - no implicit access to host globals like `process`
|
||||
* - no unrestricted built-in module imports
|
||||
* - relative imports are resolved only inside the plugin root directory
|
||||
*/
|
||||
export async function loadPluginModuleInSandbox(
|
||||
options: PluginSandboxOptions,
|
||||
): Promise<LoadedModule> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const allowedSpecifiers = options.allowedModuleSpecifiers ?? new Set<string>();
|
||||
const entrypointPath = path.resolve(options.entrypointPath);
|
||||
const pluginRoot = path.dirname(entrypointPath);
|
||||
|
||||
const context = vm.createContext({
|
||||
...DEFAULT_GLOBALS,
|
||||
...options.allowedGlobals,
|
||||
});
|
||||
|
||||
const moduleCache = new Map<string, Record<string, unknown>>();
|
||||
const allowedModules = options.allowedModules ?? {};
|
||||
|
||||
const realPluginRoot = realpathSync(pluginRoot);
|
||||
|
||||
const loadModuleSync = (modulePath: string): Record<string, unknown> => {
|
||||
const resolvedPath = resolveModulePathSync(path.resolve(modulePath));
|
||||
const realPath = realpathSync(resolvedPath);
|
||||
|
||||
if (!isWithinRoot(realPath, realPluginRoot)) {
|
||||
throw new PluginSandboxError(
|
||||
`Import '${modulePath}' escapes plugin root and is not allowed`,
|
||||
);
|
||||
}
|
||||
|
||||
const cached = moduleCache.get(realPath);
|
||||
if (cached) return cached;
|
||||
|
||||
const code = readModuleSourceSync(realPath);
|
||||
|
||||
if (looksLikeEsm(code)) {
|
||||
throw new PluginSandboxError(
|
||||
"Sandbox loader only supports CommonJS modules. Build plugin worker entrypoints as CJS for sandboxed loading.",
|
||||
);
|
||||
}
|
||||
|
||||
const module = { exports: {} as Record<string, unknown> };
|
||||
// Cache the module before execution to preserve CommonJS cycle semantics.
|
||||
moduleCache.set(realPath, module.exports);
|
||||
|
||||
const requireInSandbox = (specifier: string): Record<string, unknown> => {
|
||||
if (!specifier.startsWith(".") && !specifier.startsWith("/")) {
|
||||
if (!allowedSpecifiers.has(specifier)) {
|
||||
throw new PluginSandboxError(
|
||||
`Import denied for module '${specifier}'. Add an explicit sandbox allow-list entry.`,
|
||||
);
|
||||
}
|
||||
|
||||
const binding = allowedModules[specifier];
|
||||
if (!binding) {
|
||||
throw new PluginSandboxError(
|
||||
`Bare module '${specifier}' is allow-listed but no host binding is registered.`,
|
||||
);
|
||||
}
|
||||
|
||||
return binding;
|
||||
}
|
||||
|
||||
const candidatePath = path.resolve(path.dirname(realPath), specifier);
|
||||
return loadModuleSync(candidatePath);
|
||||
};
|
||||
|
||||
// Inject the CJS module arguments into the context so the script can call
|
||||
// the wrapper immediately. This is critical: the timeout in runInContext
|
||||
// only applies during script evaluation. By including the self-invocation
|
||||
// `(fn)(exports, module, ...)` in the script text, the timeout also covers
|
||||
// the actual module body execution — preventing infinite loops from hanging.
|
||||
const sandboxArgs = {
|
||||
__paperclip_exports: module.exports,
|
||||
__paperclip_module: module,
|
||||
__paperclip_require: requireInSandbox,
|
||||
__paperclip_filename: realPath,
|
||||
__paperclip_dirname: path.dirname(realPath),
|
||||
};
|
||||
// Temporarily inject args into the context, run, then remove to avoid pollution.
|
||||
Object.assign(context, sandboxArgs);
|
||||
const wrapped = `(function (exports, module, require, __filename, __dirname) {\n${code}\n})(__paperclip_exports, __paperclip_module, __paperclip_require, __paperclip_filename, __paperclip_dirname)`;
|
||||
const script = new vm.Script(wrapped, { filename: realPath });
|
||||
try {
|
||||
script.runInContext(context, { timeout: timeoutMs });
|
||||
} finally {
|
||||
for (const key of Object.keys(sandboxArgs)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete (context as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedExports = normalizeModuleExports(module.exports);
|
||||
moduleCache.set(realPath, normalizedExports);
|
||||
return normalizedExports;
|
||||
};
|
||||
|
||||
const entryExports = loadModuleSync(entrypointPath);
|
||||
|
||||
return {
|
||||
namespace: { ...entryExports },
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModulePathSync(candidatePath: string): string {
|
||||
for (const suffix of MODULE_PATH_SUFFIXES) {
|
||||
const fullPath = `${candidatePath}${suffix}`;
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PluginSandboxError(`Unable to resolve module import at path '${candidatePath}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `targetPath` is inside `rootPath` (or equals rootPath), false otherwise.
|
||||
* Uses `path.relative` so sibling-prefix paths (e.g. `/root-a` vs `/root`) cannot bypass checks.
|
||||
*/
|
||||
function isWithinRoot(targetPath: string, rootPath: string): boolean {
|
||||
const relative = path.relative(rootPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function readModuleSourceSync(modulePath: string): string {
|
||||
try {
|
||||
return readFileSync(modulePath, "utf8");
|
||||
} catch (error) {
|
||||
throw new PluginSandboxError(
|
||||
`Failed to read sandbox module '${modulePath}': ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModuleExports(exportsValue: unknown): Record<string, unknown> {
|
||||
if (typeof exportsValue === "object" && exportsValue !== null) {
|
||||
return exportsValue as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return { default: exportsValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight guard to reject ESM syntax in the VM CommonJS loader.
|
||||
*/
|
||||
function looksLikeEsm(code: string): boolean {
|
||||
return /(^|\n)\s*import\s+/m.test(code) || /(^|\n)\s*export\s+/m.test(code);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Plugin secrets host-side handler — resolves secret references through the
|
||||
* Paperclip secret provider system.
|
||||
*
|
||||
* When a plugin worker calls `ctx.secrets.resolve(secretRef)`, the JSON-RPC
|
||||
* request arrives at the host with `{ secretRef }`. This module provides the
|
||||
* concrete `HostServices.secrets` adapter that:
|
||||
*
|
||||
* 1. Parses the `secretRef` string to identify the secret.
|
||||
* 2. Looks up the secret record and its latest version in the database.
|
||||
* 3. Delegates to the configured `SecretProviderModule` to decrypt /
|
||||
* resolve the raw value.
|
||||
* 4. Returns the resolved plaintext value to the worker.
|
||||
*
|
||||
* ## Secret Reference Format
|
||||
*
|
||||
* A `secretRef` is a **secret UUID** — the primary key (`id`) of a row in
|
||||
* the `company_secrets` table. Operators place these UUIDs into plugin
|
||||
* config values; plugin workers resolve them at execution time via
|
||||
* `ctx.secrets.resolve(secretId)`.
|
||||
*
|
||||
* ## Security Invariants
|
||||
*
|
||||
* - Resolved values are **never** logged, persisted, or included in error
|
||||
* messages (per PLUGIN_SPEC.md §22).
|
||||
* - The handler is capability-gated: only plugins with `secrets.read-ref`
|
||||
* declared in their manifest may call it (enforced by `host-client-factory`).
|
||||
* - The host handler itself does not cache resolved values. Each call goes
|
||||
* through the secret provider to honour rotation.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §22 — Secrets
|
||||
* @see host-client-factory.ts — capability gating
|
||||
* @see services/secrets.ts — secretService used by agent env bindings
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, companySecretVersions, pluginConfig } from "@paperclipai/db";
|
||||
import type { SecretProvider } from "@paperclipai/shared";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a sanitised error that never leaks secret material.
|
||||
* Only the ref identifier is included; never the resolved value.
|
||||
*/
|
||||
function secretNotFound(secretRef: string): Error {
|
||||
const err = new Error(`Secret not found: ${secretRef}`);
|
||||
err.name = "SecretNotFoundError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function secretVersionNotFound(secretRef: string): Error {
|
||||
const err = new Error(`No version found for secret: ${secretRef}`);
|
||||
err.name = "SecretVersionNotFoundError";
|
||||
return err;
|
||||
}
|
||||
|
||||
function invalidSecretRef(secretRef: string): Error {
|
||||
const err = new Error(`Invalid secret reference: ${secretRef}`);
|
||||
err.name = "InvalidSecretRefError";
|
||||
return err;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** UUID v4 regex for validating secretRef format. */
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Check whether a secretRef looks like a valid UUID.
|
||||
*/
|
||||
function isUuid(value: string): boolean {
|
||||
return UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the property paths (dot-separated keys) whose schema node declares
|
||||
* `format: "secret-ref"`. Only top-level and nested `properties` are walked —
|
||||
* this mirrors the flat/nested object shapes that `JsonSchemaForm` renders.
|
||||
*/
|
||||
function collectSecretRefPaths(
|
||||
schema: Record<string, unknown> | null | undefined,
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
if (!schema || typeof schema !== "object") return paths;
|
||||
|
||||
function walk(node: Record<string, unknown>, prefix: string): void {
|
||||
const props = node.properties as Record<string, Record<string, unknown>> | undefined;
|
||||
if (!props || typeof props !== "object") return;
|
||||
for (const [key, propSchema] of Object.entries(props)) {
|
||||
if (!propSchema || typeof propSchema !== "object") continue;
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (propSchema.format === "secret-ref") {
|
||||
paths.add(path);
|
||||
}
|
||||
// Recurse into nested object schemas
|
||||
if (propSchema.type === "object") {
|
||||
walk(propSchema, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(schema, "");
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract secret reference UUIDs from a plugin's configJson, scoped to only
|
||||
* the fields annotated with `format: "secret-ref"` in the schema.
|
||||
*
|
||||
* When no schema is provided, falls back to collecting all UUID-shaped strings
|
||||
* (backwards-compatible for plugins without a declared instanceConfigSchema).
|
||||
*/
|
||||
export function extractSecretRefsFromConfig(
|
||||
configJson: unknown,
|
||||
schema?: Record<string, unknown> | null,
|
||||
): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
if (configJson == null || typeof configJson !== "object") return refs;
|
||||
|
||||
const secretPaths = collectSecretRefPaths(schema);
|
||||
|
||||
// If schema declares secret-ref paths, extract only those values.
|
||||
if (secretPaths.size > 0) {
|
||||
for (const dotPath of secretPaths) {
|
||||
const keys = dotPath.split(".");
|
||||
let current: unknown = configJson;
|
||||
for (const k of keys) {
|
||||
if (current == null || typeof current !== "object") { current = undefined; break; }
|
||||
current = (current as Record<string, unknown>)[k];
|
||||
}
|
||||
if (typeof current === "string" && isUuid(current)) {
|
||||
refs.add(current);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
// Fallback: no schema or no secret-ref annotations — collect all UUIDs.
|
||||
// This preserves backwards compatibility for plugins that omit
|
||||
// instanceConfigSchema.
|
||||
function walkAll(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
if (isUuid(value)) refs.add(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) walkAll(item);
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
for (const v of Object.values(value as Record<string, unknown>)) walkAll(v);
|
||||
}
|
||||
}
|
||||
|
||||
walkAll(configJson);
|
||||
return refs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input shape for the `secrets.resolve` handler.
|
||||
*
|
||||
* Matches `WorkerToHostMethods["secrets.resolve"][0]` from `protocol.ts`.
|
||||
*/
|
||||
export interface PluginSecretsResolveParams {
|
||||
/** The secret reference string (a secret UUID). */
|
||||
secretRef: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating the plugin secrets handler.
|
||||
*/
|
||||
export interface PluginSecretsHandlerOptions {
|
||||
/** Database connection. */
|
||||
db: Db;
|
||||
/**
|
||||
* The plugin ID using this handler.
|
||||
* Used for logging context only; never included in error payloads
|
||||
* that reach the plugin worker.
|
||||
*/
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `HostServices.secrets` adapter for the plugin host-client factory.
|
||||
*/
|
||||
export interface PluginSecretsService {
|
||||
/**
|
||||
* Resolve a secret reference to its current plaintext value.
|
||||
*
|
||||
* @param params - Contains the `secretRef` (UUID of the secret)
|
||||
* @returns The resolved secret value
|
||||
* @throws {Error} If the secret is not found, has no versions, or
|
||||
* the provider fails to resolve
|
||||
*/
|
||||
resolve(params: PluginSecretsResolveParams): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `HostServices.secrets` adapter for a specific plugin.
|
||||
*
|
||||
* The returned service looks up secrets by UUID, fetches the latest version
|
||||
* material, and delegates to the appropriate `SecretProviderModule` for
|
||||
* decryption.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const secretsHandler = createPluginSecretsHandler({ db, pluginId });
|
||||
* const handlers = createHostClientHandlers({
|
||||
* pluginId,
|
||||
* capabilities: manifest.capabilities,
|
||||
* services: {
|
||||
* secrets: secretsHandler,
|
||||
* // ...
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param options - Database connection and plugin identity
|
||||
* @returns A `PluginSecretsService` suitable for `HostServices.secrets`
|
||||
*/
|
||||
/** Simple sliding-window rate limiter for secret resolution attempts. */
|
||||
function createRateLimiter(maxAttempts: number, windowMs: number) {
|
||||
const attempts = new Map<string, number[]>();
|
||||
|
||||
return {
|
||||
check(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
const existing = (attempts.get(key) ?? []).filter((ts) => ts > windowStart);
|
||||
if (existing.length >= maxAttempts) return false;
|
||||
existing.push(now);
|
||||
attempts.set(key, existing);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginSecretsHandler(
|
||||
options: PluginSecretsHandlerOptions,
|
||||
): PluginSecretsService {
|
||||
const { db, pluginId } = options;
|
||||
const registry = pluginRegistryService(db);
|
||||
|
||||
// Rate limit: max 30 resolution attempts per plugin per minute
|
||||
const rateLimiter = createRateLimiter(30, 60_000);
|
||||
|
||||
let cachedAllowedRefs: Set<string> | null = null;
|
||||
let cachedAllowedRefsExpiry = 0;
|
||||
const CONFIG_CACHE_TTL_MS = 30_000; // 30 seconds, matches event bus TTL
|
||||
|
||||
return {
|
||||
async resolve(params: PluginSecretsResolveParams): Promise<string> {
|
||||
const { secretRef } = params;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 0. Rate limiting — prevent brute-force UUID enumeration
|
||||
// ---------------------------------------------------------------
|
||||
if (!rateLimiter.check(pluginId)) {
|
||||
const err = new Error("Rate limit exceeded for secret resolution");
|
||||
err.name = "RateLimitExceededError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 1. Validate the ref format
|
||||
// ---------------------------------------------------------------
|
||||
if (!secretRef || typeof secretRef !== "string" || secretRef.trim().length === 0) {
|
||||
throw invalidSecretRef(secretRef ?? "<empty>");
|
||||
}
|
||||
|
||||
const trimmedRef = secretRef.trim();
|
||||
|
||||
if (!isUuid(trimmedRef)) {
|
||||
throw invalidSecretRef(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 1b. Scope check — only allow secrets referenced in this plugin's config
|
||||
// ---------------------------------------------------------------
|
||||
const now = Date.now();
|
||||
if (!cachedAllowedRefs || now > cachedAllowedRefsExpiry) {
|
||||
const [configRow, plugin] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(pluginConfig)
|
||||
.where(eq(pluginConfig.pluginId, pluginId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
registry.getById(pluginId),
|
||||
]);
|
||||
|
||||
const schema = (plugin?.manifestJson as unknown as Record<string, unknown> | null)
|
||||
?.instanceConfigSchema as Record<string, unknown> | undefined;
|
||||
cachedAllowedRefs = extractSecretRefsFromConfig(configRow?.configJson, schema);
|
||||
cachedAllowedRefsExpiry = now + CONFIG_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
if (!cachedAllowedRefs.has(trimmedRef)) {
|
||||
// Return "not found" to avoid leaking whether the secret exists
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 2. Look up the secret record by UUID
|
||||
// ---------------------------------------------------------------
|
||||
const secret = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, trimmedRef))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!secret) {
|
||||
throw secretNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Fetch the latest version's material
|
||||
// ---------------------------------------------------------------
|
||||
const versionRow = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretVersions.secretId, secret.id),
|
||||
eq(companySecretVersions.version, secret.latestVersion),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!versionRow) {
|
||||
throw secretVersionNotFound(trimmedRef);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 4. Resolve through the appropriate secret provider
|
||||
// ---------------------------------------------------------------
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
const resolved = await provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { plugins, pluginState } from "@paperclipai/db";
|
||||
import type {
|
||||
PluginStateScopeKind,
|
||||
SetPluginState,
|
||||
ListPluginState,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Default namespace used when the plugin does not specify one. */
|
||||
const DEFAULT_NAMESPACE = "default";
|
||||
|
||||
/**
|
||||
* Build the WHERE clause conditions for a scoped state lookup.
|
||||
*
|
||||
* The five-part composite key is:
|
||||
* `(pluginId, scopeKind, scopeId, namespace, stateKey)`
|
||||
*
|
||||
* `scopeId` may be null (for `instance` scope) or a non-empty string.
|
||||
*/
|
||||
function scopeConditions(
|
||||
pluginId: string,
|
||||
scopeKind: PluginStateScopeKind,
|
||||
scopeId: string | undefined | null,
|
||||
namespace: string,
|
||||
stateKey: string,
|
||||
) {
|
||||
const conditions = [
|
||||
eq(pluginState.pluginId, pluginId),
|
||||
eq(pluginState.scopeKind, scopeKind),
|
||||
eq(pluginState.namespace, namespace),
|
||||
eq(pluginState.stateKey, stateKey),
|
||||
];
|
||||
|
||||
if (scopeId != null && scopeId !== "") {
|
||||
conditions.push(eq(pluginState.scopeId, scopeId));
|
||||
} else {
|
||||
conditions.push(isNull(pluginState.scopeId));
|
||||
}
|
||||
|
||||
return and(...conditions);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Plugin State Store — scoped key-value persistence for plugin workers.
|
||||
*
|
||||
* Provides `get`, `set`, `delete`, and `list` operations over the
|
||||
* `plugin_state` table. Each plugin's data is strictly namespaced by
|
||||
* `pluginId` so plugins cannot read or write each other's state.
|
||||
*
|
||||
* This service implements the server-side backing for the `ctx.state` SDK
|
||||
* client exposed to plugin workers. The host is responsible for:
|
||||
* - enforcing `plugin.state.read` capability before calling `get` / `list`
|
||||
* - enforcing `plugin.state.write` capability before calling `set` / `delete`
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface (`ctx.state`)
|
||||
* @see PLUGIN_SPEC.md §15.1 — Capabilities: Plugin State
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state` table
|
||||
*/
|
||||
export function pluginStateStore(db: Db) {
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async function assertPluginExists(pluginId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ id: plugins.id })
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, pluginId));
|
||||
if (rows.length === 0) {
|
||||
throw notFound(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
/**
|
||||
* Read a state value.
|
||||
*
|
||||
* Returns the stored JSON value, or `null` if no entry exists for the
|
||||
* given scope and key.
|
||||
*
|
||||
* Requires `plugin.state.read` capability (enforced by the caller).
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param scopeKind - Granularity of the scope
|
||||
* @param scopeId - Identifier for the scoped entity (null for `instance` scope)
|
||||
* @param stateKey - The key to read
|
||||
* @param namespace - Sub-namespace (defaults to `"default"`)
|
||||
*/
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scopeKind: PluginStateScopeKind,
|
||||
stateKey: string,
|
||||
{
|
||||
scopeId,
|
||||
namespace = DEFAULT_NAMESPACE,
|
||||
}: { scopeId?: string; namespace?: string } = {},
|
||||
): Promise<unknown> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pluginState)
|
||||
.where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey));
|
||||
|
||||
return rows[0]?.valueJson ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Write (create or replace) a state value.
|
||||
*
|
||||
* Uses an upsert so the caller does not need to check for prior existence.
|
||||
* On conflict (same composite key) the existing row's `value_json` and
|
||||
* `updated_at` are overwritten.
|
||||
*
|
||||
* Requires `plugin.state.write` capability (enforced by the caller).
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param input - Scope key and value to store
|
||||
*/
|
||||
set: async (pluginId: string, input: SetPluginState): Promise<void> => {
|
||||
await assertPluginExists(pluginId);
|
||||
|
||||
const namespace = input.namespace ?? DEFAULT_NAMESPACE;
|
||||
const scopeId = input.scopeId ?? null;
|
||||
|
||||
await db
|
||||
.insert(pluginState)
|
||||
.values({
|
||||
pluginId,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId,
|
||||
namespace,
|
||||
stateKey: input.stateKey,
|
||||
valueJson: input.value,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
pluginState.pluginId,
|
||||
pluginState.scopeKind,
|
||||
pluginState.scopeId,
|
||||
pluginState.namespace,
|
||||
pluginState.stateKey,
|
||||
],
|
||||
set: {
|
||||
valueJson: input.value,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a state value.
|
||||
*
|
||||
* No-ops silently if the entry does not exist (idempotent by design).
|
||||
*
|
||||
* Requires `plugin.state.write` capability (enforced by the caller).
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param scopeKind - Granularity of the scope
|
||||
* @param stateKey - The key to delete
|
||||
* @param scopeId - Identifier for the scoped entity (null for `instance` scope)
|
||||
* @param namespace - Sub-namespace (defaults to `"default"`)
|
||||
*/
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scopeKind: PluginStateScopeKind,
|
||||
stateKey: string,
|
||||
{
|
||||
scopeId,
|
||||
namespace = DEFAULT_NAMESPACE,
|
||||
}: { scopeId?: string; namespace?: string } = {},
|
||||
): Promise<void> => {
|
||||
await db
|
||||
.delete(pluginState)
|
||||
.where(scopeConditions(pluginId, scopeKind, scopeId, namespace, stateKey));
|
||||
},
|
||||
|
||||
/**
|
||||
* List all state entries for a plugin, optionally filtered by scope.
|
||||
*
|
||||
* Returns all matching rows as `PluginStateRecord`-shaped objects.
|
||||
* The `valueJson` field contains the stored value.
|
||||
*
|
||||
* Requires `plugin.state.read` capability (enforced by the caller).
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
* @param filter - Optional scope filters (scopeKind, scopeId, namespace)
|
||||
*/
|
||||
list: async (pluginId: string, filter: ListPluginState = {}): Promise<typeof pluginState.$inferSelect[]> => {
|
||||
const conditions = [eq(pluginState.pluginId, pluginId)];
|
||||
|
||||
if (filter.scopeKind !== undefined) {
|
||||
conditions.push(eq(pluginState.scopeKind, filter.scopeKind));
|
||||
}
|
||||
if (filter.scopeId !== undefined) {
|
||||
conditions.push(eq(pluginState.scopeId, filter.scopeId));
|
||||
}
|
||||
if (filter.namespace !== undefined) {
|
||||
conditions.push(eq(pluginState.namespace, filter.namespace));
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(pluginState)
|
||||
.where(and(...conditions));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all state entries owned by a plugin.
|
||||
*
|
||||
* Called during plugin uninstall when `removeData = true`. Also useful
|
||||
* for resetting a plugin's state during testing.
|
||||
*
|
||||
* @param pluginId - UUID of the owning plugin
|
||||
*/
|
||||
deleteAll: async (pluginId: string): Promise<void> => {
|
||||
await db
|
||||
.delete(pluginState)
|
||||
.where(eq(pluginState.pluginId, pluginId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginStateStore = ReturnType<typeof pluginStateStore>;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* In-memory pub/sub bus for plugin SSE streams.
|
||||
*
|
||||
* Workers emit stream events via JSON-RPC notifications. The bus fans out
|
||||
* each event to all connected SSE clients that match the (pluginId, channel,
|
||||
* companyId) tuple.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
|
||||
/** Valid SSE event types for plugin streams. */
|
||||
export type StreamEventType = "message" | "open" | "close" | "error";
|
||||
|
||||
export type StreamSubscriber = (event: unknown, eventType: StreamEventType) => void;
|
||||
|
||||
/**
|
||||
* Composite key for stream subscriptions: pluginId:channel:companyId
|
||||
*/
|
||||
function streamKey(pluginId: string, channel: string, companyId: string): string {
|
||||
return `${pluginId}:${channel}:${companyId}`;
|
||||
}
|
||||
|
||||
export interface PluginStreamBus {
|
||||
/**
|
||||
* Subscribe to stream events for a specific (pluginId, channel, companyId).
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
subscribe(
|
||||
pluginId: string,
|
||||
channel: string,
|
||||
companyId: string,
|
||||
listener: StreamSubscriber,
|
||||
): () => void;
|
||||
|
||||
/**
|
||||
* Publish an event to all subscribers of (pluginId, channel, companyId).
|
||||
* Called by the worker manager when it receives a stream notification.
|
||||
*/
|
||||
publish(
|
||||
pluginId: string,
|
||||
channel: string,
|
||||
companyId: string,
|
||||
event: unknown,
|
||||
eventType?: StreamEventType,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new PluginStreamBus instance.
|
||||
*/
|
||||
export function createPluginStreamBus(): PluginStreamBus {
|
||||
const subscribers = new Map<string, Set<StreamSubscriber>>();
|
||||
|
||||
return {
|
||||
subscribe(pluginId, channel, companyId, listener) {
|
||||
const key = streamKey(pluginId, channel, companyId);
|
||||
let set = subscribers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
subscribers.set(key, set);
|
||||
}
|
||||
set.add(listener);
|
||||
|
||||
return () => {
|
||||
set!.delete(listener);
|
||||
if (set!.size === 0) {
|
||||
subscribers.delete(key);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
publish(pluginId, channel, companyId, event, eventType: StreamEventType = "message") {
|
||||
const key = streamKey(pluginId, channel, companyId);
|
||||
const set = subscribers.get(key);
|
||||
if (!set) return;
|
||||
for (const listener of set) {
|
||||
listener(event, eventType);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* PluginToolDispatcher — orchestrates plugin tool discovery, lifecycle
|
||||
* integration, and execution routing for the agent service.
|
||||
*
|
||||
* This service sits between the agent service and the lower-level
|
||||
* `PluginToolRegistry` + `PluginWorkerManager`, providing a clean API that:
|
||||
*
|
||||
* - Discovers tools from loaded plugin manifests and registers them
|
||||
* in the tool registry.
|
||||
* - Hooks into `PluginLifecycleManager` events to automatically register
|
||||
* and unregister tools when plugins are enabled or disabled.
|
||||
* - Exposes the tool list in an agent-friendly format (with namespaced
|
||||
* names, descriptions, parameter schemas).
|
||||
* - Routes `executeTool` calls to the correct plugin worker and returns
|
||||
* structured results.
|
||||
* - Validates tool parameters against declared schemas before dispatch.
|
||||
*
|
||||
* The dispatcher is created once at server startup and shared across
|
||||
* the application.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
* @see PLUGIN_SPEC.md §13.10 — `executeTool`
|
||||
*/
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ToolRunContext, ToolResult } from "@paperclipai/plugin-sdk";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
||||
import {
|
||||
createPluginToolRegistry,
|
||||
type PluginToolRegistry,
|
||||
type RegisteredTool,
|
||||
type ToolListFilter,
|
||||
type ToolExecutionResult,
|
||||
} from "./plugin-tool-registry.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* An agent-facing tool descriptor — the shape returned when agents
|
||||
* query for available tools.
|
||||
*
|
||||
* This is intentionally simpler than `RegisteredTool`, exposing only
|
||||
* what agents need to decide whether and how to call a tool.
|
||||
*/
|
||||
export interface AgentToolDescriptor {
|
||||
/** Fully namespaced tool name (e.g. `"acme.linear:search-issues"`). */
|
||||
name: string;
|
||||
/** Human-readable display name. */
|
||||
displayName: string;
|
||||
/** Description for the agent — explains when and how to use this tool. */
|
||||
description: string;
|
||||
/** JSON Schema describing the tool's input parameters. */
|
||||
parametersSchema: Record<string, unknown>;
|
||||
/** The plugin that provides this tool. */
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating the plugin tool dispatcher.
|
||||
*/
|
||||
export interface PluginToolDispatcherOptions {
|
||||
/** The worker manager used to dispatch RPC calls to plugin workers. */
|
||||
workerManager?: PluginWorkerManager;
|
||||
/** The lifecycle manager to listen for plugin state changes. */
|
||||
lifecycleManager?: PluginLifecycleManager;
|
||||
/** Database connection for looking up plugin records. */
|
||||
db?: Db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginToolDispatcher interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The plugin tool dispatcher — the primary integration point between the
|
||||
* agent service and the plugin tool system.
|
||||
*
|
||||
* Agents use this service to:
|
||||
* 1. List all available tools (for prompt construction / tool choice)
|
||||
* 2. Execute a specific tool by its namespaced name
|
||||
*
|
||||
* The dispatcher handles lifecycle management internally — when a plugin
|
||||
* is loaded or unloaded, its tools are automatically registered or removed.
|
||||
*/
|
||||
export interface PluginToolDispatcher {
|
||||
/**
|
||||
* Initialize the dispatcher — load tools from all currently-ready plugins
|
||||
* and start listening for lifecycle events.
|
||||
*
|
||||
* Must be called once at server startup after the lifecycle manager
|
||||
* and worker manager are ready.
|
||||
*/
|
||||
initialize(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Tear down the dispatcher — unregister lifecycle event listeners
|
||||
* and clear all tool registrations.
|
||||
*
|
||||
* Called during server shutdown.
|
||||
*/
|
||||
teardown(): void;
|
||||
|
||||
/**
|
||||
* List all available tools for agents, optionally filtered.
|
||||
*
|
||||
* Returns tool descriptors in an agent-friendly format.
|
||||
*
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of agent tool descriptors
|
||||
*/
|
||||
listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[];
|
||||
|
||||
/**
|
||||
* Look up a tool by its namespaced name.
|
||||
*
|
||||
* @param namespacedName - e.g. `"acme.linear:search-issues"`
|
||||
* @returns The registered tool, or `null` if not found
|
||||
*/
|
||||
getTool(namespacedName: string): RegisteredTool | null;
|
||||
|
||||
/**
|
||||
* Execute a tool by its namespaced name, routing to the correct
|
||||
* plugin worker.
|
||||
*
|
||||
* @param namespacedName - Fully qualified tool name
|
||||
* @param parameters - Input parameters matching the tool's schema
|
||||
* @param runContext - Agent run context
|
||||
* @returns The execution result with routing metadata
|
||||
* @throws {Error} if the tool is not found, the worker is not running,
|
||||
* or the tool execution fails
|
||||
*/
|
||||
executeTool(
|
||||
namespacedName: string,
|
||||
parameters: unknown,
|
||||
runContext: ToolRunContext,
|
||||
): Promise<ToolExecutionResult>;
|
||||
|
||||
/**
|
||||
* Register all tools from a plugin manifest.
|
||||
*
|
||||
* This is called automatically when a plugin transitions to `ready`.
|
||||
* Can also be called manually for testing or recovery scenarios.
|
||||
*
|
||||
* @param pluginId - The plugin's unique identifier
|
||||
* @param manifest - The plugin manifest containing tool declarations
|
||||
*/
|
||||
registerPluginTools(
|
||||
pluginId: string,
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unregister all tools for a plugin.
|
||||
*
|
||||
* Called automatically when a plugin is disabled or unloaded.
|
||||
*
|
||||
* @param pluginId - The plugin to unregister
|
||||
*/
|
||||
unregisterPluginTools(pluginId: string): void;
|
||||
|
||||
/**
|
||||
* Get the total number of registered tools, optionally scoped to a plugin.
|
||||
*
|
||||
* @param pluginId - If provided, count only this plugin's tools
|
||||
*/
|
||||
toolCount(pluginId?: string): number;
|
||||
|
||||
/**
|
||||
* Access the underlying tool registry for advanced operations.
|
||||
*
|
||||
* This escape hatch exists for internal use (e.g. diagnostics).
|
||||
* Prefer the dispatcher's own methods for normal operations.
|
||||
*/
|
||||
getRegistry(): PluginToolRegistry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory: createPluginToolDispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new `PluginToolDispatcher`.
|
||||
*
|
||||
* The dispatcher:
|
||||
* 1. Creates and owns a `PluginToolRegistry` backed by the given worker manager.
|
||||
* 2. Listens for lifecycle events (plugin.enabled, plugin.disabled, plugin.unloaded)
|
||||
* to automatically register and unregister tools.
|
||||
* 3. On `initialize()`, loads tools from all currently-ready plugins via the DB.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // At server startup
|
||||
* const dispatcher = createPluginToolDispatcher({
|
||||
* workerManager,
|
||||
* lifecycleManager,
|
||||
* db,
|
||||
* });
|
||||
* await dispatcher.initialize();
|
||||
*
|
||||
* // In agent service — list tools for prompt construction
|
||||
* const tools = dispatcher.listToolsForAgent();
|
||||
*
|
||||
* // In agent service — execute a tool
|
||||
* const result = await dispatcher.executeTool(
|
||||
* "acme.linear:search-issues",
|
||||
* { query: "auth bug" },
|
||||
* { agentId: "a-1", runId: "r-1", companyId: "c-1", projectId: "p-1" },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function createPluginToolDispatcher(
|
||||
options: PluginToolDispatcherOptions = {},
|
||||
): PluginToolDispatcher {
|
||||
const { workerManager, lifecycleManager, db } = options;
|
||||
const log = logger.child({ service: "plugin-tool-dispatcher" });
|
||||
|
||||
// Create the underlying tool registry, backed by the worker manager
|
||||
const registry = createPluginToolRegistry(workerManager);
|
||||
|
||||
// Track lifecycle event listeners so we can remove them on teardown
|
||||
let enabledListener: ((payload: { pluginId: string; pluginKey: string }) => void) | null = null;
|
||||
let disabledListener: ((payload: { pluginId: string; pluginKey: string; reason?: string }) => void) | null = null;
|
||||
let unloadedListener: ((payload: { pluginId: string; pluginKey: string; removeData: boolean }) => void) | null = null;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to register tools for a plugin by looking up its manifest
|
||||
* from the DB. No-ops gracefully if the plugin or manifest is missing.
|
||||
*/
|
||||
async function registerFromDb(pluginId: string): Promise<void> {
|
||||
if (!db) {
|
||||
log.warn(
|
||||
{ pluginId },
|
||||
"cannot register tools from DB — no database connection configured",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const plugin = await pluginRegistry.getById(pluginId) as PluginRecord | null;
|
||||
|
||||
if (!plugin) {
|
||||
log.warn({ pluginId }, "plugin not found in registry, cannot register tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = plugin.manifestJson;
|
||||
if (!manifest) {
|
||||
log.warn({ pluginId }, "plugin has no manifest, cannot register tools");
|
||||
return;
|
||||
}
|
||||
|
||||
registry.registerPlugin(plugin.pluginKey, manifest, plugin.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a `RegisteredTool` to an `AgentToolDescriptor`.
|
||||
*/
|
||||
function toAgentDescriptor(tool: RegisteredTool): AgentToolDescriptor {
|
||||
return {
|
||||
name: tool.namespacedName,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
parametersSchema: tool.parametersSchema,
|
||||
pluginId: tool.pluginDbId,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Lifecycle event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function handlePluginEnabled(payload: { pluginId: string; pluginKey: string }): void {
|
||||
log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin enabled — registering tools");
|
||||
// Async registration from DB — we fire-and-forget since the lifecycle
|
||||
// event handler must be synchronous. Any errors are logged.
|
||||
void registerFromDb(payload.pluginId).catch((err) => {
|
||||
log.error(
|
||||
{ pluginId: payload.pluginId, err: err instanceof Error ? err.message : String(err) },
|
||||
"failed to register tools after plugin enabled",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePluginDisabled(payload: { pluginId: string; pluginKey: string; reason?: string }): void {
|
||||
log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin disabled — unregistering tools");
|
||||
registry.unregisterPlugin(payload.pluginKey);
|
||||
}
|
||||
|
||||
function handlePluginUnloaded(payload: { pluginId: string; pluginKey: string; removeData: boolean }): void {
|
||||
log.debug({ pluginId: payload.pluginId, pluginKey: payload.pluginKey }, "plugin unloaded — unregistering tools");
|
||||
registry.unregisterPlugin(payload.pluginKey);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
async initialize(): Promise<void> {
|
||||
if (initialized) {
|
||||
log.warn("dispatcher already initialized, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("initializing plugin tool dispatcher");
|
||||
|
||||
// Step 1: Load tools from all currently-ready plugins
|
||||
if (db) {
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
const readyPlugins = await pluginRegistry.listByStatus("ready") as PluginRecord[];
|
||||
|
||||
let totalTools = 0;
|
||||
for (const plugin of readyPlugins) {
|
||||
const manifest = plugin.manifestJson;
|
||||
if (manifest?.tools && manifest.tools.length > 0) {
|
||||
registry.registerPlugin(plugin.pluginKey, manifest, plugin.id);
|
||||
totalTools += manifest.tools.length;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ readyPlugins: readyPlugins.length, registeredTools: totalTools },
|
||||
"loaded tools from ready plugins",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Subscribe to lifecycle events for dynamic updates
|
||||
if (lifecycleManager) {
|
||||
enabledListener = handlePluginEnabled;
|
||||
disabledListener = handlePluginDisabled;
|
||||
unloadedListener = handlePluginUnloaded;
|
||||
|
||||
lifecycleManager.on("plugin.enabled", enabledListener);
|
||||
lifecycleManager.on("plugin.disabled", disabledListener);
|
||||
lifecycleManager.on("plugin.unloaded", unloadedListener);
|
||||
|
||||
log.debug("subscribed to lifecycle events");
|
||||
} else {
|
||||
log.warn("no lifecycle manager provided — tools will not auto-update on plugin state changes");
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
log.info(
|
||||
{ totalTools: registry.toolCount() },
|
||||
"plugin tool dispatcher initialized",
|
||||
);
|
||||
},
|
||||
|
||||
teardown(): void {
|
||||
if (!initialized) return;
|
||||
|
||||
// Unsubscribe from lifecycle events
|
||||
if (lifecycleManager) {
|
||||
if (enabledListener) lifecycleManager.off("plugin.enabled", enabledListener);
|
||||
if (disabledListener) lifecycleManager.off("plugin.disabled", disabledListener);
|
||||
if (unloadedListener) lifecycleManager.off("plugin.unloaded", unloadedListener);
|
||||
|
||||
enabledListener = null;
|
||||
disabledListener = null;
|
||||
unloadedListener = null;
|
||||
}
|
||||
|
||||
// Note: we do NOT clear the registry here because teardown may be
|
||||
// called during graceful shutdown where in-flight tool calls should
|
||||
// still be able to resolve their tool entries.
|
||||
|
||||
initialized = false;
|
||||
log.info("plugin tool dispatcher torn down");
|
||||
},
|
||||
|
||||
listToolsForAgent(filter?: ToolListFilter): AgentToolDescriptor[] {
|
||||
return registry.listTools(filter).map(toAgentDescriptor);
|
||||
},
|
||||
|
||||
getTool(namespacedName: string): RegisteredTool | null {
|
||||
return registry.getTool(namespacedName);
|
||||
},
|
||||
|
||||
async executeTool(
|
||||
namespacedName: string,
|
||||
parameters: unknown,
|
||||
runContext: ToolRunContext,
|
||||
): Promise<ToolExecutionResult> {
|
||||
log.debug(
|
||||
{
|
||||
tool: namespacedName,
|
||||
agentId: runContext.agentId,
|
||||
runId: runContext.runId,
|
||||
},
|
||||
"dispatching tool execution",
|
||||
);
|
||||
|
||||
const result = await registry.executeTool(
|
||||
namespacedName,
|
||||
parameters,
|
||||
runContext,
|
||||
);
|
||||
|
||||
log.debug(
|
||||
{
|
||||
tool: namespacedName,
|
||||
pluginId: result.pluginId,
|
||||
hasContent: !!result.result.content,
|
||||
hasError: !!result.result.error,
|
||||
},
|
||||
"tool execution completed",
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
registerPluginTools(
|
||||
pluginId: string,
|
||||
manifest: PaperclipPluginManifestV1,
|
||||
): void {
|
||||
registry.registerPlugin(pluginId, manifest);
|
||||
},
|
||||
|
||||
unregisterPluginTools(pluginId: string): void {
|
||||
registry.unregisterPlugin(pluginId);
|
||||
},
|
||||
|
||||
toolCount(pluginId?: string): number {
|
||||
return registry.toolCount(pluginId);
|
||||
},
|
||||
|
||||
getRegistry(): PluginToolRegistry {
|
||||
return registry;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* PluginToolRegistry — host-side registry for plugin-contributed agent tools.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Store tool declarations (from plugin manifests) alongside routing metadata
|
||||
* so the host can resolve namespaced tool names to the owning plugin worker.
|
||||
* - Namespace tools automatically: a tool `"search-issues"` from plugin
|
||||
* `"acme.linear"` is exposed to agents as `"acme.linear:search-issues"`.
|
||||
* - Route `executeTool` calls to the correct plugin worker via the
|
||||
* `PluginWorkerManager`.
|
||||
* - Provide tool discovery queries so agents can list available tools.
|
||||
* - Clean up tool registrations when a plugin is unloaded or its worker stops.
|
||||
*
|
||||
* The registry is an in-memory structure — tool declarations are derived from
|
||||
* the plugin manifest at load time and do not need persistence. When a plugin
|
||||
* worker restarts, the host re-registers its manifest tools.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
* @see PLUGIN_SPEC.md §13.10 — `executeTool`
|
||||
*/
|
||||
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginToolDeclaration,
|
||||
} from "@paperclipai/shared";
|
||||
import type { ToolRunContext, ToolResult, ExecuteToolParams } from "@paperclipai/plugin-sdk";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Separator between plugin ID and tool name in the namespaced tool identifier.
|
||||
*
|
||||
* Example: `"acme.linear:search-issues"`
|
||||
*/
|
||||
export const TOOL_NAMESPACE_SEPARATOR = ":";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A registered tool entry stored in the registry.
|
||||
*
|
||||
* Combines the manifest-level declaration with routing metadata so the host
|
||||
* can resolve a namespaced tool name → plugin worker in O(1).
|
||||
*/
|
||||
export interface RegisteredTool {
|
||||
/** The plugin key used for namespacing (e.g. `"acme.linear"`). */
|
||||
pluginId: string;
|
||||
/**
|
||||
* The plugin's database UUID, used for worker routing and availability
|
||||
* checks. Falls back to `pluginId` when not provided (e.g. in tests
|
||||
* where `id === pluginKey`).
|
||||
*/
|
||||
pluginDbId: string;
|
||||
/** The tool's bare name (without namespace prefix). */
|
||||
name: string;
|
||||
/** Fully namespaced identifier: `"<pluginId>:<toolName>"`. */
|
||||
namespacedName: string;
|
||||
/** Human-readable display name. */
|
||||
displayName: string;
|
||||
/** Description provided to the agent so it knows when to use this tool. */
|
||||
description: string;
|
||||
/** JSON Schema describing the tool's input parameters. */
|
||||
parametersSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter criteria for listing available tools.
|
||||
*/
|
||||
export interface ToolListFilter {
|
||||
/** Only return tools owned by this plugin. */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a tool, extending `ToolResult` with routing metadata.
|
||||
*/
|
||||
export interface ToolExecutionResult {
|
||||
/** The plugin that handled the tool call. */
|
||||
pluginId: string;
|
||||
/** The bare tool name that was executed. */
|
||||
toolName: string;
|
||||
/** The result returned by the plugin's tool handler. */
|
||||
result: ToolResult;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginToolRegistry interface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The host-side tool registry — held by the host process.
|
||||
*
|
||||
* Created once at server startup and shared across the application. Plugins
|
||||
* register their tools when their worker starts, and unregister when the
|
||||
* worker stops or the plugin is uninstalled.
|
||||
*/
|
||||
export interface PluginToolRegistry {
|
||||
/**
|
||||
* Register all tools declared in a plugin's manifest.
|
||||
*
|
||||
* Called when a plugin worker starts and its manifest is loaded. Any
|
||||
* previously registered tools for the same plugin are replaced (idempotent).
|
||||
*
|
||||
* @param pluginId - The plugin's unique identifier (e.g. `"acme.linear"`)
|
||||
* @param manifest - The plugin manifest containing the `tools` array
|
||||
* @param pluginDbId - The plugin's database UUID, used for worker routing
|
||||
* and availability checks. If omitted, `pluginId` is used (backwards-compat).
|
||||
*/
|
||||
registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void;
|
||||
|
||||
/**
|
||||
* Remove all tool registrations for a plugin.
|
||||
*
|
||||
* Called when a plugin worker stops, crashes, or is uninstalled.
|
||||
*
|
||||
* @param pluginId - The plugin to clear
|
||||
*/
|
||||
unregisterPlugin(pluginId: string): void;
|
||||
|
||||
/**
|
||||
* Look up a registered tool by its namespaced name.
|
||||
*
|
||||
* @param namespacedName - Fully qualified name, e.g. `"acme.linear:search-issues"`
|
||||
* @returns The registered tool entry, or `null` if not found
|
||||
*/
|
||||
getTool(namespacedName: string): RegisteredTool | null;
|
||||
|
||||
/**
|
||||
* Look up a registered tool by plugin ID and bare tool name.
|
||||
*
|
||||
* @param pluginId - The owning plugin
|
||||
* @param toolName - The bare tool name (without namespace prefix)
|
||||
* @returns The registered tool entry, or `null` if not found
|
||||
*/
|
||||
getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null;
|
||||
|
||||
/**
|
||||
* List all registered tools, optionally filtered.
|
||||
*
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Array of registered tool entries
|
||||
*/
|
||||
listTools(filter?: ToolListFilter): RegisteredTool[];
|
||||
|
||||
/**
|
||||
* Parse a namespaced tool name into plugin ID and bare tool name.
|
||||
*
|
||||
* @param namespacedName - e.g. `"acme.linear:search-issues"`
|
||||
* @returns `{ pluginId, toolName }` or `null` if the format is invalid
|
||||
*/
|
||||
parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null;
|
||||
|
||||
/**
|
||||
* Build a namespaced tool name from a plugin ID and bare tool name.
|
||||
*
|
||||
* @param pluginId - e.g. `"acme.linear"`
|
||||
* @param toolName - e.g. `"search-issues"`
|
||||
* @returns The namespaced name, e.g. `"acme.linear:search-issues"`
|
||||
*/
|
||||
buildNamespacedName(pluginId: string, toolName: string): string;
|
||||
|
||||
/**
|
||||
* Execute a tool by its namespaced name, routing to the correct plugin worker.
|
||||
*
|
||||
* Resolves the namespaced name to the owning plugin, validates the tool
|
||||
* exists, and dispatches the `executeTool` RPC call to the worker.
|
||||
*
|
||||
* @param namespacedName - Fully qualified tool name (e.g. `"acme.linear:search-issues"`)
|
||||
* @param parameters - The parsed parameters matching the tool's schema
|
||||
* @param runContext - Agent run context
|
||||
* @returns The execution result with routing metadata
|
||||
* @throws {Error} if the tool is not found or the worker is not running
|
||||
*/
|
||||
executeTool(
|
||||
namespacedName: string,
|
||||
parameters: unknown,
|
||||
runContext: ToolRunContext,
|
||||
): Promise<ToolExecutionResult>;
|
||||
|
||||
/**
|
||||
* Get the number of registered tools, optionally scoped to a plugin.
|
||||
*
|
||||
* @param pluginId - If provided, count only this plugin's tools
|
||||
*/
|
||||
toolCount(pluginId?: string): number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory: createPluginToolRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new `PluginToolRegistry`.
|
||||
*
|
||||
* The registry is backed by two in-memory maps:
|
||||
* - `byNamespace`: namespaced name → `RegisteredTool` for O(1) lookups.
|
||||
* - `byPlugin`: pluginId → Set of namespaced names for efficient per-plugin ops.
|
||||
*
|
||||
* @param workerManager - The worker manager used to dispatch `executeTool` RPC
|
||||
* calls to plugin workers. If not provided, `executeTool` will throw.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const toolRegistry = createPluginToolRegistry(workerManager);
|
||||
*
|
||||
* // Register tools from a plugin manifest
|
||||
* toolRegistry.registerPlugin("acme.linear", linearManifest);
|
||||
*
|
||||
* // List all available tools for agents
|
||||
* const tools = toolRegistry.listTools();
|
||||
* // → [{ namespacedName: "acme.linear:search-issues", ... }]
|
||||
*
|
||||
* // Execute a tool
|
||||
* const result = await toolRegistry.executeTool(
|
||||
* "acme.linear:search-issues",
|
||||
* { query: "auth bug" },
|
||||
* { agentId: "agent-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" },
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function createPluginToolRegistry(
|
||||
workerManager?: PluginWorkerManager,
|
||||
): PluginToolRegistry {
|
||||
const log = logger.child({ service: "plugin-tool-registry" });
|
||||
|
||||
// Primary index: namespaced name → tool entry
|
||||
const byNamespace = new Map<string, RegisteredTool>();
|
||||
|
||||
// Secondary index: pluginId → set of namespaced names (for bulk operations)
|
||||
const byPlugin = new Map<string, Set<string>>();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function buildName(pluginId: string, toolName: string): string {
|
||||
return `${pluginId}${TOOL_NAMESPACE_SEPARATOR}${toolName}`;
|
||||
}
|
||||
|
||||
function parseName(namespacedName: string): { pluginId: string; toolName: string } | null {
|
||||
const sepIndex = namespacedName.lastIndexOf(TOOL_NAMESPACE_SEPARATOR);
|
||||
if (sepIndex <= 0 || sepIndex >= namespacedName.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pluginId: namespacedName.slice(0, sepIndex),
|
||||
toolName: namespacedName.slice(sepIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function addTool(pluginId: string, decl: PluginToolDeclaration, pluginDbId: string): void {
|
||||
const namespacedName = buildName(pluginId, decl.name);
|
||||
|
||||
const entry: RegisteredTool = {
|
||||
pluginId,
|
||||
pluginDbId,
|
||||
name: decl.name,
|
||||
namespacedName,
|
||||
displayName: decl.displayName,
|
||||
description: decl.description,
|
||||
parametersSchema: decl.parametersSchema,
|
||||
};
|
||||
|
||||
byNamespace.set(namespacedName, entry);
|
||||
|
||||
let pluginTools = byPlugin.get(pluginId);
|
||||
if (!pluginTools) {
|
||||
pluginTools = new Set();
|
||||
byPlugin.set(pluginId, pluginTools);
|
||||
}
|
||||
pluginTools.add(namespacedName);
|
||||
}
|
||||
|
||||
function removePluginTools(pluginId: string): number {
|
||||
const pluginTools = byPlugin.get(pluginId);
|
||||
if (!pluginTools) return 0;
|
||||
|
||||
const count = pluginTools.size;
|
||||
for (const name of pluginTools) {
|
||||
byNamespace.delete(name);
|
||||
}
|
||||
byPlugin.delete(pluginId);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId?: string): void {
|
||||
const dbId = pluginDbId ?? pluginId;
|
||||
|
||||
// Remove any previously registered tools for this plugin (idempotent)
|
||||
const previousCount = removePluginTools(pluginId);
|
||||
if (previousCount > 0) {
|
||||
log.debug(
|
||||
{ pluginId, previousCount },
|
||||
"cleared previous tool registrations before re-registering",
|
||||
);
|
||||
}
|
||||
|
||||
const tools = manifest.tools ?? [];
|
||||
if (tools.length === 0) {
|
||||
log.debug({ pluginId }, "plugin declares no tools");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const decl of tools) {
|
||||
addTool(pluginId, decl, dbId);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
pluginId,
|
||||
toolCount: tools.length,
|
||||
tools: tools.map((t) => buildName(pluginId, t.name)),
|
||||
},
|
||||
`registered ${tools.length} tool(s) for plugin`,
|
||||
);
|
||||
},
|
||||
|
||||
unregisterPlugin(pluginId: string): void {
|
||||
const removed = removePluginTools(pluginId);
|
||||
if (removed > 0) {
|
||||
log.info(
|
||||
{ pluginId, removedCount: removed },
|
||||
`unregistered ${removed} tool(s) for plugin`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
getTool(namespacedName: string): RegisteredTool | null {
|
||||
return byNamespace.get(namespacedName) ?? null;
|
||||
},
|
||||
|
||||
getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null {
|
||||
const namespacedName = buildName(pluginId, toolName);
|
||||
return byNamespace.get(namespacedName) ?? null;
|
||||
},
|
||||
|
||||
listTools(filter?: ToolListFilter): RegisteredTool[] {
|
||||
if (filter?.pluginId) {
|
||||
const pluginTools = byPlugin.get(filter.pluginId);
|
||||
if (!pluginTools) return [];
|
||||
const result: RegisteredTool[] = [];
|
||||
for (const name of pluginTools) {
|
||||
const tool = byNamespace.get(name);
|
||||
if (tool) result.push(tool);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return Array.from(byNamespace.values());
|
||||
},
|
||||
|
||||
parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null {
|
||||
return parseName(namespacedName);
|
||||
},
|
||||
|
||||
buildNamespacedName(pluginId: string, toolName: string): string {
|
||||
return buildName(pluginId, toolName);
|
||||
},
|
||||
|
||||
async executeTool(
|
||||
namespacedName: string,
|
||||
parameters: unknown,
|
||||
runContext: ToolRunContext,
|
||||
): Promise<ToolExecutionResult> {
|
||||
// 1. Resolve the namespaced name
|
||||
const parsed = parseName(namespacedName);
|
||||
if (!parsed) {
|
||||
throw new Error(
|
||||
`Invalid tool name "${namespacedName}". Expected format: "<pluginId>${TOOL_NAMESPACE_SEPARATOR}<toolName>"`,
|
||||
);
|
||||
}
|
||||
|
||||
const { pluginId, toolName } = parsed;
|
||||
|
||||
// 2. Verify the tool is registered
|
||||
const tool = byNamespace.get(namespacedName);
|
||||
if (!tool) {
|
||||
throw new Error(
|
||||
`Tool "${namespacedName}" is not registered. ` +
|
||||
`The plugin may not be installed or its worker may not be running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Verify the worker manager is available
|
||||
if (!workerManager) {
|
||||
throw new Error(
|
||||
`Cannot execute tool "${namespacedName}" — no worker manager configured. ` +
|
||||
`Tool execution requires a PluginWorkerManager.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Verify the plugin worker is running (use DB UUID for worker lookup)
|
||||
const dbId = tool.pluginDbId;
|
||||
if (!workerManager.isRunning(dbId)) {
|
||||
throw new Error(
|
||||
`Cannot execute tool "${namespacedName}" — ` +
|
||||
`worker for plugin "${pluginId}" is not running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Dispatch the executeTool RPC call to the worker
|
||||
log.debug(
|
||||
{ pluginId, pluginDbId: dbId, toolName, namespacedName, agentId: runContext.agentId, runId: runContext.runId },
|
||||
"executing tool via plugin worker",
|
||||
);
|
||||
|
||||
const rpcParams: ExecuteToolParams = {
|
||||
toolName,
|
||||
parameters,
|
||||
runContext,
|
||||
};
|
||||
|
||||
const result = await workerManager.call(dbId, "executeTool", rpcParams);
|
||||
|
||||
log.debug(
|
||||
{
|
||||
pluginId,
|
||||
toolName,
|
||||
namespacedName,
|
||||
hasContent: !!result.content,
|
||||
hasData: result.data !== undefined,
|
||||
hasError: !!result.error,
|
||||
},
|
||||
"tool execution completed",
|
||||
);
|
||||
|
||||
return { pluginId, toolName, result };
|
||||
},
|
||||
|
||||
toolCount(pluginId?: string): number {
|
||||
if (pluginId !== undefined) {
|
||||
return byPlugin.get(pluginId)?.size ?? 0;
|
||||
}
|
||||
return byNamespace.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,859 @@
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import {
|
||||
PROJECT_COLORS,
|
||||
deriveProjectUrlKey,
|
||||
isUuidLike,
|
||||
normalizeProjectUrlKey,
|
||||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
type CreateWorkspaceInput = {
|
||||
name?: string | null;
|
||||
sourceType?: string | null;
|
||||
cwd?: string | null;
|
||||
repoUrl?: string | null;
|
||||
repoRef?: string | null;
|
||||
defaultRef?: string | null;
|
||||
visibility?: string | null;
|
||||
setupCommand?: string | null;
|
||||
cleanupCommand?: string | null;
|
||||
remoteProvider?: string | null;
|
||||
remoteWorkspaceRef?: string | null;
|
||||
sharedWorkspaceKey?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
}
|
||||
|
||||
interface ProjectShortnameRow {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ResolveProjectNameOptions {
|
||||
excludeProjectId?: string | null;
|
||||
}
|
||||
|
||||
/** Batch-load goal refs for a set of projects. */
|
||||
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const projectIds = rows.map((r) => r.id);
|
||||
|
||||
// Fetch join rows + goal titles in one query
|
||||
const links = await db
|
||||
.select({
|
||||
projectId: projectGoals.projectId,
|
||||
goalId: projectGoals.goalId,
|
||||
goalTitle: goals.title,
|
||||
})
|
||||
.from(projectGoals)
|
||||
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
||||
.where(inArray(projectGoals.projectId, projectIds));
|
||||
|
||||
const map = new Map<string, ProjectGoalRef[]>();
|
||||
for (const link of links) {
|
||||
let arr = map.get(link.projectId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(link.projectId, arr);
|
||||
}
|
||||
arr.push({ id: link.goalId, title: link.goalTitle });
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const g = map.get(r.id) ?? [];
|
||||
return {
|
||||
...r,
|
||||
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||
goalIds: g.map((x) => x.id),
|
||||
goals: g,
|
||||
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
|
||||
} as ProjectWithGoals;
|
||||
});
|
||||
}
|
||||
|
||||
function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
serviceName: row.serviceName,
|
||||
status: row.status as WorkspaceRuntimeService["status"],
|
||||
lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"],
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: row.port ?? null,
|
||||
url: row.url ?? null,
|
||||
provider: row.provider as WorkspaceRuntimeService["provider"],
|
||||
providerRef: row.providerRef ?? null,
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: row.lastUsedAt,
|
||||
startedAt: row.startedAt,
|
||||
stoppedAt: row.stoppedAt ?? null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toWorkspace(
|
||||
row: ProjectWorkspaceRow,
|
||||
runtimeServices: WorkspaceRuntimeService[] = [],
|
||||
): ProjectWorkspace {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
name: row.name,
|
||||
sourceType: row.sourceType as ProjectWorkspace["sourceType"],
|
||||
cwd: normalizeWorkspaceCwd(row.cwd),
|
||||
repoUrl: row.repoUrl ?? null,
|
||||
repoRef: row.repoRef ?? null,
|
||||
defaultRef: row.defaultRef ?? row.repoRef ?? null,
|
||||
visibility: row.visibility as ProjectWorkspace["visibility"],
|
||||
setupCommand: row.setupCommand ?? null,
|
||||
cleanupCommand: row.cleanupCommand ?? null,
|
||||
remoteProvider: row.remoteProvider ?? null,
|
||||
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
|
||||
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
isPrimary: row.isPrimary,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
const raw = readNonEmptyString(repoUrl);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
const cleanedPath = parsed.pathname.replace(/\/+$/, "");
|
||||
const repoName = cleanedPath.split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") ?? "";
|
||||
return repoName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveProjectCodebase(input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
fallbackWorkspaces: ProjectWorkspace[];
|
||||
}): ProjectCodebase {
|
||||
const primaryWorkspace = input.primaryWorkspace ?? input.fallbackWorkspaces[0] ?? null;
|
||||
const repoUrl = primaryWorkspace?.repoUrl ?? null;
|
||||
const repoName = deriveRepoNameFromRepoUrl(repoUrl);
|
||||
const localFolder = primaryWorkspace?.cwd ?? null;
|
||||
const managedFolder = resolveManagedProjectWorkspaceDir({
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId,
|
||||
repoName,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId: primaryWorkspace?.id ?? null,
|
||||
repoUrl,
|
||||
repoRef: primaryWorkspace?.repoRef ?? null,
|
||||
defaultRef: primaryWorkspace?.defaultRef ?? null,
|
||||
repoName,
|
||||
localFolder,
|
||||
managedFolder,
|
||||
effectiveLocalFolder: localFolder ?? managedFolder,
|
||||
origin: localFolder ? "local_folder" : "managed_checkout",
|
||||
};
|
||||
}
|
||||
|
||||
function pickPrimaryWorkspace(
|
||||
rows: ProjectWorkspaceRow[],
|
||||
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
|
||||
): ProjectWorkspace | null {
|
||||
if (rows.length === 0) return null;
|
||||
const explicitPrimary = rows.find((row) => row.isPrimary);
|
||||
const primary = explicitPrimary ?? rows[0];
|
||||
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
|
||||
}
|
||||
|
||||
/** Batch-load workspace refs for a set of projects. */
|
||||
async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<ProjectWithGoals[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const projectIds = rows.map((r) => r.id);
|
||||
const workspaceRows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
workspaceRows.map((workspace) => workspace.id),
|
||||
);
|
||||
const sharedRuntimeServicesByWorkspaceId = new Map(
|
||||
Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [
|
||||
workspaceId,
|
||||
services.map(toRuntimeService),
|
||||
]),
|
||||
);
|
||||
|
||||
const map = new Map<string, ProjectWorkspaceRow[]>();
|
||||
for (const row of workspaceRows) {
|
||||
let arr = map.get(row.projectId);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
map.set(row.projectId, arr);
|
||||
}
|
||||
arr.push(row);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||
toWorkspace(
|
||||
workspace,
|
||||
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
|
||||
),
|
||||
);
|
||||
const primaryWorkspace = pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId);
|
||||
return {
|
||||
...row,
|
||||
codebase: deriveProjectCodebase({
|
||||
companyId: row.companyId,
|
||||
projectId: row.id,
|
||||
primaryWorkspace,
|
||||
fallbackWorkspaces: workspaces,
|
||||
}),
|
||||
workspaces,
|
||||
primaryWorkspace,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sync the project_goals join table for a single project. */
|
||||
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||
// Delete existing links
|
||||
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
|
||||
|
||||
// Insert new links
|
||||
if (goalIds.length > 0) {
|
||||
await db.insert(projectGoals).values(
|
||||
goalIds.map((goalId) => ({ projectId, goalId, companyId })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve goalIds from input, handling the legacy goalId field. */
|
||||
function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined {
|
||||
if (data.goalIds !== undefined) return data.goalIds;
|
||||
if (data.goalId !== undefined) {
|
||||
return data.goalId ? [data.goalId] : [];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceCwd(value: unknown): string | null {
|
||||
const cwd = readNonEmptyString(value);
|
||||
if (!cwd) return null;
|
||||
return cwd === REPO_ONLY_CWD_SENTINEL ? null : cwd;
|
||||
}
|
||||
|
||||
function deriveNameFromCwd(cwd: string): string {
|
||||
const normalized = cwd.replace(/[\\/]+$/, "");
|
||||
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
||||
return segments[segments.length - 1] ?? "Local folder";
|
||||
}
|
||||
|
||||
function deriveNameFromRepoUrl(repoUrl: string): string {
|
||||
try {
|
||||
const url = new URL(repoUrl);
|
||||
const cleanedPath = url.pathname.replace(/\/+$/, "");
|
||||
const lastSegment = cleanedPath.split("/").filter(Boolean).pop() ?? "";
|
||||
const noGitSuffix = lastSegment.replace(/\.git$/i, "");
|
||||
return noGitSuffix || repoUrl;
|
||||
} catch {
|
||||
return repoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveWorkspaceName(input: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
repoUrl?: string | null;
|
||||
}) {
|
||||
const explicit = readNonEmptyString(input.name);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const cwd = readNonEmptyString(input.cwd);
|
||||
if (cwd) return deriveNameFromCwd(cwd);
|
||||
|
||||
const repoUrl = readNonEmptyString(input.repoUrl);
|
||||
if (repoUrl) return deriveNameFromRepoUrl(repoUrl);
|
||||
|
||||
return "Workspace";
|
||||
}
|
||||
|
||||
export function resolveProjectNameForUniqueShortname(
|
||||
requestedName: string,
|
||||
existingProjects: ProjectShortnameRow[],
|
||||
options?: ResolveProjectNameOptions,
|
||||
): string {
|
||||
const requestedShortname = normalizeProjectUrlKey(requestedName);
|
||||
if (!requestedShortname) return requestedName;
|
||||
|
||||
const usedShortnames = new Set(
|
||||
existingProjects
|
||||
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
|
||||
.map((project) => normalizeProjectUrlKey(project.name))
|
||||
.filter((value): value is string => value !== null),
|
||||
);
|
||||
if (!usedShortnames.has(requestedShortname)) return requestedName;
|
||||
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const candidateName = `${requestedName} ${suffix}`;
|
||||
const candidateShortname = normalizeProjectUrlKey(candidateName);
|
||||
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
|
||||
return candidateName;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback guard for pathological naming collisions.
|
||||
return `${requestedName} ${Date.now()}`;
|
||||
}
|
||||
|
||||
async function ensureSinglePrimaryWorkspace(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
keepWorkspaceId: string;
|
||||
},
|
||||
) {
|
||||
await dbOrTx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId),
|
||||
),
|
||||
);
|
||||
|
||||
await dbOrTx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId),
|
||||
eq(projectWorkspaces.id, input.keepWorkspaceId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function projectService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
const withGoals = await attachGoals(db, rows);
|
||||
return attachWorkspaces(db, withGoals);
|
||||
},
|
||||
|
||||
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
||||
const dedupedIds = [...new Set(ids)];
|
||||
if (dedupedIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds)));
|
||||
const withGoals = await attachGoals(db, rows);
|
||||
const withWorkspaces = await attachWorkspaces(db, withGoals);
|
||||
const byId = new Map(withWorkspaces.map((project) => [project.id, project]));
|
||||
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
if (!withGoals) return null;
|
||||
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
||||
return enriched ?? null;
|
||||
},
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
|
||||
// Auto-assign a color from the palette if none provided
|
||||
if (!projectData.color) {
|
||||
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
||||
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
||||
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
||||
projectData.color = nextColor;
|
||||
}
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||
|
||||
// Also write goalId to the legacy column (first goal or null)
|
||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
await syncGoalLinks(db, row.id, companyId, ids);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched!;
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
||||
): Promise<ProjectWithGoals | null> => {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
const existingProject = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existingProject) return null;
|
||||
|
||||
if (projectData.name !== undefined) {
|
||||
const existingShortname = normalizeProjectUrlKey(existingProject.name);
|
||||
const nextShortname = normalizeProjectUrlKey(projectData.name);
|
||||
if (existingShortname !== nextShortname) {
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, existingProject.companyId));
|
||||
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
|
||||
excludeProjectId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keep legacy goalId column in sync
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
...projectData,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (ids !== undefined) {
|
||||
updates.goalId = ids.length > 0 ? ids[0] : null;
|
||||
}
|
||||
|
||||
const row = await db
|
||||
.update(projects)
|
||||
.set(updates)
|
||||
.where(eq(projects.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (ids !== undefined) {
|
||||
await syncGoalLinks(db, id, row.companyId, ids);
|
||||
}
|
||||
|
||||
const [withGoals] = await attachGoals(db, [row]);
|
||||
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
||||
return enriched ?? null;
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) };
|
||||
}),
|
||||
|
||||
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
if (rows.length === 0) return [];
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
rows.map((workspace) => workspace.id),
|
||||
);
|
||||
return rows.map((row) =>
|
||||
toWorkspace(
|
||||
row,
|
||||
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
createWorkspace: async (
|
||||
projectId: string,
|
||||
data: CreateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
const project = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!project) return null;
|
||||
|
||||
const cwd = normalizeWorkspaceCwd(data.cwd);
|
||||
const repoUrl = readNonEmptyString(data.repoUrl);
|
||||
const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed");
|
||||
const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef);
|
||||
if (sourceType === "remote_managed") {
|
||||
if (!remoteWorkspaceRef && !repoUrl) return null;
|
||||
} else if (!cwd && !repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const name = deriveWorkspaceName({
|
||||
name: data.name,
|
||||
cwd,
|
||||
repoUrl,
|
||||
});
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(asc(projectWorkspaces.createdAt))
|
||||
.then((rows) => rows);
|
||||
|
||||
const shouldBePrimary = data.isPrimary === true || existing.length === 0;
|
||||
const created = await db.transaction(async (tx) => {
|
||||
if (shouldBePrimary) {
|
||||
await tx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, project.companyId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const row = await tx
|
||||
.insert(projectWorkspaces)
|
||||
.values({
|
||||
companyId: project.companyId,
|
||||
projectId,
|
||||
name,
|
||||
sourceType,
|
||||
cwd: cwd ?? null,
|
||||
repoUrl: repoUrl ?? null,
|
||||
repoRef: readNonEmptyString(data.repoRef),
|
||||
defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef),
|
||||
visibility: readNonEmptyString(data.visibility) ?? "default",
|
||||
setupCommand: readNonEmptyString(data.setupCommand),
|
||||
cleanupCommand: readNonEmptyString(data.cleanupCommand),
|
||||
remoteProvider: readNonEmptyString(data.remoteProvider),
|
||||
remoteWorkspaceRef,
|
||||
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
|
||||
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||
isPrimary: shouldBePrimary,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row;
|
||||
});
|
||||
|
||||
return created ? toWorkspace(created) : null;
|
||||
},
|
||||
|
||||
updateWorkspace: async (
|
||||
projectId: string,
|
||||
workspaceId: string,
|
||||
data: UpdateWorkspaceInput,
|
||||
): Promise<ProjectWorkspace | null> => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.id, workspaceId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextCwd =
|
||||
data.cwd !== undefined
|
||||
? normalizeWorkspaceCwd(data.cwd)
|
||||
: normalizeWorkspaceCwd(existing.cwd);
|
||||
const nextRepoUrl =
|
||||
data.repoUrl !== undefined
|
||||
? readNonEmptyString(data.repoUrl)
|
||||
: readNonEmptyString(existing.repoUrl);
|
||||
const nextSourceType =
|
||||
data.sourceType !== undefined
|
||||
? readNonEmptyString(data.sourceType)
|
||||
: readNonEmptyString(existing.sourceType);
|
||||
const nextRemoteWorkspaceRef =
|
||||
data.remoteWorkspaceRef !== undefined
|
||||
? readNonEmptyString(data.remoteWorkspaceRef)
|
||||
: readNonEmptyString(existing.remoteWorkspaceRef);
|
||||
if (nextSourceType === "remote_managed") {
|
||||
if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null;
|
||||
} else if (!nextCwd && !nextRepoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.name !== undefined) patch.name = deriveWorkspaceName({ name: data.name, cwd: nextCwd, repoUrl: nextRepoUrl });
|
||||
if (data.name === undefined && (data.cwd !== undefined || data.repoUrl !== undefined)) {
|
||||
patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl });
|
||||
}
|
||||
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
|
||||
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
|
||||
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef);
|
||||
if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType;
|
||||
if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef);
|
||||
if (data.visibility !== undefined && readNonEmptyString(data.visibility)) {
|
||||
patch.visibility = readNonEmptyString(data.visibility)!;
|
||||
}
|
||||
if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand);
|
||||
if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand);
|
||||
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
|
||||
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
|
||||
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
|
||||
if (data.metadata !== undefined) patch.metadata = data.metadata;
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary === true) {
|
||||
await tx
|
||||
.update(projectWorkspaces)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, existing.companyId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
);
|
||||
patch.isPrimary = true;
|
||||
} else if (data.isPrimary === false) {
|
||||
patch.isPrimary = false;
|
||||
}
|
||||
|
||||
const row = await tx
|
||||
.update(projectWorkspaces)
|
||||
.set(patch)
|
||||
.where(eq(projectWorkspaces.id, workspaceId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (row.isPrimary) return row;
|
||||
|
||||
const hasPrimary = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
eq(projectWorkspaces.isPrimary, true),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!hasPrimary) {
|
||||
const nextPrimaryCandidate = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
eq(projectWorkspaces.id, row.id),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const alternateCandidate = await tx
|
||||
.select({ id: projectWorkspaces.id })
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.then((rows) => rows.find((candidate) => candidate.id !== row.id) ?? null);
|
||||
|
||||
await ensureSinglePrimaryWorkspace(tx, {
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
keepWorkspaceId: alternateCandidate?.id ?? nextPrimaryCandidate?.id ?? row.id,
|
||||
});
|
||||
const refreshed = await tx
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.id, row.id))
|
||||
.then((rows) => rows[0] ?? row);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return updated ? toWorkspace(updated) : null;
|
||||
},
|
||||
|
||||
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.id, workspaceId),
|
||||
eq(projectWorkspaces.projectId, projectId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
const row = await tx
|
||||
.delete(projectWorkspaces)
|
||||
.where(eq(projectWorkspaces.id, workspaceId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
|
||||
if (!row.isPrimary) return row;
|
||||
|
||||
const next = await tx
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, row.companyId),
|
||||
eq(projectWorkspaces.projectId, row.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (next) {
|
||||
await ensureSinglePrimaryWorkspace(tx, {
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId,
|
||||
keepWorkspaceId: next.id,
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return removed ? toWorkspace(removed) : null;
|
||||
},
|
||||
|
||||
resolveByReference: async (companyId: string, reference: string) => {
|
||||
const raw = reference.trim();
|
||||
if (raw.length === 0) {
|
||||
return { project: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
if (isUuidLike(raw)) {
|
||||
const row = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, raw), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return { project: null, ambiguous: false } as const;
|
||||
return {
|
||||
project: { id: row.id, companyId: row.companyId, urlKey: deriveProjectUrlKey(row.name, row.id) },
|
||||
ambiguous: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const urlKey = normalizeProjectUrlKey(raw);
|
||||
if (!urlKey) {
|
||||
return { project: null, ambiguous: false } as const;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.companyId, companyId));
|
||||
const matches = rows.filter((row) => deriveProjectUrlKey(row.name, row.id) === urlKey);
|
||||
if (matches.length === 1) {
|
||||
const match = matches[0]!;
|
||||
return {
|
||||
project: { id: match.id, companyId: match.companyId, urlKey: deriveProjectUrlKey(match.name, match.id) },
|
||||
ambiguous: false,
|
||||
} as const;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { project: null, ambiguous: true } as const;
|
||||
}
|
||||
return { project: null, ambiguous: false } as const;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ProviderQuotaResult } from "@paperclipai/shared";
|
||||
import { listServerAdapters } from "../adapters/registry.js";
|
||||
|
||||
const QUOTA_PROVIDER_TIMEOUT_MS = 20_000;
|
||||
|
||||
function providerSlugForAdapterType(type: string): string {
|
||||
switch (type) {
|
||||
case "claude_local":
|
||||
return "anthropic";
|
||||
case "codex_local":
|
||||
return "openai";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks each registered adapter for its provider quota windows and aggregates the results.
|
||||
* Adapters that don't implement getQuotaWindows() are silently skipped.
|
||||
* Individual adapter failures are caught and returned as error results rather than
|
||||
* letting one provider's outage block the entire response.
|
||||
*/
|
||||
export async function fetchAllQuotaWindows(): Promise<ProviderQuotaResult[]> {
|
||||
const adapters = listServerAdapters().filter((a) => a.getQuotaWindows != null);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
adapters.map((adapter) => withQuotaTimeout(adapter.type, adapter.getQuotaWindows!())),
|
||||
);
|
||||
|
||||
return settled.map((result, i) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const adapterType = adapters[i]!.type;
|
||||
return {
|
||||
provider: providerSlugForAdapterType(adapterType),
|
||||
ok: false,
|
||||
error: String(result.reason),
|
||||
windows: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function withQuotaTimeout(
|
||||
adapterType: string,
|
||||
task: Promise<ProviderQuotaResult>,
|
||||
): Promise<ProviderQuotaResult> {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
task,
|
||||
new Promise<ProviderQuotaResult>((resolve) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
resolve({
|
||||
provider: providerSlugForAdapterType(adapterType),
|
||||
ok: false,
|
||||
error: `quota polling timed out after ${Math.round(QUOTA_PROVIDER_TIMEOUT_MS / 1000)}s`,
|
||||
windows: [],
|
||||
});
|
||||
}, QUOTA_PROVIDER_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,156 @@
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
export type RunLogStoreType = "local_file";
|
||||
|
||||
export interface RunLogHandle {
|
||||
store: RunLogStoreType;
|
||||
logRef: string;
|
||||
}
|
||||
|
||||
export interface RunLogReadOptions {
|
||||
offset?: number;
|
||||
limitBytes?: number;
|
||||
}
|
||||
|
||||
export interface RunLogReadResult {
|
||||
content: string;
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export interface RunLogFinalizeSummary {
|
||||
bytes: number;
|
||||
sha256?: string;
|
||||
compressed: boolean;
|
||||
}
|
||||
|
||||
export interface RunLogStore {
|
||||
begin(input: { companyId: string; agentId: string; runId: string }): Promise<RunLogHandle>;
|
||||
append(
|
||||
handle: RunLogHandle,
|
||||
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
|
||||
): Promise<void>;
|
||||
finalize(handle: RunLogHandle): Promise<RunLogFinalizeSummary>;
|
||||
read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise<RunLogReadResult>;
|
||||
}
|
||||
|
||||
function safeSegments(...segments: string[]) {
|
||||
return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));
|
||||
}
|
||||
|
||||
function resolveWithin(basePath: string, relativePath: string) {
|
||||
const resolved = path.resolve(basePath, relativePath);
|
||||
const base = path.resolve(basePath) + path.sep;
|
||||
if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {
|
||||
throw new Error("Invalid log path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function createLocalFileRunLogStore(basePath: string): RunLogStore {
|
||||
async function ensureDir(relativeDir: string) {
|
||||
const dir = resolveWithin(basePath, relativeDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function readFileRange(filePath: string, offset: number, limitBytes: number): Promise<RunLogReadResult> {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat) throw notFound("Run log not found");
|
||||
|
||||
const start = Math.max(0, Math.min(offset, stat.size));
|
||||
const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));
|
||||
|
||||
if (start > end) {
|
||||
return { content: "", nextOffset: start };
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
stream.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve());
|
||||
});
|
||||
|
||||
const content = Buffer.concat(chunks).toString("utf8");
|
||||
const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
|
||||
return { content, nextOffset };
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async begin(input) {
|
||||
const [companyId, agentId] = safeSegments(input.companyId, input.agentId);
|
||||
const runId = safeSegments(input.runId)[0]!;
|
||||
const relDir = path.join(companyId, agentId);
|
||||
const relPath = path.join(relDir, `${runId}.ndjson`);
|
||||
await ensureDir(relDir);
|
||||
|
||||
const absPath = resolveWithin(basePath, relPath);
|
||||
await fs.writeFile(absPath, "", "utf8");
|
||||
|
||||
return { store: "local_file", logRef: relPath };
|
||||
},
|
||||
|
||||
async append(handle, event) {
|
||||
if (handle.store !== "local_file") return;
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const line = JSON.stringify({
|
||||
ts: event.ts,
|
||||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
if (handle.store !== "local_file") {
|
||||
return { bytes: 0, compressed: false };
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const stat = await fs.stat(absPath).catch(() => null);
|
||||
if (!stat) throw notFound("Run log not found");
|
||||
|
||||
const hash = await sha256File(absPath);
|
||||
return {
|
||||
bytes: stat.size,
|
||||
sha256: hash,
|
||||
compressed: false,
|
||||
};
|
||||
},
|
||||
|
||||
async read(handle, opts) {
|
||||
if (handle.store !== "local_file") {
|
||||
throw notFound("Run log not found");
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limitBytes = opts?.limitBytes ?? 256_000;
|
||||
return readFileRange(absPath, offset, limitBytes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let cachedStore: RunLogStore | null = null;
|
||||
|
||||
export function getRunLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs");
|
||||
cachedStore = createLocalFileRunLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, companySecretVersions } from "@paperclipai/db";
|
||||
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared";
|
||||
import { envBindingSchema } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const REDACTED_SENTINEL = "***REDACTED***";
|
||||
|
||||
type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isSensitiveEnvKey(key: string) {
|
||||
return SENSITIVE_ENV_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
||||
if (typeof binding === "string") {
|
||||
return { type: "plain", value: binding };
|
||||
}
|
||||
if (binding.type === "plain") {
|
||||
return { type: "plain", value: String(binding.value) };
|
||||
}
|
||||
return {
|
||||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version ?? "latest",
|
||||
};
|
||||
}
|
||||
|
||||
export function secretService(db: Db) {
|
||||
async function getById(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getByName(companyId: string, name: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getSecretVersion(secretId: string, version: number) {
|
||||
return db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretVersions.secretId, secretId),
|
||||
eq(companySecretVersions.version, version),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assertSecretInCompany(companyId: string, secretId: string) {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
|
||||
return secret;
|
||||
}
|
||||
|
||||
async function resolveSecretValue(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
): Promise<string> {
|
||||
const secret = await assertSecretInCompany(companyId, secretId);
|
||||
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
|
||||
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
||||
if (!versionRow) throw notFound("Secret version not found");
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
return provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeEnvConfig(
|
||||
companyId: string,
|
||||
envValue: unknown,
|
||||
opts?: { strictMode?: boolean },
|
||||
): Promise<AgentEnvConfig> {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) throw unprocessable("adapterConfig.env must be an object");
|
||||
|
||||
const normalized: AgentEnvConfig = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
throw unprocessable(`Invalid environment variable name: ${key}`);
|
||||
}
|
||||
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
||||
}
|
||||
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) {
|
||||
throw unprocessable(
|
||||
`Strict secret mode requires secret references for sensitive key: ${key}`,
|
||||
);
|
||||
}
|
||||
if (binding.value === REDACTED_SENTINEL) {
|
||||
throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`);
|
||||
}
|
||||
normalized[key] = binding;
|
||||
continue;
|
||||
}
|
||||
|
||||
await assertSecretInCompany(companyId, binding.secretId);
|
||||
normalized[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
version: binding.version,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function normalizeAdapterConfigForPersistenceInternal(
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
) {
|
||||
const normalized = { ...adapterConfig };
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return normalized;
|
||||
}
|
||||
normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return {
|
||||
listProviders: () => listSecretProviders(),
|
||||
|
||||
list: (companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.companyId, companyId))
|
||||
.orderBy(desc(companySecrets.createdAt)),
|
||||
|
||||
getById,
|
||||
getByName,
|
||||
resolveSecretValue,
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
input: {
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
value: string;
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const existing = await getByName(companyId, input.name);
|
||||
if (existing) throw conflict(`Secret already exists: ${input.name}`);
|
||||
|
||||
const provider = getSecretProvider(input.provider);
|
||||
const prepared = await provider.createVersion({
|
||||
value: input.value,
|
||||
externalRef: input.externalRef ?? null,
|
||||
});
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const secret = await tx
|
||||
.insert(companySecrets)
|
||||
.values({
|
||||
companyId,
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
externalRef: prepared.externalRef,
|
||||
latestVersion: 1,
|
||||
description: input.description ?? null,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
await tx.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: 1,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
|
||||
return secret;
|
||||
});
|
||||
},
|
||||
|
||||
rotate: async (
|
||||
secretId: string,
|
||||
input: { value: string; externalRef?: string | null },
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
const nextVersion = secret.latestVersion + 1;
|
||||
const prepared = await provider.createVersion({
|
||||
value: input.value,
|
||||
externalRef: input.externalRef ?? secret.externalRef ?? null,
|
||||
});
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.insert(companySecretVersions).values({
|
||||
secretId: secret.id,
|
||||
version: nextVersion,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
});
|
||||
|
||||
const updated = await tx
|
||||
.update(companySecrets)
|
||||
.set({
|
||||
latestVersion: nextVersion,
|
||||
externalRef: prepared.externalRef,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!updated) throw notFound("Secret not found");
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
|
||||
update: async (
|
||||
secretId: string,
|
||||
patch: { name?: string; description?: string | null; externalRef?: string | null },
|
||||
) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
|
||||
if (patch.name && patch.name !== secret.name) {
|
||||
const duplicate = await getByName(secret.companyId, patch.name);
|
||||
if (duplicate && duplicate.id !== secret.id) {
|
||||
throw conflict(`Secret already exists: ${patch.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
.update(companySecrets)
|
||||
.set({
|
||||
name: patch.name ?? secret.name,
|
||||
description:
|
||||
patch.description === undefined ? secret.description : patch.description,
|
||||
externalRef:
|
||||
patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
|
||||
remove: async (secretId: string) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
|
||||
return secret;
|
||||
},
|
||||
|
||||
normalizeAdapterConfigForPersistence: async (
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
|
||||
|
||||
normalizeHireApprovalPayloadForPersistence: async (
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
opts?: { strictMode?: boolean },
|
||||
) => {
|
||||
const normalized = { ...payload };
|
||||
const adapterConfig = asRecord(payload.adapterConfig);
|
||||
if (adapterConfig) {
|
||||
normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal(
|
||||
companyId,
|
||||
adapterConfig,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
|
||||
resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record<string, string>; secretKeys: Set<string> }> => {
|
||||
const record = asRecord(envValue);
|
||||
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
|
||||
const resolved: Record<string, string> = {};
|
||||
const secretKeys = new Set<string>();
|
||||
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
throw unprocessable(`Invalid environment variable name: ${key}`);
|
||||
}
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
||||
}
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
resolved[key] = binding.value;
|
||||
} else {
|
||||
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
return { env: resolved, secretKeys };
|
||||
},
|
||||
|
||||
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return { config: resolved, secretKeys };
|
||||
}
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
return { config: resolved, secretKeys };
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
throw unprocessable(`Invalid environment variable name: ${key}`);
|
||||
}
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) {
|
||||
throw unprocessable(`Invalid environment binding for key: ${key}`);
|
||||
}
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
} else {
|
||||
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
|
||||
secretKeys.add(key);
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
return { config: resolved, secretKeys };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
||||
import type { SidebarBadges } from "@paperclipai/shared";
|
||||
|
||||
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
||||
|
||||
export function sidebarBadgeService(db: Db) {
|
||||
return {
|
||||
get: async (
|
||||
companyId: string,
|
||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
||||
): Promise<SidebarBadges> => {
|
||||
const actionableApprovals = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(approvals)
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.companyId, companyId),
|
||||
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
|
||||
const latestRunByAgent = await db
|
||||
.selectDistinctOn([heartbeatRuns.agentId], {
|
||||
runStatus: heartbeatRuns.status,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
eq(agents.companyId, companyId),
|
||||
not(eq(agents.status, "terminated")),
|
||||
),
|
||||
)
|
||||
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
||||
|
||||
const failedRuns = latestRunByAgent.filter((row) =>
|
||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
||||
).length;
|
||||
|
||||
const joinRequests = extra?.joinRequests ?? 0;
|
||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||
return {
|
||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { issueWorkProducts } from "@paperclipai/db";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
|
||||
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
|
||||
|
||||
function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
issueId: row.issueId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
runtimeServiceId: row.runtimeServiceId ?? null,
|
||||
type: row.type as IssueWorkProduct["type"],
|
||||
provider: row.provider,
|
||||
externalId: row.externalId ?? null,
|
||||
title: row.title,
|
||||
url: row.url ?? null,
|
||||
status: row.status,
|
||||
reviewState: row.reviewState as IssueWorkProduct["reviewState"],
|
||||
isPrimary: row.isPrimary,
|
||||
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
|
||||
summary: row.summary ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
createdByRunId: row.createdByRunId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function workProductService(db: Db) {
|
||||
return {
|
||||
listForIssue: async (issueId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.issueId, issueId))
|
||||
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
|
||||
return rows.map(toIssueWorkProduct);
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
|
||||
const row = await db.transaction(async (tx) => {
|
||||
if (data.isPrimary) {
|
||||
await tx
|
||||
.update(issueWorkProducts)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(issueWorkProducts.companyId, companyId),
|
||||
eq(issueWorkProducts.issueId, issueId),
|
||||
eq(issueWorkProducts.type, data.type),
|
||||
),
|
||||
);
|
||||
}
|
||||
return await tx
|
||||
.insert(issueWorkProducts)
|
||||
.values({
|
||||
...data,
|
||||
companyId,
|
||||
issueId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
|
||||
const row = await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
if (patch.isPrimary === true) {
|
||||
await tx
|
||||
.update(issueWorkProducts)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(issueWorkProducts.companyId, existing.companyId),
|
||||
eq(issueWorkProducts.issueId, existing.issueId),
|
||||
eq(issueWorkProducts.type, existing.type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await tx
|
||||
.update(issueWorkProducts)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
|
||||
remove: async (id: string) => {
|
||||
const row = await db
|
||||
.delete(issueWorkProducts)
|
||||
.where(eq(issueWorkProducts.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toIssueWorkProduct(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toIssueWorkProduct };
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { notFound } from "../errors.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
export type WorkspaceOperationLogStoreType = "local_file";
|
||||
|
||||
export interface WorkspaceOperationLogHandle {
|
||||
store: WorkspaceOperationLogStoreType;
|
||||
logRef: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogReadOptions {
|
||||
offset?: number;
|
||||
limitBytes?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogReadResult {
|
||||
content: string;
|
||||
nextOffset?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogFinalizeSummary {
|
||||
bytes: number;
|
||||
sha256?: string;
|
||||
compressed: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationLogStore {
|
||||
begin(input: { companyId: string; operationId: string }): Promise<WorkspaceOperationLogHandle>;
|
||||
append(
|
||||
handle: WorkspaceOperationLogHandle,
|
||||
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
|
||||
): Promise<void>;
|
||||
finalize(handle: WorkspaceOperationLogHandle): Promise<WorkspaceOperationLogFinalizeSummary>;
|
||||
read(handle: WorkspaceOperationLogHandle, opts?: WorkspaceOperationLogReadOptions): Promise<WorkspaceOperationLogReadResult>;
|
||||
}
|
||||
|
||||
function safeSegments(...segments: string[]) {
|
||||
return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));
|
||||
}
|
||||
|
||||
function resolveWithin(basePath: string, relativePath: string) {
|
||||
const resolved = path.resolve(basePath, relativePath);
|
||||
const base = path.resolve(basePath) + path.sep;
|
||||
if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {
|
||||
throw new Error("Invalid log path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function createLocalFileWorkspaceOperationLogStore(basePath: string): WorkspaceOperationLogStore {
|
||||
async function ensureDir(relativeDir: string) {
|
||||
const dir = resolveWithin(basePath, relativeDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function readFileRange(filePath: string, offset: number, limitBytes: number): Promise<WorkspaceOperationLogReadResult> {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat) throw notFound("Workspace operation log not found");
|
||||
|
||||
const start = Math.max(0, Math.min(offset, stat.size));
|
||||
const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));
|
||||
|
||||
if (start > end) {
|
||||
return { content: "", nextOffset: start };
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath, { start, end });
|
||||
stream.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve());
|
||||
});
|
||||
|
||||
const content = Buffer.concat(chunks).toString("utf8");
|
||||
const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
|
||||
return { content, nextOffset };
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
async begin(input) {
|
||||
const [companyId] = safeSegments(input.companyId);
|
||||
const operationId = safeSegments(input.operationId)[0]!;
|
||||
const relDir = companyId;
|
||||
const relPath = path.join(relDir, `${operationId}.ndjson`);
|
||||
await ensureDir(relDir);
|
||||
|
||||
const absPath = resolveWithin(basePath, relPath);
|
||||
await fs.writeFile(absPath, "", "utf8");
|
||||
|
||||
return { store: "local_file", logRef: relPath };
|
||||
},
|
||||
|
||||
async append(handle, event) {
|
||||
if (handle.store !== "local_file") return;
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const line = JSON.stringify({
|
||||
ts: event.ts,
|
||||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
if (handle.store !== "local_file") {
|
||||
return { bytes: 0, compressed: false };
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const stat = await fs.stat(absPath).catch(() => null);
|
||||
if (!stat) throw notFound("Workspace operation log not found");
|
||||
|
||||
const hash = await sha256File(absPath);
|
||||
return {
|
||||
bytes: stat.size,
|
||||
sha256: hash,
|
||||
compressed: false,
|
||||
};
|
||||
},
|
||||
|
||||
async read(handle, opts) {
|
||||
if (handle.store !== "local_file") {
|
||||
throw notFound("Workspace operation log not found");
|
||||
}
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limitBytes = opts?.limitBytes ?? 256_000;
|
||||
return readFileRange(absPath, offset, limitBytes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let cachedStore: WorkspaceOperationLogStore | null = null;
|
||||
|
||||
export function getWorkspaceOperationLogStore() {
|
||||
if (cachedStore) return cachedStore;
|
||||
const basePath = process.env.WORKSPACE_OPERATION_LOG_BASE_PATH
|
||||
?? path.resolve(resolvePaperclipInstanceRoot(), "data", "workspace-operation-logs");
|
||||
cachedStore = createLocalFileWorkspaceOperationLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { workspaceOperations } from "@paperclipai/db";
|
||||
import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus } from "@paperclipai/shared";
|
||||
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
|
||||
import { notFound } from "../errors.js";
|
||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
|
||||
|
||||
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
|
||||
|
||||
function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
heartbeatRunId: row.heartbeatRunId ?? null,
|
||||
phase: row.phase as WorkspaceOperationPhase,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
status: row.status as WorkspaceOperationStatus,
|
||||
exitCode: row.exitCode ?? null,
|
||||
logStore: row.logStore ?? null,
|
||||
logRef: row.logRef ?? null,
|
||||
logBytes: row.logBytes ?? null,
|
||||
logSha256: row.logSha256 ?? null,
|
||||
logCompressed: row.logCompressed,
|
||||
stdoutExcerpt: row.stdoutExcerpt ?? null,
|
||||
stderrExcerpt: row.stderrExcerpt ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function appendExcerpt(current: string, chunk: string) {
|
||||
return `${current}${chunk}`.slice(-4096);
|
||||
}
|
||||
|
||||
function combineMetadata(
|
||||
base: Record<string, unknown> | null | undefined,
|
||||
patch: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (!base && !patch) return null;
|
||||
return {
|
||||
...(base ?? {}),
|
||||
...(patch ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceOperationRecorder {
|
||||
attachExecutionWorkspaceId(executionWorkspaceId: string | null): Promise<void>;
|
||||
recordOperation(input: {
|
||||
phase: WorkspaceOperationPhase;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
run: () => Promise<{
|
||||
status?: WorkspaceOperationStatus;
|
||||
exitCode?: number | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
system?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}>;
|
||||
}): Promise<WorkspaceOperation>;
|
||||
}
|
||||
|
||||
export function workspaceOperationService(db: Db) {
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const logStore = getWorkspaceOperationLogStore();
|
||||
|
||||
async function getById(id: string) {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(eq(workspaceOperations.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toWorkspaceOperation(row) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
getById,
|
||||
|
||||
createRecorder(input: {
|
||||
companyId: string;
|
||||
heartbeatRunId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
}): WorkspaceOperationRecorder {
|
||||
let executionWorkspaceId = input.executionWorkspaceId ?? null;
|
||||
const createdIds: string[] = [];
|
||||
|
||||
return {
|
||||
async attachExecutionWorkspaceId(nextExecutionWorkspaceId) {
|
||||
executionWorkspaceId = nextExecutionWorkspaceId ?? null;
|
||||
if (!executionWorkspaceId || createdIds.length === 0) return;
|
||||
await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(workspaceOperations.id, createdIds));
|
||||
},
|
||||
|
||||
async recordOperation(recordInput) {
|
||||
const currentUserRedactionOptions = {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
};
|
||||
const startedAt = new Date();
|
||||
const id = randomUUID();
|
||||
const handle = await logStore.begin({
|
||||
companyId: input.companyId,
|
||||
operationId: id,
|
||||
});
|
||||
|
||||
let stdoutExcerpt = "";
|
||||
let stderrExcerpt = "";
|
||||
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
|
||||
if (!chunk) return;
|
||||
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
|
||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||
await logStore.append(handle, {
|
||||
stream,
|
||||
chunk: sanitizedChunk,
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
phase: recordInput.phase,
|
||||
command: recordInput.command ?? null,
|
||||
cwd: recordInput.cwd ?? null,
|
||||
status: "running",
|
||||
logStore: handle.store,
|
||||
logRef: handle.logRef,
|
||||
metadata: redactCurrentUserValue(
|
||||
recordInput.metadata ?? null,
|
||||
currentUserRedactionOptions,
|
||||
) as Record<string, unknown> | null,
|
||||
startedAt,
|
||||
});
|
||||
createdIds.push(id);
|
||||
|
||||
try {
|
||||
const result = await recordInput.run();
|
||||
await append("system", result.system ?? null);
|
||||
await append("stdout", result.stdout ?? null);
|
||||
await append("stderr", result.stderr ?? null);
|
||||
const finalized = await logStore.finalize(handle);
|
||||
const finishedAt = new Date();
|
||||
const row = await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
status: result.status ?? "succeeded",
|
||||
exitCode: result.exitCode ?? null,
|
||||
stdoutExcerpt: stdoutExcerpt || null,
|
||||
stderrExcerpt: stderrExcerpt || null,
|
||||
logBytes: finalized.bytes,
|
||||
logSha256: finalized.sha256,
|
||||
logCompressed: finalized.compressed,
|
||||
metadata: redactCurrentUserValue(
|
||||
combineMetadata(recordInput.metadata, result.metadata),
|
||||
currentUserRedactionOptions,
|
||||
) as Record<string, unknown> | null,
|
||||
finishedAt,
|
||||
updatedAt: finishedAt,
|
||||
})
|
||||
.where(eq(workspaceOperations.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Workspace operation not found");
|
||||
return toWorkspaceOperation(row);
|
||||
} catch (error) {
|
||||
await append("stderr", error instanceof Error ? error.message : String(error));
|
||||
const finalized = await logStore.finalize(handle).catch(() => null);
|
||||
const finishedAt = new Date();
|
||||
await db
|
||||
.update(workspaceOperations)
|
||||
.set({
|
||||
executionWorkspaceId,
|
||||
status: "failed",
|
||||
stdoutExcerpt: stdoutExcerpt || null,
|
||||
stderrExcerpt: stderrExcerpt || null,
|
||||
logBytes: finalized?.bytes ?? null,
|
||||
logSha256: finalized?.sha256 ?? null,
|
||||
logCompressed: finalized?.compressed ?? false,
|
||||
finishedAt,
|
||||
updatedAt: finishedAt,
|
||||
})
|
||||
.where(eq(workspaceOperations.id, id));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
listForRun: async (runId: string, executionWorkspaceId?: string | null) => {
|
||||
const conditions = [eq(workspaceOperations.heartbeatRunId, runId)];
|
||||
if (executionWorkspaceId) {
|
||||
const cleanupCondition = and(
|
||||
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId)!,
|
||||
isNull(workspaceOperations.heartbeatRunId),
|
||||
)!;
|
||||
if (cleanupCondition) conditions.push(cleanupCondition);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(conditions.length === 1 ? conditions[0]! : or(...conditions)!)
|
||||
.orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt), asc(workspaceOperations.id));
|
||||
|
||||
return rows.map(toWorkspaceOperation);
|
||||
},
|
||||
|
||||
listForExecutionWorkspace: async (executionWorkspaceId: string) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceOperations)
|
||||
.where(eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId))
|
||||
.orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.createdAt));
|
||||
return rows.map(toWorkspaceOperation);
|
||||
},
|
||||
|
||||
readLog: async (operationId: string, opts?: { offset?: number; limitBytes?: number }) => {
|
||||
const operation = await getById(operationId);
|
||||
if (!operation) throw notFound("Workspace operation not found");
|
||||
if (!operation.logStore || !operation.logRef) throw notFound("Workspace operation log not found");
|
||||
|
||||
const result = await logStore.read(
|
||||
{
|
||||
store: operation.logStore as "local_file",
|
||||
logRef: operation.logRef,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
return {
|
||||
operationId,
|
||||
store: operation.logStore,
|
||||
logRef: operation.logRef,
|
||||
...result,
|
||||
content: redactCurrentUserText(result.content, {
|
||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { toWorkspaceOperation };
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user