mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Gate external objects behind experimental flag
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user