Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions server/src/__tests__/route-plugin-worker-wiring-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

/**
* Structural guard for a bug class that recurred while being fixed.
*
* A route that builds a heartbeat service without the plugin worker manager
* dispatches runs that can never acquire a plugin-backed sandbox lease. Every
* such run fails setup, and it surfaces as a sandbox provider error rather than
* as the missing dependency it is.
*
* Enumerating the affected routes by hand missed cases twice, because a route
* can dispatch a run either by calling `heartbeat.wakeup()` directly or by
* handing its heartbeat to a helper such as `queueIssueAssignmentWakeup`. So
* the rule here is uniform and does not try to distinguish dispatchers from
* readers: any route module that builds a heartbeat service must pass the
* manager through, and `app.ts` must supply it at the mount site.
*/

const routesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "routes");
const appFile = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "app.ts");

function routeFiles() {
return readdirSync(routesDir)
.filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts"))
.sort();
}

describe("route plugin worker manager wiring", () => {
it("no route builds a heartbeat service without the plugin worker manager", () => {
const offenders = routeFiles().filter((name) => {
const source = readFileSync(path.join(routesDir, name), "utf8");
// Matches `heartbeatService(db)` and `heartbeatService(db, {...})` so we
// can inspect what the call actually forwards.
const calls = source.match(/heartbeatService\(\s*db\s*(?:,([\s\S]*?))?\)/g) ?? [];
return calls.some((call) => !call.includes("pluginWorkerManager"));
});

expect(offenders).toEqual([]);
});

it("app.ts passes the plugin worker manager to every route that takes one", () => {
const app = readFileSync(appFile, "utf8");

const needsManager = routeFiles()
.filter((name) => readFileSync(path.join(routesDir, name), "utf8").includes("pluginWorkerManager"))
.map((name) => name.replace(/\.ts$/, ""));

const offenders = needsManager.filter((moduleName) => {
const importMatch = app.match(
new RegExp(`import\\s*\\{\\s*(\\w+)\\s*\\}\\s*from\\s*"\\./routes/${moduleName}\\.js"`),
);
if (!importMatch) return false; // not mounted by app.ts
const factory = importMatch[1];
// Take everything from the factory call to the end of the `api.use(...)`
// statement. Mounts vary in shape (extra args, multi-line option objects),
// so anchor on the closing `}));` or `));` rather than a fixed width.
const mount = app.match(new RegExp(`${factory}\\(\\s*db[\\s\\S]*?\\)\\s*\\);`));
// A mounted factory that accepts the manager must be handed one.
return !mount || !mount[0].includes("pluginWorkerManager");
});

expect(offenders).toEqual([]);
});
});
6 changes: 3 additions & 3 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export async function createApp(
api.use(companySkillPolicyRoutes(db));
api.use(inboxAgentPolicyRoutes(db));
api.use(builtInAgentRoutes(db, { pluginWorkerManager: workerManager }));
api.use(summarySlotRoutes(db));
api.use(summarySlotRoutes(db, { pluginWorkerManager: workerManager }));
api.use(teamsCatalogRoutes(db));
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
api.use(assetRoutes(db, opts.storageService));
Expand All @@ -281,7 +281,7 @@ export async function createApp(
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
?? null;
api.use(costRoutes(db, { pluginWorkerManager: workerManager }));
api.use(activityRoutes(db));
api.use(activityRoutes(db, { pluginWorkerManager: workerManager }));
api.use(dashboardRoutes(db));
api.use(attentionRoutes(db));
api.use(decisionTrainingRoutes(db));
Expand All @@ -290,7 +290,7 @@ export async function createApp(
api.use(sidebarPreferenceRoutes(db));
api.use(resourceMembershipRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
api.use(instanceSettingsRoutes(db, { pluginWorkerManager: workerManager }));
if (opts.databaseBackupService) {
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
}
Expand Down
10 changes: 8 additions & 2 deletions server/src/routes/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { activityService, normalizeActivityLimit } from "../services/activity.js
import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { accessService, heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";

const createActivitySchema = z.object({
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
Expand All @@ -18,11 +19,16 @@ const createActivitySchema = z.object({
details: z.record(z.unknown()).optional().nullable(),
});

export function activityRoutes(db: Db) {
export function activityRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
const svc = activityService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const issueSvc = issueService(db);

async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
Expand Down
10 changes: 8 additions & 2 deletions server/src/routes/instance-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { heartbeatService, instanceSettingsService, logActivity } from "../servi
import { environmentService } from "../services/environments.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";

function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
Expand All @@ -24,11 +25,16 @@ function assertCanManageInstanceSettings(req: Request) {
throw forbidden("Instance admin access required");
}

export function instanceSettingsRoutes(db: Db) {
export function instanceSettingsRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
const svc = instanceSettingsService(db);
const environments = environmentService(db);
const heartbeat = heartbeatService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});

router.get("/instance/settings", async (req, res) => {
assertCanManageInstanceSettings(req);
Expand Down
10 changes: 8 additions & 2 deletions server/src/routes/summary-slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { accessService, heartbeatService, instanceSettingsService, logActivity }
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import { summarySlotService } from "../services/summary-slots.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";

function readScopeId(req: Request): string | null {
const raw = req.query.scopeId;
Expand All @@ -23,12 +24,17 @@ export function summarySlotSessionTaskKey(input: {
return `summary-slot:${input.companyId}:${input.scopeKind}:${input.scopeId ?? "company"}:${input.slotKey}`;
}

export function summarySlotRoutes(db: Db) {
export function summarySlotRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const router = Router();
const access = accessService(db);
const settings = instanceSettingsService(db);
const svc = summarySlotService(db);
const heartbeat = heartbeatService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});

async function assertSummariesEnabled() {
const experimental = await settings.getExperimental();
Expand Down
Loading