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:
Dotta
2026-03-16 16:02:37 -05:00
63 changed files with 5060 additions and 1054 deletions

View File

@@ -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(() => {});
}
}

View File

@@ -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));

View File

@@ -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[] = [];

View File

@@ -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",

View File

@@ -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);

View File

@@ -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();

View File

@@ -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;