Gate external objects behind experimental flag

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:05 +00:00
1 parent 71d08a6e05
commit f0c5c1913a
18 files changed
+242 -59

No files matched your search

@@ -27,12 +27,19 @@ const mockExternalObjectsService = vi.hoisted(() => ({
listForIssue: vi.fn(),
refreshIssueObjects: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
function registerRouteMocks() {
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectsService,
}));
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -47,7 +54,6 @@ function registerRouteMocks() {
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueReferenceService: () => ({
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
@@ -153,6 +159,9 @@ describe("external object routes", () => {
mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([
{ object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" },
]);
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableExternalObjects: true,
});
});
it("enforces company access on read routes", async () => {
@@ -415,6 +415,28 @@ describeEmbeddedPostgres("externalObjectService", () => {
});
});
it("no-ops detection and summaries when external objects are disabled", async () => {
const { issueId } = await createIssue();
const svc = externalObjectService(db, { enabled: false });
await svc.syncIssue(issueId);
const [objectRows, mentionRows, summary] = await Promise.all([
db.select().from(externalObjects),
db.select().from(externalObjectMentions),
svc.getIssueSummary(issueId),
]);
expect(objectRows).toHaveLength(0);
expect(mentionRows).toHaveLength(0);
expect(summary).toMatchObject({
total: 0,
byStatusCategory: {},
byLiveness: {},
highestSeverity: "neutral",
objects: [],
});
});
it("preserves last-known status when resolver reports auth and unreachable failures", async () => {
const { companyId, issueId } = await createIssue();
const resolver: ExternalObjectResolver = {
@@ -100,6 +100,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -142,6 +143,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
enableCloudSync: true,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -194,6 +196,7 @@ describe("instance settings routes", () => {
enableExperimentalFileViewer: false,
enableTaskWatchdogs: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
@@ -271,6 +274,24 @@ describe("instance settings routes", () => {
).toBe(true);
});
it("allows local board users to update external object detection", async () => {
const app = await createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableExternalObjects: true })
.expect(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableExternalObjects: true,
});
});
it("allows local board users to update issue graph liveness auto-recovery", async () => {
const app = await createApp({
type: "board",
+1
View File
@@ -1113,6 +1113,7 @@ export function issueRoutes(
}) ?? noopTaskWatchdogService();
const externalObjectsSvc = externalObjectService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
+5 -1
View File
@@ -16,6 +16,7 @@ import { validate } from "../middleware/validate.js";
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { conflict, forbidden } from "../errors.js";
import { externalObjectService } from "../services/external-objects.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
buildWorkspaceRuntimeDesiredStatePatch,
@@ -45,7 +46,10 @@ export function projectRoutes(db: Db) {
const access = accessService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const externalObjectsSvc = externalObjectService(db);
const instanceSettings = instanceSettingsService(db);
const externalObjectsSvc = externalObjectService(db, {
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const environmentsSvc = environmentService(db);
+19
View File
@@ -345,6 +345,7 @@ export function externalObjectService(
resolvers?: ExternalObjectResolver[];
pluginWorkerManager?: PluginWorkerManager;
github?: GitHubExternalObjectProviderOptions | false;
enabled?: boolean | (() => boolean | Promise<boolean>);
} = {},
) {
const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github);
@@ -361,6 +362,15 @@ export function externalObjectService(
...(githubProvider?.resolvers ?? []),
]);
async function isEnabled() {
if (typeof opts.enabled === "function") return await opts.enabled();
return opts.enabled ?? true;
}
function emptySummary() {
return summarizeObjectPayloads([]);
}
async function issueById(issueId: string, dbOrTx: any = db) {
return dbOrTx
.select({
@@ -461,6 +471,7 @@ export function externalObjectService(
}
async function syncIssue(issueId: string, dbOrTx: any = db) {
if (!(await isEnabled())) return;
const runSync = async (tx: any) => {
const issue = await issueById(issueId, tx);
if (!issue) throw notFound("Issue not found");
@@ -487,6 +498,7 @@ export function externalObjectService(
}
async function syncComment(commentId: string, dbOrTx: any = db) {
if (!(await isEnabled())) return;
const comment = await dbOrTx
.select({
id: issueComments.id,
@@ -510,6 +522,7 @@ export function externalObjectService(
}
async function syncDocument(documentId: string, dbOrTx: any = db) {
if (!(await isEnabled())) return;
const document = await dbOrTx
.select({
documentId: documents.id,
@@ -567,6 +580,7 @@ export function externalObjectService(
}
async function listForIssue(issueId: string) {
if (!(await isEnabled())) return [];
const issue = await issueById(issueId);
if (!issue) throw notFound("Issue not found");
const rows = await db
@@ -659,12 +673,14 @@ export function externalObjectService(
}
async function getIssueSummary(issueId: string) {
if (!(await isEnabled())) return emptySummary();
const groups = await listForIssue(issueId);
const objects = groups.flatMap((group) => (group.object ? [group.object] : []));
return summarizeObjectPayloads(objects);
}
async function getIssueSummaries(companyId: string, issueIds: string[]) {
if (!(await isEnabled())) return new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0);
const summaries = new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
if (uniqueIssueIds.length === 0) return summaries;
@@ -697,6 +713,7 @@ export function externalObjectService(
}
async function getProjectSummary(projectId: string) {
if (!(await isEnabled())) return emptySummary();
const projectIssues = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
@@ -840,6 +857,7 @@ export function externalObjectService(
objectIds?: string[];
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
}) {
if (!(await isEnabled())) return [];
const groups = await listForIssue(issueId);
const objectIds = groups
.flatMap((group) => (group.object ? [group.object.id] : []))
@@ -852,6 +870,7 @@ export function externalObjectService(
}
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
if (!(await isEnabled())) return [];
const due = await db
.select({ id: externalObjects.id })
.from(externalObjects)
+2
View File
@@ -52,6 +52,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
enableCloudSync: parsed.data.enableCloudSync ?? false,
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
issueGraphLivenessAutoRecoveryLookbackHours:
@@ -68,6 +69,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableCloudSync: false,
enableExternalObjects: false,
autoRestartDevServerWhenIdle: false,
enableIssueGraphLivenessAutoRecovery: false,
issueGraphLivenessAutoRecoveryLookbackHours: