Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
@@ -1,8 +1,25 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
@@ -45,4 +62,27 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { eq, count } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
companies,
|
||||
companyLogos,
|
||||
assets,
|
||||
agents,
|
||||
agentApiKeys,
|
||||
agentRuntimeState,
|
||||
@@ -23,10 +25,41 @@ import {
|
||||
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 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;
|
||||
@@ -70,32 +103,97 @@ export function companyService(db: Db) {
|
||||
}
|
||||
|
||||
return {
|
||||
list: () => db.select().from(companies),
|
||||
list: () =>
|
||||
getCompanyQuery(db).then((rows) => rows.map((row) => enrichCompany(row))),
|
||||
|
||||
getById: (id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(companies)
|
||||
getCompanyQuery(db)
|
||||
.where(eq(companies.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
.then((rows) => (rows[0] ? enrichCompany(rows[0]) : null)),
|
||||
|
||||
create: async (data: typeof companies.$inferInsert) => createCompanyWithUniquePrefix(data),
|
||||
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");
|
||||
return enrichCompany(row);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
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));
|
||||
}
|
||||
|
||||
return enrichCompany({
|
||||
...updated,
|
||||
logoAssetId: logoAssetId === undefined ? existing.logoAssetId : logoAssetId,
|
||||
});
|
||||
}),
|
||||
|
||||
archive: (id: string) =>
|
||||
db
|
||||
.update(companies)
|
||||
.set({ status: "archived", updatedAt: new Date() })
|
||||
.where(eq(companies.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
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);
|
||||
return row ? enrichCompany(row) : null;
|
||||
}),
|
||||
|
||||
remove: (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
@@ -116,6 +214,8 @@ export function companyService(db: Db) {
|
||||
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));
|
||||
|
||||
@@ -1364,11 +1364,11 @@ export function heartbeatService(db: Db) {
|
||||
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
|
||||
const now = new Date();
|
||||
|
||||
// Find all runs in "queued" or "running" state
|
||||
// Find all runs stuck in "running" state (queued runs are legitimately waiting; resumeQueuedRuns handles them)
|
||||
const activeRuns = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(inArray(heartbeatRuns.status, ["queued", "running"]));
|
||||
.where(eq(heartbeatRuns.status, "running"));
|
||||
|
||||
const reaped: string[] = [];
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
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",
|
||||
@@ -124,6 +125,7 @@ const LAUNCHER_PLACEMENT_CAPABILITIES: Record<
|
||||
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",
|
||||
|
||||
@@ -34,6 +34,10 @@ export function validateInstanceConfig(
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -556,6 +556,18 @@ export function buildHostServices(
|
||||
}
|
||||
await scopedBus.emit(params.name, params.companyId, params.payload);
|
||||
},
|
||||
async subscribe(params: { eventPattern: string; filter?: Record<string, unknown> | null }) {
|
||||
const handler = async (event: import("@paperclipai/plugin-sdk").PluginEvent) => {
|
||||
if (notifyWorker) {
|
||||
notifyWorker("onEvent", { event });
|
||||
}
|
||||
};
|
||||
if (params.filter) {
|
||||
scopedBus.subscribe(params.eventPattern as any, params.filter as any, handler);
|
||||
} else {
|
||||
scopedBus.subscribe(params.eventPattern as any, handler);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
http: {
|
||||
@@ -1058,6 +1070,10 @@ export function buildHostServices(
|
||||
dispose() {
|
||||
disposed = true;
|
||||
|
||||
// Clear event bus subscriptions to prevent accumulation on worker restart.
|
||||
// Without this, each crash/restart cycle adds duplicate subscriptions.
|
||||
scopedBus.clear();
|
||||
|
||||
// Snapshot to avoid iterator invalidation from concurrent sendMessage() calls
|
||||
const snapshot = Array.from(activeSubscriptions);
|
||||
activeSubscriptions.clear();
|
||||
|
||||
@@ -1302,6 +1302,7 @@ export function pluginLoader(
|
||||
const plugin = (await registry.getById(pluginId)) as {
|
||||
id: string;
|
||||
packageName: string;
|
||||
packagePath: string | null;
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
} | null;
|
||||
if (!plugin) throw new Error(`Plugin not found: ${pluginId}`);
|
||||
@@ -1309,7 +1310,10 @@ export function pluginLoader(
|
||||
const oldManifest = plugin.manifestJson;
|
||||
const {
|
||||
packageName = plugin.packageName,
|
||||
localPath,
|
||||
// For local-path installs, fall back to the stored packagePath so
|
||||
// `upgradePlugin` can re-read the manifest from disk without needing
|
||||
// the caller to re-supply the path every time.
|
||||
localPath = plugin.packagePath ?? undefined,
|
||||
version,
|
||||
} = upgradeOptions;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user