External object references across issue surfaces (#8512)

## Thinking Path

> - Paperclip is the open source control plane people use to coordinate
AI agents, issues, approvals, comments, and work products.
> - The involved subsystem is issue context: markdown links, issue
properties, related work, lists, filters, inbox/sidebar status, and
plugin-provided external context.
> - The gap is that URLs to external systems currently remain mostly
plain links, so humans and agents must manually open them to understand
status, identity, and liveness.
> - This matters because external work objects such as GitHub issues and
pull requests are part of the operational state of a Paperclip company.
> - The implementation keeps core provider-neutral: shared contracts,
storage, sync, routes, and UI surfaces live in core while providers can
contribute detection and status resolution.
> - This pull request adds the external object reference foundation,
GitHub provider support, issue-surface rendering, filters,
sidebar/list/inbox signals, and test/story coverage.
> - The benefit is that linked external work becomes inspectable
Paperclip context without hardcoding every provider directly into the
UI.

## Linked Issues or Issue Description

No public GitHub issue exists for this work.

Feature request:

- Problem: URLs in Paperclip issues, comments, documents, and related
surfaces do not expose provider status or object identity inline.
- Proposed behavior: detect supported external object URLs, persist
normalized references, refresh provider status, and render concise
status-aware links across issue surfaces.
- Users affected: board users, agents, and maintainers who triage issues
containing external work links.
- Acceptance: external object references are company-scoped,
provider-extensible, visible in key issue surfaces, filterable where
relevant, and covered by focused shared/server/UI tests.

Related PR search:

- No open duplicate PRs found for `external object references`.
- Closed related prior attempt: #4556.

## What Changed

- Added shared external-object contracts, validators, status/liveness
helpers, and plugin protocol declarations.
- Added database schema and additive migrations for external objects,
source mentions, and display metadata.
- Added server services/routes for detecting, syncing, summarizing,
refreshing, and resolving external objects across issues, documents,
comments, projects, and plugins.
- Added a GitHub external-object provider plus plugin SDK authoring
docs.
- Wired UI presentation across markdown links, comments, issue chat,
documents, properties, related work, issue rows, filters, inbox/sidebar
badges, and Storybook stories.
- Rebasing cleanup: moved the branch onto current `master`, repaired
stale worktree provision config, hardened environment-sensitive
tests/mocks, and removed committed screenshot artifacts from the PR
branch to keep the reviewable file set below tool limits.

## Verification

- `pnpm exec vitest run packages/shared/src/external-objects.test.ts
server/src/__tests__/external-object-routes.test.ts
server/src/__tests__/external-objects-service.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/lib/external-objects.test.ts` passed after rebasing: 5 files, 56
tests.
- Historical branch verification before this PR creation included `pnpm
test:run`, `pnpm -r typecheck`, and `pnpm build`; this PR body does not
claim those were rerun after the final rebase.

## Risks

- Medium: this adds a new cross-surface sync path on
issue/document/comment writes. The implementation uses safe sync
wrappers so external-object failures warn instead of blocking core
mutations.
- Medium: the migrations introduce new tables and indexes. They are
additive and company-scoped.
- Medium: provider-specific URL parsing can miss or misclassify edge
cases. Shared canonicalization tests and provider tests cover current
GitHub shapes.
- Low: UI badge/filter behavior could add visual noise for object-heavy
issues; component tests and Storybook stories cover the intended
surfaces.

> Roadmap checked: `ROADMAP.md` references the plugin system as the
current extension path and does not list a duplicate core feature.
Related long-range docs discuss external references, work products,
preview URLs, and plugin extension points; this PR implements the scoped
external-object reference foundation.

## Model Used

OpenAI Codex, GPT-5 coding-agent runtime, with shell and GitHub CLI tool
use. Reasoning mode: medium. Exact deployed runtime model ID and context
window were not exposed in the environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-06-23 08:27:19 -05:00
1 parent cd38c150b0
commit 2dbaf4a7fa
97 files changed
+8217 -84

No files matched your search

@@ -180,5 +180,5 @@ describeLiveSsh("live SSH environment smoke", () => {
expect(result.stdout).toContain(config.remoteWorkspacePath);
expect(result.stdout).toContain("git");
expect(result.stdout).toContain("tar");
});
}, 30_000);
});
@@ -0,0 +1,252 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const issueId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "44444444-4444-4444-8444-444444444444";
const ownerRunId = "55555555-5555-4555-8555-555555555555";
const mockIssueService = vi.hoisted(() => ({
assertCheckoutOwner: vi.fn(),
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
hasPermission: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockExternalObjectsService = vi.hoisted(() => ({
getIssueSummary: vi.fn(),
getIssueSummaries: vi.fn(),
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/task-watchdog-scope.js", () => ({
TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog",
resolveTaskWatchdogMutationScope: vi.fn(async () => ({ kind: "none" })),
taskWatchdogScopeAllowsIssueMutation: vi.fn(async () => ({ kind: "none" })),
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companyService: () => ({
getById: vi.fn(async () => null),
}),
companySearchService: () => ({}),
documentAnnotationService: () => ({}),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
feedbackService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({}),
issueReferenceService: () => ({
listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }),
}),
issueService: () => mockIssueService,
issueThreadInteractionService: () => ({}),
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({}),
workProductService: () => ({}),
}));
}
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: issueId,
companyId,
status: "in_progress",
priority: "medium",
projectId: null,
goalId: null,
parentId: null,
assigneeAgentId: ownerAgentId,
assigneeUserId: null,
identifier: "PAP-2265",
title: "External object routes",
executionWorkspaceId: null,
...overrides,
};
}
async function createApp(actor: Express.Request["actor"]) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, { provider: "local_disk" } as any));
app.use(errorHandler);
return app;
}
function boardActor(): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
userName: null,
userEmail: null,
companyIds: [companyId],
memberships: [],
isInstanceAdmin: false,
source: "local_implicit",
};
}
function ownerActor(): Express.Request["actor"] {
return {
type: "agent",
agentId: ownerAgentId,
companyId,
keyId: "key-1",
runId: ownerRunId,
source: "agent_key",
};
}
function peerActor(): Express.Request["actor"] {
return {
type: "agent",
agentId: peerAgentId,
companyId,
keyId: "key-2",
runId: "66666666-6666-4666-8666-666666666666",
source: "agent_key",
};
}
describe("external object routes", () => {
beforeEach(() => {
vi.resetModules();
vi.doUnmock("../routes/issues.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/external-objects.js");
registerRouteMocks();
vi.resetAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockAccessService.hasPermission.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({
allowed: action === "issue:mutate",
explanation: "Denied by test mock",
}));
mockAgentService.list.mockResolvedValue([
{ id: ownerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
{ id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
]);
mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] });
mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([
[issueId, { total: 1, objects: [] }],
]));
mockExternalObjectsService.listForIssue.mockResolvedValue([]);
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 () => {
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
expect(res.status).toBe(403);
expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled();
});
it("allows board users to read issue external object summaries", async () => {
const app = await createApp(boardActor());
const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`);
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId);
});
it("allows board users to fetch company-scoped external object summaries in bulk", async () => {
const app = await createApp(boardActor());
const res = await request(app)
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
.send({ issueIds: [issueId] });
expect(res.status).toBe(200);
expect(res.body.summaries[issueId].total).toBe(1);
expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]);
});
it("enforces company access on bulk external object summaries", async () => {
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
const res = await request(app)
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
.send({ issueIds: [issueId] });
expect(res.status).toBe(403);
expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled();
});
it("requires active checkout ownership for agent manual refresh", async () => {
const app = await createApp(peerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/external-objects/refresh`)
.send({});
expect(res.status).toBe(409);
expect(res.body.error).toBe("Issue is checked out by another agent");
expect(mockExternalObjectsService.refreshIssueObjects).not.toHaveBeenCalled();
});
it("allows the checked-out agent to request manual refresh", async () => {
const app = await createApp(ownerActor());
const res = await request(app)
.post(`/api/issues/${issueId}/external-objects/refresh`)
.send({});
expect(res.status).toBe(200);
expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId);
expect(mockExternalObjectsService.refreshIssueObjects).toHaveBeenCalledWith(issueId, expect.objectContaining({
companyId,
actor: expect.objectContaining({ actorType: "agent", actorId: ownerAgentId }),
}));
});
});
@@ -0,0 +1,705 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
companies,
createDb,
activityLog,
externalObjectMentions,
externalObjects,
issueComments,
issues,
plugins,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
createExternalObjectDetectorRegistry,
createExternalObjectResolverRegistry,
externalObjectService,
type ExternalObjectResolver,
} from "../services/external-objects.js";
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres external object tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describe("external object registries", () => {
it("lets provider detectors claim urls before the generic fallback", async () => {
const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42");
if (!canonical) throw new Error("expected canonical url");
const registry = createExternalObjectDetectorRegistry([
{
key: "github",
detect: ({ urls }) =>
urls.map((url) => ({
canonical: url,
detectorKey: "github",
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#42",
confidence: "exact",
})),
},
]);
const detections = await registry.detect({
companyId: "company-1",
urls: [canonical],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections).toHaveLength(1);
expect(detections[0]).toMatchObject({
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#42",
});
});
it("falls back to generic url objects when no provider detector claims a url", async () => {
const canonical = canonicalizeExternalObjectUrl("https://example.com/path?token=secret#frag");
if (!canonical) throw new Error("expected canonical url");
const registry = createExternalObjectDetectorRegistry([]);
const detections = await registry.detect({
companyId: "company-1",
urls: [canonical],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections[0]).toMatchObject({
providerKey: "url",
objectType: "link",
externalId: canonical.canonicalIdentityHash,
displayTitle: "https://example.com/path",
});
});
it("matches resolvers by provider and optional object type", () => {
const fallbackResolver: ExternalObjectResolver = {
providerKey: "github",
resolve: async () => ({
ok: true,
snapshot: { statusCategory: "unknown", statusTone: "neutral" },
}),
};
const pullRequestResolver: ExternalObjectResolver = {
providerKey: "github",
objectType: "pull_request",
resolve: async () => ({
ok: true,
snapshot: { statusCategory: "open", statusTone: "info" },
}),
};
const registry = createExternalObjectResolverRegistry([pullRequestResolver, fallbackResolver]);
expect(registry.find({ providerKey: "github", objectType: "pull_request" })).toBe(pullRequestResolver);
expect(registry.find({ providerKey: "github", objectType: "issue" })).toBe(fallbackResolver);
expect(registry.find({ providerKey: "linear", objectType: "issue" })).toBeNull();
});
});
describe("GitHub external object provider", () => {
function githubObject(path: string, objectType: "pull_request" | "issue") {
const canonical = canonicalizeExternalObjectUrl(`https://github.com/acme/app/${path}`);
if (!canonical) throw new Error("expected canonical url");
return {
id: randomUUID(),
companyId: "company-1",
providerKey: "github",
objectType,
externalId: `acme/app#${path}`,
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
canonicalIdentityHash: canonical.canonicalIdentityHash,
displayTitle: "acme/app#42",
statusKey: null,
statusLabel: null,
statusCategory: "unknown",
statusTone: "neutral",
liveness: "unknown",
isTerminal: false,
data: {},
remoteVersion: null,
etag: null,
} as any;
}
function response(body: Record<string, unknown>, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json", etag: '"etag-1"', ...(init.headers ?? {}) },
...init,
});
}
it("detects GitHub pull request and issue URLs before the generic fallback", async () => {
const provider = createGitHubExternalObjectProvider({} as any, { tokenProvider: null });
const pr = canonicalizeExternalObjectUrl("https://github.com/Acme/App/pull/42?token=secret#discussion");
const issue = canonicalizeExternalObjectUrl("https://github.com/Acme/App/issues/7");
const other = canonicalizeExternalObjectUrl("https://example.com/Acme/App/pull/42");
if (!pr || !issue || !other) throw new Error("expected canonical urls");
const detections = await provider.detector.detect({
companyId: "company-1",
urls: [pr, issue, other],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
});
expect(detections).toEqual([
expect.objectContaining({
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#pull/42",
displayKey: "GitHub Pull Request",
iconKey: "github",
displayTitle: "Acme/App#42",
}),
expect.objectContaining({
providerKey: "github",
objectType: "issue",
externalId: "acme/app#issues/7",
displayKey: "GitHub Issue",
iconKey: "github",
displayTitle: "Acme/App#7",
}),
]);
expect(JSON.stringify(detections)).not.toContain("secret");
});
it.each([
[
"open",
{ state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false },
],
[
"draft",
{ state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false },
],
[
"closed",
{ state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
],
[
"merged",
{ state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true },
],
])("resolves a %s pull request snapshot", async (_name, body, expected) => {
const fetch = vi.fn(async () => response(body));
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/acme/app/pulls/42",
expect.objectContaining({
headers: expect.not.objectContaining({ authorization: expect.any(String) }),
}),
);
expect(result).toEqual({
ok: true,
snapshot: expect.objectContaining({
...expected,
displayKey: "GitHub Pull Request",
iconKey: "github",
displayTitle: expect.stringContaining(String(body.title)),
remoteVersion: "2026-04-24T01:02:03Z",
etag: '"etag-1"',
data: expect.objectContaining({
provider: "github",
owner: "acme",
repo: "app",
number: 42,
}),
}),
});
expect(JSON.stringify(result)).not.toContain("authorization");
});
it.each([
[
"open",
{ state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false },
],
[
"closed",
{ state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" },
{ statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true },
],
])("resolves a %s issue snapshot", async (_name, body, expected) => {
const fetch = vi.fn(async () => response(body));
const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null });
const resolver = provider.resolvers.find((entry) => entry.objectType === "issue")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("issues/42", "issue"),
});
expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/acme/app/issues/42", expect.any(Object));
expect(result).toEqual({
ok: true,
snapshot: expect.objectContaining({
...expected,
displayKey: "GitHub Issue",
iconKey: "github",
data: expect.objectContaining({
provider: "github",
owner: "acme",
repo: "app",
number: 42,
}),
}),
});
});
it("uses a configured token without storing it in the resolved snapshot", async () => {
const fetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer ghp_secret" }));
return response({ state: "open", draft: false, merged: false, title: "Private PR" });
});
const provider = createGitHubExternalObjectProvider({} as any, {
fetch,
tokenProvider: async () => "ghp_secret",
});
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(result.ok).toBe(true);
expect(JSON.stringify(result)).not.toContain("ghp_secret");
});
it.each([
[
"auth-required",
new Response("", { status: 401 }),
{ ok: false, liveness: "auth_required", errorCode: "github_auth_required" },
],
[
"rate-limit",
new Response("", {
status: 403,
headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 120) },
}),
{ ok: false, liveness: "unreachable", errorCode: "github_rate_limited" },
],
[
"not-found",
new Response("", { status: 404, headers: { etag: '"missing"' } }),
{ ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub Pull Request", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) },
],
])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => {
const provider = createGitHubExternalObjectProvider({} as any, {
fetch: async () => githubResponse,
tokenProvider: null,
});
const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!;
const result = await resolver.resolve({
companyId: "company-1",
object: githubObject("pull/42", "pull_request"),
});
expect(result).toEqual(expect.objectContaining(expected));
expect(JSON.stringify(result)).not.toContain("http");
});
});
describeEmbeddedPostgres("externalObjectService", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-external-objects-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(externalObjectMentions);
await db.delete(externalObjects);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(plugins);
await db.delete(companies);
vi.restoreAllMocks();
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createIssue(companyId = randomUUID()) {
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `E${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 12).toUpperCase()}`,
title: "External refs",
description: "Track https://github.com/acme/app/pull/42?token=secret#discussion twice https://github.com/acme/app/pull/42.",
status: "todo",
priority: "medium",
});
return { companyId, issueId };
}
it("syncs sanitized, deduped mentions without storing secret-bearing urls", async () => {
const { companyId, issueId } = await createIssue();
const svc = externalObjectService(db);
await svc.syncIssue(issueId);
const [objectRows, mentionRows] = await Promise.all([
db.select().from(externalObjects),
db.select().from(externalObjectMentions),
]);
expect(objectRows).toHaveLength(1);
expect(objectRows[0]).toMatchObject({
companyId,
providerKey: "github",
objectType: "pull_request",
externalId: "acme/app#pull/42",
sanitizedCanonicalUrl: "https://github.com/acme/app/pull/42",
liveness: "unknown",
statusCategory: "unknown",
});
expect(JSON.stringify(objectRows[0])).not.toContain("secret");
expect(mentionRows).toHaveLength(1);
expect(mentionRows[0]).toMatchObject({
companyId,
sourceIssueId: issueId,
sourceKind: "description",
sanitizedDisplayUrl: "https://github.com/acme/app/pull/42",
matchedTextRedacted: "https://github.com/acme/app/pull/42",
});
});
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 = {
providerKey: "url",
objectType: "link",
resolve: vi
.fn()
.mockResolvedValueOnce({
ok: true,
snapshot: {
statusCategory: "open",
statusTone: "info",
statusKey: "open",
statusLabel: "Open",
ttlSeconds: 1,
},
})
.mockResolvedValueOnce({
ok: false,
liveness: "auth_required",
errorCode: "auth_required",
errorMessage: "token=secret failed for https://github.com/acme/app/pull/42?token=secret",
retryAfterSeconds: 60,
})
.mockResolvedValueOnce({
ok: false,
liveness: "unreachable",
errorCode: "network",
errorMessage: "GET https://github.com/acme/app/pull/42 failed",
retryAfterSeconds: 60,
}),
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
await svc.refreshObject(object.id, { companyId, force: true });
await svc.refreshObject(object.id, { companyId, force: true });
const authFailure = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(authFailure.lastErrorMessage).toContain("token=[redacted]");
expect(authFailure.lastErrorMessage).not.toContain("secret");
await svc.refreshObject(object.id, { companyId, force: true });
const updated = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(updated.statusCategory).toBe("open");
expect(updated.statusLabel).toBe("Open");
expect(updated.liveness).toBe("unreachable");
expect(updated.lastErrorMessage).toContain("[redacted-url]");
expect(updated.lastErrorMessage).not.toContain("secret");
});
it("schedules newly detected objects for automatic refresh", async () => {
const { companyId, issueId } = await createIssue();
const resolve = vi.fn(async () => ({
ok: true as const,
snapshot: {
statusCategory: "open" as const,
statusTone: "info" as const,
statusKey: "open",
statusLabel: "Open",
ttlSeconds: 300,
},
}));
const resolver: ExternalObjectResolver = {
providerKey: "url",
objectType: "link",
resolve,
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(object.nextRefreshAt).toBeInstanceOf(Date);
const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
expect(refreshed).toHaveLength(1);
expect(resolve).toHaveBeenCalledTimes(1);
});
it("removes comment mentions when a synced comment is hard-deleted", async () => {
const { companyId, issueId } = await createIssue();
const commentId = randomUUID();
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorType: "user",
authorUserId: "local-board",
body: "See https://github.com/acme/app/issues/88",
});
const svc = externalObjectService(db, { github: false });
await svc.syncComment(commentId);
expect(await db.select().from(externalObjectMentions)).toHaveLength(1);
await db.delete(issueComments).where(eq(issueComments.id, commentId));
await svc.syncComment(commentId);
expect(await db.select().from(externalObjectMentions)).toHaveLength(0);
});
it("skips terminal objects when refreshing due objects", async () => {
const { companyId, issueId } = await createIssue();
const resolve = vi.fn(async () => ({
ok: true as const,
snapshot: {
statusCategory: "closed" as const,
statusTone: "muted" as const,
statusKey: "closed",
statusLabel: "Closed",
isTerminal: true,
ttlSeconds: 1,
},
}));
const resolver: ExternalObjectResolver = {
providerKey: "url",
objectType: "link",
resolve,
};
const svc = externalObjectService(db, { resolvers: [resolver], github: false });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
await svc.refreshObject(object.id, { companyId, force: true });
await db
.update(externalObjects)
.set({ nextRefreshAt: new Date(0) })
.where(eq(externalObjects.id, object.id));
const refreshed = await svc.refreshDueObjects(companyId);
expect(refreshed).toEqual([]);
expect(resolve).toHaveBeenCalledTimes(1);
});
it("keeps external object identities company-scoped for duplicate urls", async () => {
const first = await createIssue();
const second = await createIssue();
const svc = externalObjectService(db);
await svc.syncIssue(first.issueId);
await svc.syncIssue(second.issueId);
const objectRows = await db.select().from(externalObjects);
expect(objectRows).toHaveLength(2);
expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId]));
expect(new Set(objectRows.map((row) => row.canonicalIdentityHash)).size).toBe(1);
});
it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => {
const { companyId, issueId } = await createIssue();
await db
.update(issues)
.set({
description: "Track https://mock.example/tickets/123?secret=drop",
})
.where(eq(issues.id, issueId));
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.mock-object-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "Mock Object Provider",
description: "Detects mock tracker tickets",
author: "Paperclip",
categories: ["connector"],
capabilities: ["external.objects.detect", "external.objects.read"],
entrypoints: { worker: "dist/worker.js" },
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
},
],
};
const [plugin] = await db.insert(plugins).values({
pluginKey: manifest.id,
packageName: "@paperclip/mock-object-provider",
version: manifest.version,
apiVersion: 1,
categories: manifest.categories,
manifestJson: manifest,
status: "ready",
installOrder: 1,
}).returning();
const workerManager = {
call: vi.fn(async (pluginId: string, method: string, params: any) => {
expect(pluginId).toBe(plugin!.id);
if (method === "detectExternalObjects") {
return {
detections: params.urls.map((url: any) => ({
urlIdentityHash: url.canonicalIdentityHash,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: "Mock ticket 123",
confidence: "exact",
})),
};
}
if (method === "resolveExternalObject") {
return {
ok: true,
snapshot: {
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: `Resolved ${params.externalId}`,
statusKey: "ready",
statusLabel: "Ready",
statusIconKey: "check-circle",
statusCategory: "succeeded",
statusTone: "success",
ttlSeconds: 300,
},
};
}
throw new Error(`unexpected method ${method}`);
}),
} as unknown as PluginWorkerManager;
const svc = externalObjectService(db, { pluginWorkerManager: workerManager });
await svc.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(object).toMatchObject({
companyId,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayKey: "Mock Ticket",
iconKey: "circle-dot",
pluginId: plugin!.id,
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
});
expect(JSON.stringify(object)).not.toContain("secret");
const refreshed = await svc.refreshObject(object.id, { companyId, force: true });
expect(refreshed.object).toMatchObject({
displayKey: "Mock Ticket",
iconKey: "circle-dot",
displayTitle: "Resolved MOCK-123",
statusIconKey: "check-circle",
statusCategory: "succeeded",
statusTone: "success",
liveness: "fresh",
});
});
});
+14 -2
View File
@@ -22,13 +22,25 @@ import {
issues,
} from "@paperclipai/db";
import { feedbackService } from "../services/feedback.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres feedback service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function closeDbClient(db: ReturnType<typeof createDb> | undefined) {
await db?.$client?.end?.({ timeout: 0 });
}
describe("feedbackService.saveIssueVote", () => {
describeEmbeddedPostgres("feedbackService.saveIssueVote", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof feedbackService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -15,7 +15,19 @@ import {
import { runningProcesses } from "../adapters/index.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat comment wake batching tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function waitFor(condition: () => boolean | Promise<boolean>, timeoutMs = 10_000, intervalMs = 50) {
const startedAt = Date.now();
@@ -147,7 +159,7 @@ async function createControlledGatewayServer() {
};
}
describe("heartbeat comment wake batching", () => {
describeEmbeddedPostgres("heartbeat comment wake batching", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
@@ -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",
@@ -19,6 +19,7 @@ describe("instance settings service", () => {
enableIsolatedWorkspaces: true,
enableStreamlinedLeftNavigation: true,
enableConferenceRoomChat: false,
enableExternalObjects: false,
enableIssuePlanDecompositions: true,
enableExperimentalFileViewer: true,
enableTaskWatchdogs: true,
@@ -104,6 +104,34 @@ const mockHeartbeatService = vi.hoisted(() => ({
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => new Map()),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
getProjectSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -136,6 +164,10 @@ function registerRouteMocks() {
workProductService: () => mockWorkProductService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/activity-log.js", () => ({
logActivity: vi.fn(async () => undefined),
}));
@@ -329,6 +361,7 @@ describe("agent issue mutation checkout ownership", () => {
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/documents.js");
vi.doUnmock("../services/external-objects.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../services/issues.js");
vi.doUnmock("../services/work-products.js");
@@ -464,6 +497,14 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.findMentionedAgents.mockReset();
mockDocumentService.upsertIssueDocument.mockReset();
mockWorkProductService.createForIssue.mockReset();
mockExternalObjectService.getIssueSummaries.mockClear();
mockExternalObjectService.getIssueSummary.mockClear();
mockExternalObjectService.getProjectSummary.mockClear();
mockExternalObjectService.listForIssue.mockClear();
mockExternalObjectService.refreshIssueObjects.mockClear();
mockExternalObjectService.syncCommentSafely.mockClear();
mockExternalObjectService.syncDocumentSafely.mockClear();
mockExternalObjectService.syncIssueSafely.mockClear();
mockWorkProductService.getById.mockReset();
mockWorkProductService.remove.mockReset();
mockWorkProductService.update.mockReset();
@@ -57,6 +57,24 @@ const mockIssueReferenceService = vi.hoisted(() => ({
syncDocument: vi.fn(async () => undefined),
syncIssue: vi.fn(async () => undefined),
}));
const mockExternalObjectService = vi.hoisted(() => ({
getIssueSummaries: vi.fn(async () => ({ summaries: {} })),
getIssueSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
staleCount: 0,
total: 0,
unreachableCount: 0,
})),
listForIssue: vi.fn(async () => []),
refreshIssueObjects: vi.fn(async () => []),
syncCommentSafely: vi.fn(async () => undefined),
syncDocumentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -92,6 +110,10 @@ function registerModuleMocks() {
issueService: () => mockIssueService,
}));
vi.doMock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
vi.doMock("../services/index.js", () => ({
companyService: () => ({
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
@@ -181,6 +203,7 @@ describe.sequential("issue comment cancel routes", () => {
vi.doUnmock("../telemetry.js");
vi.doUnmock("../services/access.js");
vi.doUnmock("../services/activity-log.js");
vi.doUnmock("../services/external-objects.js");
vi.doUnmock("../services/feedback.js");
vi.doUnmock("../services/heartbeat.js");
vi.doUnmock("../services/index.js");
@@ -240,6 +263,7 @@ describe.sequential("issue comment cancel routes", () => {
});
mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined);
mockIssueReferenceService.syncComment.mockResolvedValue(undefined);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
});
it("cancels a queued comment from its author and restores the deleted body", async () => {
@@ -352,6 +376,7 @@ describe.sequential("issue comment cancel routes", () => {
expect.objectContaining({ afterTombstone: expect.any(Function) }),
);
expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("comment-1", "tx");
expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"comment-1",
@@ -362,6 +387,7 @@ describe.sequential("issue comment cancel routes", () => {
"tx",
);
expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx");
expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx");
const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1];
expect(deletedActivity).toEqual(expect.objectContaining({
action: "issue.comment_deleted",
@@ -79,6 +79,10 @@ const mockIssueRecoveryActionService = vi.hoisted(() => ({
const mockIssueTreeControlService = vi.hoisted(() => ({
getActivePauseHoldGate: vi.fn(async () => null),
}));
const mockExternalObjectService = vi.hoisted(() => ({
syncCommentSafely: vi.fn(async () => undefined),
syncIssueSafely: vi.fn(async () => undefined),
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@@ -158,6 +162,10 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
vi.mock("../services/external-objects.js", () => ({
externalObjectService: () => mockExternalObjectService,
}));
function createApp() {
const app = express();
app.use(express.json());
@@ -253,6 +261,8 @@ describe.sequential("issue comment reopen routes", () => {
mockRoutineService.syncRunStatusForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockExternalObjectService.syncCommentSafely.mockReset();
mockExternalObjectService.syncIssueSafely.mockReset();
mockTxInsertValues.mockReset();
mockTxInsert.mockReset();
mockDbSelect.mockReset();
@@ -276,6 +286,8 @@ describe.sequential("issue comment reopen routes", () => {
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined);
mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
@@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => {
});
});
const objectReferenceManifest: PaperclipPluginManifestV1 = {
id: "test.external-object-provider",
apiVersion: 1,
version: "1.0.0",
displayName: "External Object Provider",
description: "Test external object provider plugin",
author: "Paperclip",
categories: ["connector"],
capabilities: ["external.objects.detect", "external.objects.read"],
entrypoints: { worker: "dist/worker.js" },
objectReferences: [
{
providerKey: "mocktracker",
displayName: "Mock Tracker",
objectTypes: ["ticket"],
urlPatterns: ["https://mock.example/tickets/:id"],
refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 },
},
],
};
describe("plugin external object provider seam", () => {
it("validates provider manifest declarations and capabilities", () => {
expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true);
const missingCapability = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
});
expect(missingCapability.success).toBe(false);
expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read");
const duplicateProvider = pluginManifestV1Schema.safeParse({
...objectReferenceManifest,
objectReferences: [
objectReferenceManifest.objectReferences![0],
{ ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" },
],
});
expect(duplicateProvider.success).toBe(false);
expect(JSON.stringify(duplicateProvider.error?.issues)).toContain(
"Duplicate object reference provider keys",
);
});
it("enforces provider capability requirements", () => {
const validator = pluginCapabilityValidator();
expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([
"external.objects.detect",
]);
expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([
"external.objects.read",
]);
expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true);
const withoutCapability = {
...objectReferenceManifest,
capabilities: ["external.objects.detect"],
} satisfies PaperclipPluginManifestV1;
expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({
allowed: false,
missing: ["external.objects.read"],
});
});
it("dispatches provider detection and resolution worker hooks", async () => {
const plugin = definePlugin({
async setup() {},
async onDetectExternalObjects(params) {
return {
detections: params.urls.map((url) => ({
urlIdentityHash: url.canonicalIdentityHash,
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
displayTitle: "Mock ticket",
confidence: "exact",
})),
};
},
async onResolveExternalObject(params) {
return {
ok: true,
snapshot: {
displayTitle: `Resolved ${params.externalId}`,
statusKey: "ready",
statusLabel: "Ready",
statusCategory: "succeeded",
statusTone: "success",
isTerminal: true,
ttlSeconds: 600,
},
};
},
});
const stdin = new PassThrough();
const stdout = new PassThrough();
const host = startWorkerRpcHost({ plugin, stdin, stdout });
const responses: unknown[] = [];
stdout.on("data", (chunk) => {
const lines = String(chunk).split("\n").filter(Boolean);
for (const line of lines) {
responses.push(parseMessage(line));
}
});
stdin.write(serializeMessage(createRequest("initialize", {
manifest: objectReferenceManifest,
config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
apiVersion: 1,
}, 1)));
await waitForResponses(responses, 1);
const initializeResponse = responses[0];
expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(initializeResponse)) return;
expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects");
expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject");
stdin.write(serializeMessage(createRequest("detectExternalObjects", {
companyId: "company-1",
urls: [{
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
sanitizedDisplayUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" },
redactedMatchedText: "https://mock.example/tickets/123",
}],
sourceContext: {
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
},
}, 2)));
await waitForResponses(responses, 2);
const detectResponse = responses[1];
expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(detectResponse)) return;
expect(detectResponse.result.detections[0]).toMatchObject({
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
});
stdin.write(serializeMessage(createRequest("resolveExternalObject", {
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
object: {
id: "object-1",
companyId: "company-1",
providerKey: "mocktracker",
objectType: "ticket",
externalId: "MOCK-123",
sanitizedCanonicalUrl: "https://mock.example/tickets/123",
canonicalIdentityHash: "hash-123",
displayTitle: "Mock ticket",
statusKey: null,
statusLabel: null,
statusCategory: "unknown",
statusTone: "neutral",
liveness: "unknown",
isTerminal: false,
data: {},
remoteVersion: null,
etag: null,
},
}, 3)));
await waitForResponses(responses, 3);
const resolveResponse = responses[2];
expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true);
if (!isJsonRpcSuccessResponse(resolveResponse)) return;
expect(resolveResponse.result).toMatchObject({
ok: true,
snapshot: {
statusCategory: "succeeded",
statusTone: "success",
},
});
host.stop();
});
});
async function waitForResponses(responses: unknown[], count: number): Promise<void> {
const deadline = Date.now() + 1_000;
while (responses.length < count && Date.now() < deadline) {
@@ -1536,6 +1536,97 @@ describe("realizeExecutionWorkspace", () => {
}
});
it("regenerates stale worktree config that points at another host", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-config-"));
const baseRoot = path.join(tempRoot, "base");
const worktreeRoot = path.join(tempRoot, "worktree");
const fakeBin = path.join(tempRoot, "bin");
const fakePnpmPath = path.join(fakeBin, "pnpm");
const scriptPath = path.join(worktreeRoot, "provision-worktree.sh");
const paperclipDir = path.join(worktreeRoot, ".paperclip");
try {
await fs.mkdir(baseRoot, { recursive: true });
await fs.mkdir(paperclipDir, { recursive: true });
await fs.mkdir(fakeBin, { recursive: true });
await fs.copyFile(provisionWorktreeScriptPath, scriptPath);
await fs.chmod(scriptPath, 0o755);
await fs.writeFile(
path.join(paperclipDir, "config.json"),
JSON.stringify({
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/Users/example/.paperclip-worktrees/instances/stale/db",
},
logging: {
mode: "file",
logDir: "/Users/example/.paperclip-worktrees/instances/stale/logs",
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/Users/example/.paperclip-worktrees/instances/stale/data/storage",
},
},
secrets: {
provider: "local_encrypted",
localEncrypted: {
keyFilePath: "/Users/example/.paperclip-worktrees/instances/stale/secrets/master.key",
},
},
}),
"utf8",
);
await fs.writeFile(
path.join(paperclipDir, ".env"),
[
"PAPERCLIP_HOME=/Users/example/.paperclip-worktrees",
"PAPERCLIP_INSTANCE_ID=stale",
`PAPERCLIP_CONFIG=/Users/example/paperclip/${path.basename(worktreeRoot)}/.paperclip/config.json`,
"",
].join("\n"),
"utf8",
);
await fs.writeFile(
fakePnpmPath,
[
"#!/bin/sh",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then",
" exit 0",
"fi",
"if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then",
" mkdir -p \"$PWD/.paperclip\"",
" printf '%s\\n' '{\"database\":{\"embeddedPostgresDataDir\":\"'$PWD'/.paperclip/runtime/db\"}}' > \"$PWD/.paperclip/config.json\"",
" printf '%s\\n' \"PAPERCLIP_HOME=$PWD/.paperclip/runtime\" \"PAPERCLIP_INSTANCE_ID=healthy\" \"PAPERCLIP_CONFIG=$PWD/.paperclip/config.json\" > \"$PWD/.paperclip/.env\"",
" exit 0",
"fi",
"exit 0",
"",
].join("\n"),
"utf8",
);
await fs.chmod(fakePnpmPath, 0o755);
const result = await execFileAsync(scriptPath, [], {
cwd: worktreeRoot,
env: {
...process.env,
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot,
PAPERCLIP_WORKSPACE_CWD: worktreeRoot,
},
});
expect(result.stderr).toContain("Existing isolated Paperclip worktree config is stale for this host; regenerating.");
await expect(fs.readFile(path.join(paperclipDir, ".env"), "utf8")).resolves.toContain(
`PAPERCLIP_CONFIG=${worktreeRoot}/.paperclip/config.json`,
);
await expect(fs.readFile(path.join(paperclipDir, "config.json"), "utf8")).resolves.toContain(worktreeRoot);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-"));
const baseRoot = path.join(tempRoot, "base");
+87 -1
View File
@@ -148,11 +148,18 @@ import {
resolveCoreTrustPreset,
type TrustPresetResolution,
} from "../services/trust-preset-resolver.js";
import { externalObjectService } from "../services/external-objects.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
const refreshExternalObjectsSchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
const externalObjectSummariesSchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const promoteLowTrustOutputSchema = z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
@@ -1105,6 +1112,10 @@ export function issueRoutes(
? heartbeat.wakeup
: opts.taskWatchdogEnqueueWakeup ?? undefined,
}) ?? noopTaskWatchdogService();
const externalObjectsSvc = externalObjectService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
});
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
@@ -3730,6 +3741,69 @@ export function issueRoutes(
res.json(workProducts);
});
router.get("/issues/:id/external-objects", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const objects = await externalObjectsSvc.listForIssue(issue.id);
res.json(objects);
});
router.get("/issues/:id/external-object-summary", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await externalObjectsSvc.getIssueSummary(issue.id);
res.json(summary);
});
router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds);
res.json({ summaries: Object.fromEntries(summaries) });
});
router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
const actor = getActorInfo(req);
const results = await externalObjectsSvc.refreshIssueObjects(issue.id, {
companyId: issue.companyId,
objectIds: req.body.objectIds,
actor,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "external_object.refresh_requested",
entityType: "issue",
entityId: issue.id,
details: {
issueId: issue.id,
objectIds: results.map((result) => result.object.id),
},
});
res.json({ refreshed: results });
});
router.get("/issues/:id/documents", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
@@ -4016,6 +4090,7 @@ export function issueRoutes(
const redirectedFromLockedDocument =
"redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null;
await issueReferencesSvc.syncDocument(doc.id);
await externalObjectsSvc.syncDocumentSafely(doc.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = result.created
@@ -4245,6 +4320,7 @@ export function issueRoutes(
});
await issueReferencesSvc.syncDocument(result.document.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
await externalObjectsSvc.syncDocumentSafely(result.document.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({
issueId: issue.id,
@@ -4358,6 +4434,7 @@ export function issueRoutes(
}
await issueReferencesSvc.deleteDocumentSource(removed.id);
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
if (removed) await externalObjectsSvc.syncDocumentSafely(removed.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -5025,6 +5102,7 @@ export function issueRoutes(
watchdogActorRunId: actor.runId,
});
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(issue.id);
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
issueReferencesSvc.emptySummary(),
@@ -5190,6 +5268,7 @@ export function issueRoutes(
actorUserId: actor.actorType === "user" ? actor.actorId : null,
watchdogActorRunId: actor.runId,
});
await externalObjectsSvc.syncIssueSafely(issue.id);
await logActivity(db, {
companyId: parent.companyId,
@@ -5958,6 +6037,7 @@ export function issueRoutes(
if (titleOrDescriptionChanged) {
await issueReferencesSvc.syncIssue(issue.id);
await externalObjectsSvc.syncIssueSafely(issue.id);
}
const updateReferenceSummaryAfter = titleOrDescriptionChanged
? await issueReferencesSvc.listIssueReferenceSummary(issue.id)
@@ -6263,6 +6343,7 @@ export function issueRoutes(
sourceTrust: await sourceTrustForActorWrite(issue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
commentReferenceSummaryBefore,
@@ -7256,6 +7337,7 @@ export function issueRoutes(
{
afterTombstone: async (deletedComment, tx) => {
await issueReferencesSvc.syncComment(deletedComment.id, tx);
await externalObjectsSvc.syncCommentSafely(deletedComment.id, tx);
annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, {
actorType: actor.actorType,
actorId: actor.actorId,
@@ -7265,7 +7347,10 @@ export function issueRoutes(
}, tx);
await Promise.all(
annotationCleanup.deletedCommentIds.map((annotationCommentId) =>
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx)
Promise.all([
issueReferencesSvc.deleteCommentSource(annotationCommentId, tx),
externalObjectsSvc.syncCommentSafely(annotationCommentId, tx),
])
),
);
},
@@ -7705,6 +7790,7 @@ export function issueRoutes(
}
await issueReferencesSvc.syncComment(comment.id);
await externalObjectsSvc.syncCommentSafely(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
commentReferenceSummaryBefore,
+59
View File
@@ -447,6 +447,14 @@ const jsonBody = (schema: z.ZodTypeAny) => ({
const r = responses;
const externalObjectSummariesBodySchema = z.object({
issueIds: z.array(z.string().uuid()).max(1000),
}).strict();
const refreshExternalObjectsBodySchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
function paramsSchemaFromPath(routePath: string): z.ZodObject<z.ZodRawShape> | undefined {
const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]);
if (names.length === 0) return undefined;
@@ -3847,6 +3855,57 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/external-objects",
tags: ["issues"],
summary: "List external objects mentioned by an issue",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/issues/{id}/external-object-summary",
tags: ["issues"],
summary: "Get external object status summary for an issue",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/issues/external-object-summaries",
tags: ["issues"],
summary: "Get external object status summaries for issues",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(externalObjectSummariesBodySchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "post",
path: "/api/issues/{id}/external-objects/refresh",
tags: ["issues"],
summary: "Refresh external objects mentioned by an issue",
request: {
params: z.object({ id: z.string() }),
body: jsonBody(refreshExternalObjectsBodySchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "get",
path: "/api/projects/{id}/external-object-summary",
tags: ["projects"],
summary: "Get external object status summary for a project",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
// ─── Org chart images ─────────────────────────────────────────────────────────
registry.registerPath({
+18
View File
@@ -15,6 +15,8 @@ import { trackProjectCreated } from "@paperclipai/shared/telemetry";
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,
@@ -44,6 +46,10 @@ export function projectRoutes(db: Db) {
const access = accessService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(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);
@@ -139,6 +145,18 @@ export function projectRoutes(db: Db) {
res.json(project);
});
router.get("/projects/:id/external-object-summary", async (req, res) => {
const id = req.params.id as string;
const project = await svc.getById(id);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, project.companyId);
const summary = await externalObjectsSvc.getProjectSummary(project.id);
res.json(summary);
});
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+945
View File
@@ -0,0 +1,945 @@
import { and, asc, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
import {
formatExternalObjectMentionSourceLabel,
type ExternalObjectCanonicalUrl,
type ExternalObjectLivenessState,
type ExternalObjectMentionConfidence,
type ExternalObjectMentionSourceKind,
type ExternalObjectStatusCategory,
type ExternalObjectStatusTone,
type PaperclipPluginManifestV1,
} from "@paperclipai/shared";
import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server";
import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk";
import { notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
import { logActivity, type LogActivityInput } from "./activity-log.js";
import { createGitHubExternalObjectProvider, type GitHubExternalObjectProviderOptions } from "./github-external-object-provider.js";
import { publishLiveEvent } from "./live-events.js";
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
export interface ExternalObjectSourceContext {
companyId: string;
sourceIssueId: string;
sourceKind: ExternalObjectMentionSourceKind;
sourceRecordId: string | null;
documentKey: string | null;
propertyKey: string | null;
}
export interface ExternalObjectDetection {
canonical: ExternalObjectCanonicalUrl;
detectorKey: string;
providerKey: string;
objectType: string;
externalId: string;
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
confidence?: ExternalObjectMentionConfidence;
pluginId?: string | null;
}
export interface ExternalObjectDetector {
key: string;
detect(input: {
companyId: string;
urls: ExternalObjectCanonicalUrl[];
sourceContext: ExternalObjectSourceContext;
}): Promise<ExternalObjectDetection[]> | ExternalObjectDetection[];
}
export interface ExternalObjectResolverSnapshot {
displayKey?: string | null;
iconKey?: string | null;
displayTitle?: string | null;
statusKey?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
statusTone: ExternalObjectStatusTone;
isTerminal?: boolean;
data?: Record<string, unknown>;
remoteVersion?: string | null;
etag?: string | null;
ttlSeconds?: number;
}
export type ExternalObjectResolveResult =
| { ok: true; snapshot: ExternalObjectResolverSnapshot }
| {
ok: false;
liveness: Extract<ExternalObjectLivenessState, "auth_required" | "unreachable">;
errorCode: string;
errorMessage?: string | null;
retryAfterSeconds?: number;
};
export interface ExternalObjectResolver {
providerKey: string;
objectType?: string;
resolve(input: {
companyId: string;
object: ExternalObjectRecord;
}): Promise<ExternalObjectResolveResult>;
}
type ExternalObjectRecord = typeof externalObjects.$inferSelect;
type ExternalObjectMentionRecord = typeof externalObjectMentions.$inferSelect;
const DEFAULT_REFRESH_TTL_SECONDS = 300;
const DEFAULT_RETRY_AFTER_SECONDS = 300;
function sourceWhere(input: ExternalObjectSourceContext) {
const conditions = [
eq(externalObjectMentions.companyId, input.companyId),
eq(externalObjectMentions.sourceIssueId, input.sourceIssueId),
eq(externalObjectMentions.sourceKind, input.sourceKind),
];
if (input.sourceRecordId) {
conditions.push(eq(externalObjectMentions.sourceRecordId, input.sourceRecordId));
} else {
conditions.push(isNull(externalObjectMentions.sourceRecordId));
}
if (input.documentKey) {
conditions.push(eq(externalObjectMentions.documentKey, input.documentKey));
} else {
conditions.push(isNull(externalObjectMentions.documentKey));
}
if (input.propertyKey) {
conditions.push(eq(externalObjectMentions.propertyKey, input.propertyKey));
} else {
conditions.push(isNull(externalObjectMentions.propertyKey));
}
return and(...conditions);
}
function addSeconds(date: Date, seconds: number) {
return new Date(date.getTime() + Math.max(1, seconds) * 1000);
}
function visibleLiveness(object: ExternalObjectRecord, now = new Date()): ExternalObjectLivenessState {
if (object.liveness === "fresh" && object.nextRefreshAt && object.nextRefreshAt <= now) {
return "stale";
}
return object.liveness;
}
function objectChanged(before: ExternalObjectRecord, after: ExternalObjectRecord) {
return (
before.statusKey !== after.statusKey ||
before.statusLabel !== after.statusLabel ||
before.statusIconKey !== after.statusIconKey ||
before.statusCategory !== after.statusCategory ||
before.statusTone !== after.statusTone ||
before.isTerminal !== after.isTerminal
);
}
function sanitizeErrorMessage(message: string | null | undefined) {
if (!message) return null;
return message
.replace(/https?:\/\/[^\s<>()]+/gi, "[redacted-url]")
.replace(/\b(token|key|secret|authorization|bearer)=\S+/gi, "$1=[redacted]");
}
function genericUrlDetector(): ExternalObjectDetector {
return {
key: "generic-url",
detect({ urls }) {
return urls.map((canonical) => ({
canonical,
detectorKey: "generic-url",
providerKey: "url",
objectType: "link",
externalId: canonical.canonicalIdentityHash,
displayTitle: canonical.sanitizedDisplayUrl,
confidence: "possible",
}));
},
};
}
export function createExternalObjectDetectorRegistry(detectors: ExternalObjectDetector[] = []) {
const entries = [...detectors, genericUrlDetector()];
async function detect(input: {
companyId: string;
urls: ExternalObjectCanonicalUrl[];
sourceContext: ExternalObjectSourceContext;
}) {
const claimed = new Set<string>();
const detections: ExternalObjectDetection[] = [];
for (const detector of entries) {
const remaining = input.urls.filter((url) => !claimed.has(url.canonicalIdentityHash));
if (remaining.length === 0) break;
try {
const detected = await detector.detect({ ...input, urls: remaining });
for (const detection of detected) {
if (claimed.has(detection.canonical.canonicalIdentityHash)) continue;
claimed.add(detection.canonical.canonicalIdentityHash);
detections.push({ ...detection, detectorKey: detection.detectorKey || detector.key });
}
} catch (err) {
logger.warn({ err, detectorKey: detector.key }, "external object detector failed");
}
}
return detections;
}
return { detect };
}
export function createExternalObjectResolverRegistry(resolvers: ExternalObjectResolver[] = []) {
function find(object: Pick<ExternalObjectRecord, "providerKey" | "objectType">) {
return resolvers.find(
(resolver) =>
resolver.providerKey === object.providerKey &&
(!resolver.objectType || resolver.objectType === object.objectType),
) ?? null;
}
return { find };
}
function manifestProvidesObject(
manifest: PaperclipPluginManifestV1,
object: Pick<ExternalObjectRecord, "providerKey" | "objectType">,
) {
return (manifest.objectReferences ?? []).some(
(provider) =>
provider.providerKey === object.providerKey &&
provider.objectTypes.includes(object.objectType),
);
}
function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot {
return {
id: object.id,
companyId: object.companyId,
providerKey: object.providerKey,
objectType: object.objectType,
externalId: object.externalId,
sanitizedCanonicalUrl: object.sanitizedCanonicalUrl,
canonicalIdentityHash: object.canonicalIdentityHash,
displayKey: object.displayKey,
iconKey: object.iconKey,
displayTitle: object.displayTitle,
statusKey: object.statusKey,
statusLabel: object.statusLabel,
statusIconKey: object.statusIconKey,
statusCategory: object.statusCategory,
statusTone: object.statusTone,
liveness: object.liveness,
isTerminal: object.isTerminal,
data: object.data as Record<string, unknown>,
remoteVersion: object.remoteVersion,
etag: object.etag,
};
}
async function readyObjectReferencePlugins(db: Db) {
return db
.select({
id: plugins.id,
pluginKey: plugins.pluginKey,
manifestJson: plugins.manifestJson,
})
.from(plugins)
.where(eq(plugins.status, "ready"))
.orderBy(asc(plugins.installOrder))
.then((rows) =>
rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0),
);
}
function createPluginProviderDetector(
db: Db,
pluginWorkerManager: PluginWorkerManager,
): ExternalObjectDetector {
return {
key: "plugin-object-reference-providers",
async detect(input) {
const providers = await readyObjectReferencePlugins(db);
const detections: ExternalObjectDetection[] = [];
for (const provider of providers) {
const manifest = provider.manifestJson;
if (!manifest.capabilities.includes("external.objects.detect")) continue;
try {
const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", {
companyId: input.companyId,
urls: input.urls.map((url) => ({
sanitizedCanonicalUrl: url.sanitizedCanonicalUrl,
sanitizedDisplayUrl: url.sanitizedDisplayUrl,
canonicalIdentityHash: url.canonicalIdentityHash,
canonicalIdentity: url.canonicalIdentity as unknown as Record<string, unknown>,
redactedMatchedText: url.redactedMatchedText,
})),
sourceContext: input.sourceContext,
});
const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url]));
const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey));
for (const detection of result.detections ?? []) {
const canonical = urlsByHash.get(detection.urlIdentityHash);
if (!canonical) continue;
if (!declaredProviderKeys.has(detection.providerKey)) continue;
const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey);
if (!declaration?.objectTypes.includes(detection.objectType)) continue;
detections.push({
canonical,
detectorKey: `${provider.pluginKey}:${detection.providerKey}`,
providerKey: detection.providerKey,
objectType: detection.objectType,
externalId: detection.externalId,
displayKey: detection.displayKey,
iconKey: detection.iconKey,
displayTitle: detection.displayTitle,
confidence: detection.confidence ?? "exact",
pluginId: provider.id,
});
}
} catch (err) {
logger.warn(
{ err, pluginId: provider.id, pluginKey: provider.pluginKey },
"plugin external object detector failed",
);
}
}
return detections;
},
};
}
async function resolveViaPluginProvider(
db: Db,
pluginWorkerManager: PluginWorkerManager | undefined,
object: ExternalObjectRecord,
): Promise<PluginExternalObjectResolveResult | null> {
if (!pluginWorkerManager) return null;
const providers = await readyObjectReferencePlugins(db);
for (const provider of providers) {
const manifest = provider.manifestJson;
if (!manifest.capabilities.includes("external.objects.read")) continue;
if (!manifestProvidesObject(manifest, object)) continue;
try {
return await pluginWorkerManager.call(provider.id, "resolveExternalObject", {
companyId: object.companyId,
providerKey: object.providerKey,
objectType: object.objectType,
externalId: object.externalId,
object: objectSnapshot(object),
});
} catch (err) {
logger.warn(
{ err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id },
"plugin external object resolver failed",
);
return {
ok: false,
liveness: "unreachable",
errorCode: "plugin_resolver_failed",
errorMessage: err instanceof Error ? err.message : String(err),
};
}
}
return null;
}
export function externalObjectService(
db: Db,
opts: {
detectors?: ExternalObjectDetector[];
resolvers?: ExternalObjectResolver[];
pluginWorkerManager?: PluginWorkerManager;
github?: GitHubExternalObjectProviderOptions | false;
enabled?: boolean | (() => boolean | Promise<boolean>);
} = {},
) {
const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github);
const pluginProviderDetector = opts.pluginWorkerManager
? createPluginProviderDetector(db, opts.pluginWorkerManager)
: null;
const detectorRegistry = createExternalObjectDetectorRegistry([
...(pluginProviderDetector ? [pluginProviderDetector] : []),
...(opts.detectors ?? []),
...(githubProvider ? [githubProvider.detector] : []),
]);
const resolverRegistry = createExternalObjectResolverRegistry([
...(opts.resolvers ?? []),
...(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({
id: issues.id,
companyId: issues.companyId,
title: issues.title,
description: issues.description,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ id: string; companyId: string; title: string; description: string | null }>) => rows[0] ?? null);
}
async function upsertObjectFromDetection(
companyId: string,
detection: ExternalObjectDetection,
dbOrTx: any,
): Promise<ExternalObjectRecord> {
const now = new Date();
const canonical = detection.canonical;
const values = {
companyId,
providerKey: detection.providerKey,
pluginId: detection.pluginId ?? null,
objectType: detection.objectType,
externalId: detection.externalId,
sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl,
canonicalIdentityHash: canonical.canonicalIdentityHash,
displayKey: detection.displayKey ?? null,
iconKey: detection.iconKey ?? null,
displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl,
nextRefreshAt: now,
updatedAt: now,
};
const inserted = await dbOrTx
.insert(externalObjects)
.values(values)
.onConflictDoUpdate({
target: [
externalObjects.companyId,
externalObjects.providerKey,
externalObjects.objectType,
externalObjects.externalId,
],
set: {
sanitizedCanonicalUrl: values.sanitizedCanonicalUrl,
canonicalIdentityHash: values.canonicalIdentityHash,
displayKey: values.displayKey,
iconKey: values.iconKey,
displayTitle: values.displayTitle,
nextRefreshAt: sql`coalesce(${externalObjects.nextRefreshAt}, now())`,
updatedAt: now,
},
})
.returning();
return inserted[0]!;
}
async function replaceSourceMentions(
input: ExternalObjectSourceContext & { text: string | null | undefined },
dbOrTx: any = db,
) {
const urls = extractExternalObjectCanonicalUrls(input.text ?? "");
await dbOrTx.delete(externalObjectMentions).where(sourceWhere(input));
if (urls.length === 0) return;
const detections = await detectorRegistry.detect({
companyId: input.companyId,
urls,
sourceContext: input,
});
if (detections.length === 0) return;
const seen = new Set<string>();
const values: Array<typeof externalObjectMentions.$inferInsert> = [];
for (const detection of detections) {
const canonicalHash = detection.canonical.canonicalIdentityHash;
const sourceKey = `${detection.providerKey}:${detection.objectType}:${canonicalHash}`;
if (seen.has(sourceKey)) continue;
seen.add(sourceKey);
const object = await upsertObjectFromDetection(input.companyId, detection, dbOrTx);
values.push({
companyId: input.companyId,
sourceIssueId: input.sourceIssueId,
sourceKind: input.sourceKind,
sourceRecordId: input.sourceRecordId,
documentKey: input.documentKey,
propertyKey: input.propertyKey,
matchedTextRedacted: detection.canonical.redactedMatchedText,
sanitizedDisplayUrl: detection.canonical.sanitizedDisplayUrl,
canonicalIdentityHash: canonicalHash,
canonicalIdentity: detection.canonical.canonicalIdentity as unknown as Record<string, unknown>,
objectId: object.id,
providerKey: detection.providerKey,
detectorKey: detection.detectorKey,
objectType: detection.objectType,
confidence: detection.confidence ?? "exact",
createdByPluginId: detection.pluginId ?? null,
});
}
if (values.length > 0) {
await dbOrTx.insert(externalObjectMentions).values(values);
}
}
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");
await replaceSourceMentions({
companyId: issue.companyId,
sourceIssueId: issue.id,
sourceKind: "title",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
text: issue.title,
}, tx);
await replaceSourceMentions({
companyId: issue.companyId,
sourceIssueId: issue.id,
sourceKind: "description",
sourceRecordId: null,
documentKey: null,
propertyKey: null,
text: issue.description,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function syncComment(commentId: string, dbOrTx: any = db) {
const runSync = async (tx: any) => {
if (!(await isEnabled())) return;
const comment = await tx
.select({
id: issueComments.id,
companyId: issueComments.companyId,
issueId: issueComments.issueId,
body: issueComments.body,
})
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null);
if (!comment) {
await tx
.delete(externalObjectMentions)
.where(and(eq(externalObjectMentions.sourceKind, "comment"), eq(externalObjectMentions.sourceRecordId, commentId)));
return;
}
await replaceSourceMentions({
companyId: comment.companyId,
sourceIssueId: comment.issueId,
sourceKind: "comment",
sourceRecordId: comment.id,
documentKey: null,
propertyKey: null,
text: comment.body,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function syncDocument(documentId: string, dbOrTx: any = db) {
const runSync = async (tx: any) => {
if (!(await isEnabled())) return;
const document = await tx
.select({
documentId: documents.id,
companyId: documents.companyId,
issueId: issueDocuments.issueId,
key: issueDocuments.key,
body: documents.latestBody,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(eq(documents.id, documentId))
.then((rows: Array<{ documentId: string; companyId: string; issueId: string; key: string; body: string }>) => rows[0] ?? null);
if (!document) {
await tx
.delete(externalObjectMentions)
.where(and(eq(externalObjectMentions.sourceKind, "document"), eq(externalObjectMentions.sourceRecordId, documentId)));
return;
}
await replaceSourceMentions({
companyId: document.companyId,
sourceIssueId: document.issueId,
sourceKind: "document",
sourceRecordId: document.documentId,
documentKey: document.key,
propertyKey: null,
text: document.body,
}, tx);
};
return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx);
}
async function safeSync(label: string, fn: () => Promise<void>) {
try {
await fn();
} catch (err) {
logger.warn({ err }, `external object ${label} sync failed`);
}
}
async function syncIssueSafely(issueId: string) {
await safeSync("issue", () => syncIssue(issueId));
}
async function syncCommentSafely(commentId: string, dbOrTx: any = db) {
await safeSync("comment", () => syncComment(commentId, dbOrTx));
}
async function syncDocumentSafely(documentId: string) {
await safeSync("document", () => syncDocument(documentId));
}
function toObjectPayload(object: ExternalObjectRecord, now = new Date()) {
return {
...object,
liveness: visibleLiveness(object, now),
};
}
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
.select({
mention: externalObjectMentions,
object: externalObjects,
})
.from(externalObjectMentions)
.leftJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, issue.companyId),
eq(externalObjectMentions.sourceIssueId, issue.id),
))
.orderBy(asc(externalObjectMentions.sourceKind), asc(externalObjectMentions.createdAt));
const now = new Date();
const grouped = new Map<string, {
object: ReturnType<typeof toObjectPayload> | null;
mentions: ExternalObjectMentionRecord[];
mentionCount: number;
sourceLabels: string[];
}>();
for (const row of rows) {
const key = row.object?.id ?? `mention:${row.mention.id}`;
const existing = grouped.get(key) ?? {
object: row.object ? toObjectPayload(row.object, now) : null,
mentions: [],
mentionCount: 0,
sourceLabels: [],
};
existing.mentions.push(row.mention);
existing.mentionCount += 1;
const label = formatExternalObjectMentionSourceLabel({
sourceKind: row.mention.sourceKind,
documentKey: row.mention.documentKey,
propertyKey: row.mention.propertyKey,
});
if (!existing.sourceLabels.includes(label)) existing.sourceLabels.push(label);
grouped.set(key, existing);
}
return [...grouped.values()];
}
function summarizeObjects(objects: Array<ReturnType<typeof toObjectPayload>>) {
const byStatusCategory: Record<string, number> = {};
const byLiveness: Record<string, number> = {};
let highestSeverity: ExternalObjectStatusTone = "neutral";
const severityRank: Record<ExternalObjectStatusTone, number> = {
neutral: 0,
muted: 0,
success: 1,
info: 2,
warning: 3,
danger: 4,
};
for (const object of objects) {
byStatusCategory[object.statusCategory] = (byStatusCategory[object.statusCategory] ?? 0) + 1;
byLiveness[object.liveness] = (byLiveness[object.liveness] ?? 0) + 1;
const livenessTone = object.liveness === "auth_required" || object.liveness === "unreachable"
? "danger"
: object.liveness === "stale"
? "warning"
: object.statusTone;
if (severityRank[livenessTone] > severityRank[highestSeverity]) highestSeverity = livenessTone;
}
return {
total: objects.length,
byStatusCategory,
byLiveness,
highestSeverity,
staleCount: byLiveness.stale ?? 0,
authRequiredCount: byLiveness.auth_required ?? 0,
unreachableCount: byLiveness.unreachable ?? 0,
};
}
function summarizeObjectPayloads(objects: Array<ReturnType<typeof toObjectPayload>>, objectLimit = objects.length) {
return {
...summarizeObjects(objects),
objects: objects.slice(0, objectLimit).map((object) => ({
id: object.id,
providerKey: object.providerKey,
objectType: object.objectType,
displayKey: object.displayKey,
iconKey: object.iconKey,
displayTitle: object.displayTitle,
statusIconKey: object.statusIconKey,
statusCategory: object.statusCategory,
statusTone: object.statusTone,
liveness: object.liveness,
isTerminal: object.isTerminal,
})),
};
}
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;
const rows = await db
.select({
issueId: externalObjectMentions.sourceIssueId,
object: externalObjects,
})
.from(externalObjectMentions)
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, companyId),
inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds),
));
const now = new Date();
const objectsByIssueId = new Map<string, Map<string, ReturnType<typeof toObjectPayload>>>();
for (const row of rows) {
const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map<string, ReturnType<typeof toObjectPayload>>();
issueObjects.set(row.object.id, toObjectPayload(row.object, now));
objectsByIssueId.set(row.issueId, issueObjects);
}
for (const [issueId, issueObjects] of objectsByIssueId) {
const objects = [...issueObjects.values()];
if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects));
}
return summaries;
}
async function getProjectSummary(projectId: string) {
if (!(await isEnabled())) return emptySummary();
const projectIssues = await db
.select({ id: issues.id, companyId: issues.companyId })
.from(issues)
.where(and(eq(issues.projectId, projectId), inArray(issues.status, ["todo", "in_progress", "in_review", "blocked"])));
if (projectIssues.length === 0) return { ...summarizeObjects([]), objects: [] };
const companyIds = new Set(projectIssues.map((issue) => issue.companyId));
if (companyIds.size !== 1) return { ...summarizeObjects([]), objects: [] };
const issueIds = projectIssues.map((issue) => issue.id);
const rows = await db
.select({ object: externalObjects })
.from(externalObjectMentions)
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
.where(and(
eq(externalObjectMentions.companyId, projectIssues[0]!.companyId),
inArray(externalObjectMentions.sourceIssueId, issueIds),
));
const now = new Date();
const objectsById = new Map<string, ReturnType<typeof toObjectPayload>>();
for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now));
const objects = [...objectsById.values()];
return summarizeObjectPayloads(objects, 25);
}
async function refreshObject(
objectId: string,
input: {
companyId: string;
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
force?: boolean;
now?: Date;
},
) {
const now = input.now ?? new Date();
const object = await db
.select()
.from(externalObjects)
.where(and(eq(externalObjects.id, objectId), eq(externalObjects.companyId, input.companyId)))
.then((rows) => rows[0] ?? null);
if (!object) throw notFound("External object not found");
if (!input.force && object.nextRefreshAt && object.nextRefreshAt > now) {
return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const };
}
const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object);
const resolver = pluginResult ? null : resolverRegistry.find(object);
if (!pluginResult && !resolver) {
const [updated] = await db
.update(externalObjects)
.set({
liveness: visibleLiveness(object, now) === "fresh" ? "stale" : object.liveness,
nextRefreshAt: addSeconds(now, DEFAULT_RETRY_AFTER_SECONDS),
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const };
}
const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object });
if (!result.ok) {
const [updated] = await db
.update(externalObjects)
.set({
liveness: result.liveness,
lastErrorAt: now,
lastErrorCode: result.errorCode,
lastErrorMessage: sanitizeErrorMessage(result.errorMessage),
nextRefreshAt: addSeconds(now, result.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS),
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
publishLiveEvent({
companyId: object.companyId,
type: "external_object.updated",
payload: { objectId: object.id, liveness: result.liveness },
});
return { object: toObjectPayload(updated ?? object, now), refreshed: true, reason: result.liveness };
}
const snapshot = result.snapshot;
const patch = {
displayKey: snapshot.displayKey ?? object.displayKey,
iconKey: snapshot.iconKey ?? object.iconKey,
displayTitle: snapshot.displayTitle ?? object.displayTitle,
statusKey: snapshot.statusKey ?? object.statusKey,
statusLabel: snapshot.statusLabel ?? object.statusLabel,
statusIconKey: snapshot.statusIconKey ?? object.statusIconKey,
statusCategory: snapshot.statusCategory,
statusTone: snapshot.statusTone,
isTerminal: snapshot.isTerminal ?? object.isTerminal,
data: snapshot.data ?? object.data,
remoteVersion: snapshot.remoteVersion ?? object.remoteVersion,
etag: snapshot.etag ?? object.etag,
liveness: "fresh" as ExternalObjectLivenessState,
lastResolvedAt: now,
lastErrorAt: null,
lastErrorCode: null,
lastErrorMessage: null,
nextRefreshAt: addSeconds(now, snapshot.ttlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS),
updatedAt: now,
};
const [updated] = await db
.update(externalObjects)
.set({
...patch,
lastChangedAt: objectChanged(object, { ...object, ...patch }) ? now : object.lastChangedAt,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.returning();
const next = updated ?? object;
if (objectChanged(object, next) && input.actor) {
await logActivity(db, {
companyId: object.companyId,
actorType: input.actor.actorType,
actorId: input.actor.actorId,
agentId: input.actor.agentId,
runId: input.actor.runId,
action: "external_object.status_changed",
entityType: "external_object",
entityId: object.id,
details: {
providerKey: object.providerKey,
objectType: object.objectType,
statusCategory: next.statusCategory,
statusLabel: next.statusLabel,
_previous: {
statusCategory: object.statusCategory,
statusLabel: object.statusLabel,
},
},
});
}
publishLiveEvent({
companyId: object.companyId,
type: "external_object.updated",
payload: { objectId: object.id, statusCategory: next.statusCategory, liveness: next.liveness },
});
return { object: toObjectPayload(next, now), refreshed: true, reason: "resolved" as const };
}
async function refreshIssueObjects(issueId: string, input: {
companyId: string;
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] : []))
.filter((id) => !input.objectIds || input.objectIds.includes(id));
const results = [];
for (const objectId of objectIds) {
results.push(await refreshObject(objectId, { companyId: input.companyId, actor: input.actor }));
}
return results;
}
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
if (!(await isEnabled())) return [];
const due = await db
.select({ id: externalObjects.id })
.from(externalObjects)
.where(
and(
eq(externalObjects.companyId, companyId),
eq(externalObjects.isTerminal, false),
lte(externalObjects.nextRefreshAt, now),
),
)
.limit(limit);
const results = [];
for (const row of due) {
results.push(await refreshObject(row.id, {
companyId,
actor: { actorType: "system", actorId: "external-object-resolver", agentId: null, runId: null },
now,
}));
}
return results;
}
return {
syncIssue,
syncComment,
syncDocument,
syncIssueSafely,
syncCommentSafely,
syncDocumentSafely,
listForIssue,
getIssueSummary,
getIssueSummaries,
getProjectSummary,
refreshObject,
refreshIssueObjects,
refreshDueObjects,
};
}
@@ -0,0 +1,445 @@
import type { Db } from "@paperclipai/db";
import type { ExternalObjectCanonicalUrl } from "@paperclipai/shared";
import { ghFetch, gitHubApiBase } from "./github-fetch.js";
import { secretService } from "./secrets.js";
import type {
ExternalObjectDetection,
ExternalObjectDetector,
ExternalObjectResolver,
ExternalObjectResolverSnapshot,
ExternalObjectResolveResult,
} from "./external-objects.js";
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
export interface GitHubExternalObjectProviderOptions {
fetch?: FetchLike;
tokenProvider?: (companyId: string) => Promise<string | null> | string | null;
secretNames?: readonly string[];
}
interface GitHubObjectIdentity {
host: string;
owner: string;
repo: string;
number: number;
objectType: "pull_request" | "issue";
pathKind: "pull" | "issues";
}
const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const;
const GITHUB_OBJECT_TTL_SECONDS = 300;
function isGitHubHost(host: string) {
const h = host.toLowerCase();
return h === "github.com" || h === "www.github.com";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : null;
}
function asBoolean(value: unknown) {
return typeof value === "boolean" ? value : null;
}
function asNestedString(record: Record<string, unknown>, key: string, nestedKey: string) {
const nested = asRecord(record[key]);
return nested ? asString(nested[nestedKey]) : null;
}
function parseGitHubCanonicalUrl(canonical: ExternalObjectCanonicalUrl): GitHubObjectIdentity | null {
if (canonical.canonicalIdentity.scheme !== "https") return null;
const host = canonical.canonicalIdentity.host.toLowerCase();
if (!isGitHubHost(host)) return null;
const parts = canonical.canonicalIdentity.path.split("/").filter(Boolean);
if (parts.length !== 4) return null;
const [owner, repo, kind, rawNumber] = parts;
if (!owner || !repo || !kind || !rawNumber) return null;
if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null;
if (!/^[1-9][0-9]*$/.test(rawNumber)) return null;
if (kind !== "pull" && kind !== "issues") return null;
return {
host: host === "www.github.com" ? "github.com" : host,
owner,
repo,
number: Number(rawNumber),
pathKind: kind,
objectType: kind === "pull" ? "pull_request" : "issue",
};
}
function parseGitHubObject(object: { externalId: string; sanitizedCanonicalUrl: string | null }): GitHubObjectIdentity | null {
const match = /^([^/]+)\/([^/]+)#(pull|issues)\/([1-9][0-9]*)$/.exec(object.externalId);
if (!match) return null;
let host = "github.com";
if (object.sanitizedCanonicalUrl) {
try {
const url = new URL(object.sanitizedCanonicalUrl);
if (isGitHubHost(url.hostname)) host = url.hostname === "www.github.com" ? "github.com" : url.hostname;
} catch {
return null;
}
}
return {
host,
owner: match[1]!,
repo: match[2]!,
pathKind: match[3] as "pull" | "issues",
number: Number(match[4]),
objectType: match[3] === "pull" ? "pull_request" : "issue",
};
}
function externalIdFor(identity: GitHubObjectIdentity) {
return `${identity.owner.toLowerCase()}/${identity.repo.toLowerCase()}#${identity.pathKind}/${identity.number}`;
}
function displayTitleFor(identity: GitHubObjectIdentity) {
return `${identity.owner}/${identity.repo}#${identity.number}`;
}
function displayKeyFor(identity: Pick<GitHubObjectIdentity, "objectType">) {
return identity.objectType === "pull_request" ? "GitHub Pull Request" : "GitHub Issue";
}
function retryAfterSeconds(response: Response) {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter);
const reset = response.headers.get("x-ratelimit-reset");
if (reset && /^[0-9]+$/.test(reset)) {
return Math.max(1, Number(reset) - Math.floor(Date.now() / 1000));
}
return 300;
}
function failureFromGitHubResponse(response: Response): ExternalObjectResolveResult | null {
if (response.status === 401) {
return {
ok: false,
liveness: "auth_required",
errorCode: "github_auth_required",
errorMessage: "GitHub authentication is required to refresh this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
if (response.status === 403) {
const rateLimitRemaining = response.headers.get("x-ratelimit-remaining");
if (rateLimitRemaining === "0") {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_rate_limited",
errorMessage: "GitHub rate limit reached while refreshing this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
return {
ok: false,
liveness: "auth_required",
errorCode: "github_forbidden",
errorMessage: "GitHub rejected the configured credentials for this object.",
retryAfterSeconds: retryAfterSeconds(response),
};
}
if (response.status === 429 || response.status >= 500) {
return {
ok: false,
liveness: "unreachable",
errorCode: response.status === 429 ? "github_rate_limited" : "github_unreachable",
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
retryAfterSeconds: retryAfterSeconds(response),
};
}
return null;
}
function notFoundSnapshot(identity: GitHubObjectIdentity, etag: string | null): ExternalObjectResolverSnapshot {
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: displayTitleFor(identity),
statusKey: "not_found",
statusLabel: "Not found",
statusIconKey: "archive",
statusCategory: "archived",
statusTone: "muted",
isTerminal: true,
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
notFound: true,
},
};
}
function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
const title = asString(body.title);
const state = asString(body.state) ?? "unknown";
const draft = asBoolean(body.draft) ?? false;
const merged = (asBoolean(body.merged) ?? false) || Boolean(asString(body.merged_at));
const authorLogin = asNestedString(body, "user", "login");
const headRef = asNestedString(body, "head", "ref");
const baseRef = asNestedString(body, "base", "ref");
const reviewDecision = asString(body.review_decision);
let statusKey = state;
let statusLabel = state === "open" ? "Open" : state === "closed" ? "Closed" : "Unknown";
let statusCategory: ExternalObjectResolverSnapshot["statusCategory"] = state === "open" ? "open" : "unknown";
let statusTone: ExternalObjectResolverSnapshot["statusTone"] = state === "open" ? "info" : "neutral";
let isTerminal = false;
if (merged) {
statusKey = "merged";
statusLabel = "Merged";
statusCategory = "succeeded";
statusTone = "success";
isTerminal = true;
} else if (state === "closed") {
statusKey = "closed";
statusLabel = "Closed";
statusCategory = "closed";
statusTone = "muted";
isTerminal = true;
} else if (draft) {
statusKey = "draft";
statusLabel = "Draft";
statusCategory = "waiting";
statusTone = "warning";
}
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
statusKey,
statusLabel,
statusIconKey: merged
? "git-merge"
: state === "closed"
? "x-circle"
: draft
? "clock"
: "git-pull-request",
statusCategory,
statusTone,
isTerminal,
remoteVersion: asString(body.updated_at),
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
state,
merged,
draft,
...(authorLogin ? { authorLogin } : {}),
...(headRef ? { headRef } : {}),
...(baseRef ? { baseRef } : {}),
...(reviewDecision ? { reviewDecision } : {}),
},
};
}
function issueSnapshot(identity: GitHubObjectIdentity, body: Record<string, unknown>, etag: string | null): ExternalObjectResolverSnapshot {
const title = asString(body.title);
const state = asString(body.state) ?? "unknown";
const stateReason = asString(body.state_reason);
const authorLogin = asNestedString(body, "user", "login");
const statusKey = state === "closed" && stateReason ? `closed_${stateReason}` : state;
const statusLabel = state === "closed"
? stateReason
? `Closed: ${stateReason.replace(/_/g, " ")}`
: "Closed"
: state === "open"
? "Open"
: "Unknown";
return {
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
statusKey,
statusLabel,
statusIconKey: state === "closed" ? "circle" : "circle-dot",
statusCategory: state === "open" ? "open" : state === "closed" ? "closed" : "unknown",
statusTone: state === "open" ? "info" : state === "closed" ? "muted" : "neutral",
isTerminal: state === "closed",
remoteVersion: asString(body.updated_at),
etag,
ttlSeconds: GITHUB_OBJECT_TTL_SECONDS,
data: {
provider: "github",
owner: identity.owner,
repo: identity.repo,
number: identity.number,
state,
...(stateReason ? { stateReason } : {}),
...(authorLogin ? { authorLogin } : {}),
},
};
}
async function safeJson(response: Response) {
try {
return asRecord(await response.json());
} catch {
return null;
}
}
async function defaultTokenProvider(db: Db, companyId: string, secretNames: readonly string[]) {
const secrets = secretService(db);
for (const secretName of secretNames) {
const secret = await secrets.getByName(companyId, secretName);
if (!secret) continue;
const token = await secrets.resolveSecretValue(companyId, secret.id, "latest");
const trimmed = token.trim();
if (trimmed) return trimmed;
}
return null;
}
export function createGitHubExternalObjectProvider(
db: Db,
opts: GitHubExternalObjectProviderOptions = {},
): { detector: ExternalObjectDetector; resolvers: ExternalObjectResolver[] } {
const fetchImpl = opts.fetch ?? ghFetch;
const secretNames = opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES;
const tokenProvider = Object.prototype.hasOwnProperty.call(opts, "tokenProvider") && opts.tokenProvider !== undefined
? opts.tokenProvider
: ((companyId: string) => defaultTokenProvider(db, companyId, secretNames));
const detector: ExternalObjectDetector = {
key: "github",
detect({ urls }): ExternalObjectDetection[] {
return urls.flatMap((canonical) => {
const identity = parseGitHubCanonicalUrl(canonical);
if (!identity) return [];
return [{
canonical,
detectorKey: "github",
providerKey: "github",
objectType: identity.objectType,
externalId: externalIdFor(identity),
displayKey: displayKeyFor(identity),
iconKey: "github",
displayTitle: displayTitleFor(identity),
confidence: "exact",
}];
});
},
};
function resolver(objectType: GitHubObjectIdentity["objectType"]): ExternalObjectResolver {
return {
providerKey: "github",
objectType,
async resolve({ companyId, object }) {
const identity = parseGitHubObject(object);
if (!identity || identity.objectType !== objectType) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_invalid_identity",
errorMessage: "GitHub object identity is invalid.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
let token: string | null = null;
try {
token = typeof tokenProvider === "function" ? await tokenProvider(companyId) : tokenProvider;
} catch {
return {
ok: false,
liveness: "auth_required",
errorCode: "github_token_unavailable",
errorMessage: "Configured GitHub credentials could not be resolved.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
token = token?.trim() || null;
const headers: Record<string, string> = {
accept: "application/vnd.github+json",
"user-agent": "paperclip-external-object-resolver",
"x-github-api-version": "2022-11-28",
};
if (token) headers.authorization = `Bearer ${token}`;
const apiKind = objectType === "pull_request" ? "pulls" : "issues";
const url = `${gitHubApiBase(identity.host)}/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repo)}/${apiKind}/${identity.number}`;
let response: Response;
try {
response = await fetchImpl(url, { headers });
} catch {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_fetch_failed",
errorMessage: "GitHub could not be reached while refreshing this object.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
const etag = response.headers.get("etag");
if (response.status === 404) {
return { ok: true, snapshot: notFoundSnapshot(identity, etag) };
}
const failure = failureFromGitHubResponse(response);
if (failure) return failure;
if (!response.ok) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_unexpected_response",
errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`,
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
const body = await safeJson(response);
if (!body) {
return {
ok: false,
liveness: "unreachable",
errorCode: "github_invalid_response",
errorMessage: "GitHub returned an invalid object response.",
retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS,
};
}
return {
ok: true,
snapshot: objectType === "pull_request"
? pullRequestSnapshot(identity, body, etag)
: issueSnapshot(identity, body, etag),
};
},
};
}
return {
detector,
resolvers: [resolver("pull_request"), resolver("issue")],
};
}
+9
View File
@@ -33,6 +33,15 @@ export {
resolveTaskWatchdogMutationScope,
taskWatchdogScopeAllowsIssueMutation,
} from "./task-watchdog-scope.js";
export {
createExternalObjectDetectorRegistry,
createExternalObjectResolverRegistry,
externalObjectService,
type ExternalObjectDetector,
type ExternalObjectResolver,
type ExternalObjectResolveResult,
type ExternalObjectResolverSnapshot,
} from "./external-objects.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
+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:
@@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"telemetry.track": ["telemetry.track"],
"db.migrate": ["database.namespace.migrate"],
"db.execute": ["database.namespace.write"],
"external.objects.detect": ["external.objects.detect"],
"external.objects.read": ["external.objects.read"],
"external.objects.write": ["external.objects.write"],
"external.objects.refresh": ["external.objects.refresh"],
// Plugin state operations
"plugin.state.get": ["plugin.state.read"],
@@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record<string, PluginCapability> = {
agents: "agents.managed",
projects: "projects.managed",
routines: "routines.managed",
objectReferences: "external.objects.detect",
};
// ---------------------------------------------------------------------------
@@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator {
}
}
if ((manifest.objectReferences?.length ?? 0) > 0) {
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) {
allMissing.push(requiredCap);
}
}
}
// Check UI slots → required capabilities
const uiSlots = manifest.ui?.slots ?? [];
if (uiSlots.length > 0) {