fix(external-objects): refresh PR status labels (#10704)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue properties panel can show external objects such as GitHub
pull requests.
> - Those objects are resolved by external-object providers and then
displayed as compact status labels.
> - A GitHub pull request could remain in the fallback `unknown` state
and appear as `Not yet resolved`.
> - That label is confusing when the object is known but has not been
refreshed yet.
> - This pull request refreshes due external objects from the heartbeat
scheduler and improves the unknown-status copy.
> - The benefit is a properties panel that moves from pending refresh to
the real pull request state without a manual refresh.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. I searched for related
public issues and pull requests using the terms `Not yet refreshed`,
`external objects refresh`, and `external PR status`, and did not find a
duplicate implementation.

**What happened?**

The issue properties panel could show a GitHub pull request as `Not yet
resolved` even when the referenced pull request was valid. The object
stayed stale unless a manual refresh path ran.

**Expected behavior**

A known external object should show pending-refresh copy while it waits
for provider data. When the scheduler refreshes it, the properties panel
should show the provider status such as open, merged, or closed.

**Steps to reproduce**

1. Create or view an issue that references a GitHub pull request.
2. Open the issue properties panel.
3. Observe the external object row before a manual refresh has run.

**Paperclip version or commit**

Current `master` before this pull request.

**Deployment mode**

Local dev and self-hosted server.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific.

**Database mode**

Not database-related.

**Access context**

Board view.

**Privacy checklist**

I reviewed this description and did not include logs, credentials,
private URLs, internal issue IDs, or PII.

## What Changed

- Added a heartbeat scheduler tick that refreshes due external objects
for active companies.
- Kept manual external-object refresh behavior on the same service path.
- Changed display copy so known provider objects use liveness labels
such as `Not yet refreshed`, while fresh unknown provider statuses show
`Status unavailable`.
- Added server and UI tests for scheduled refresh and label behavior.

## Verification

- `corepack pnpm install --frozen-lockfile`
- `pnpm check:token-gates`
- `pnpm exec vitest run
server/src/__tests__/external-objects-service.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts
ui/src/components/ExternalObjectPill.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/lib/external-objects.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/server build`
- `pnpm --filter @paperclipai/ui build`

- `pnpm run typecheck:build-gaps`
- GitHub PR checks passed on head `0e7fcd30`
- Greptile reported 5/5 on head `0e7fcd30` with no unresolved review
threads

Notes:

- I ran recursive typecheck and build first. Both hit container resource
limits with exit 137 during concurrent package work, so I reran the
affected server and UI targets separately.
- An unrelated workspace-runtime auto-port test fails in this container
with a PID ownership mismatch. It is outside the files changed here.

## Risks

Low to medium risk.

The scheduler does more periodic external-object work, so the main risk
is extra provider refresh load. The implementation bounds the work to
active companies, due non-terminal objects, and 50 objects per company
per tick. The path also stays behind the external-objects experimental
setting.

## Model Used

OpenAI GPT-5 Codex in the Codex execution environment, with shell and
GitHub CLI tool use. The runtime did not expose a more specific internal
model ID or context window.

## 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:
Devin Foley 2026-08-02 20:24:52 -07:00 committed by GitHub
parent 7a3815eb9a
commit 185515c97b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 76692 additions and 33 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "external_objects" ADD COLUMN "refresh_started_at" timestamp with time zone;

View File

@ -0,0 +1 @@
ALTER TABLE "external_objects" ADD COLUMN "refresh_token" uuid;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1394,6 +1394,20 @@
"when": 1785635155420,
"tag": "0200_yellow_maria_hill",
"breakpoints": true
},
{
"idx": 201,
"version": "7",
"when": 1785701828695,
"tag": "0201_concerned_captain_midlands",
"breakpoints": true
},
{
"idx": 202,
"version": "7",
"when": 1785702264747,
"tag": "0202_eminent_marvel_zombies",
"breakpoints": true
}
]
}

View File

@ -44,6 +44,8 @@ export const externalObjects = pgTable(
lastChangedAt: timestamp("last_changed_at", { withTimezone: true }),
lastErrorAt: timestamp("last_error_at", { withTimezone: true }),
nextRefreshAt: timestamp("next_refresh_at", { withTimezone: true }),
refreshStartedAt: timestamp("refresh_started_at", { withTimezone: true }),
refreshToken: uuid("refresh_token"),
lastErrorCode: text("last_error_code"),
lastErrorMessage: text("last_error_message"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),

View File

@ -32,6 +32,7 @@ export interface ExternalObject {
lastChangedAt: string | null;
lastErrorAt: string | null;
nextRefreshAt: string | null;
refreshStartedAt: string | null;
lastErrorCode: string | null;
lastErrorMessage: string | null;
createdAt: string;

View File

@ -529,6 +529,207 @@ describeEmbeddedPostgres("externalObjectService", () => {
expect(resolve).toHaveBeenCalledTimes(1);
});
it("coalesces automatic and manual refreshes for the same object", async () => {
const { companyId, issueId } = await createIssue();
let markResolverStarted!: () => void;
const resolverStarted = new Promise<void>((resolve) => {
markResolverStarted = resolve;
});
let finishResolver!: () => void;
const resolverCanFinish = new Promise<void>((resolve) => {
finishResolver = resolve;
});
const resolve = vi.fn(async () => {
markResolverStarted();
await resolverCanFinish;
return {
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]!);
const dueRefresh = svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
await resolverStarted;
const manualRefresh = svc.refreshObject(object.id, { companyId, force: true });
finishResolver();
const [dueResults, manualResult] = await Promise.all([dueRefresh, manualRefresh]);
expect(dueResults).toHaveLength(1);
expect(dueResults[0]?.refreshed).toBe(true);
expect(manualResult.refreshed).toBe(true);
expect(manualResult.object.statusLabel).toBe("Open");
expect(resolve).toHaveBeenCalledTimes(1);
});
it("prevents duplicate refreshes across service instances", async () => {
const { companyId, issueId } = await createIssue();
let markResolverStarted!: () => void;
const resolverStarted = new Promise<void>((resolve) => {
markResolverStarted = resolve;
});
let finishResolver!: () => void;
const resolverCanFinish = new Promise<void>((resolve) => {
finishResolver = resolve;
});
const resolve = vi.fn(async () => {
markResolverStarted();
await resolverCanFinish;
return {
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 scheduledService = externalObjectService(db, { resolvers: [resolver], github: false });
const manualService = externalObjectService(db, { resolvers: [resolver], github: false });
await scheduledService.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000));
await resolverStarted;
const manualRefresh = await manualService.refreshObject(object.id, { companyId, force: true });
finishResolver();
const dueResults = await dueRefresh;
expect(dueResults).toHaveLength(1);
expect(dueResults[0]?.refreshed).toBe(true);
expect(manualRefresh.refreshed).toBe(false);
expect(manualRefresh.reason).toBe("refresh_in_progress");
expect(manualRefresh.object).not.toHaveProperty("refreshToken");
expect(resolve).toHaveBeenCalledTimes(1);
await expect(
db.select().from(externalObjects).then((rows) => rows[0]?.refreshStartedAt ?? null),
).resolves.toBeNull();
});
it("does not let a stale refresh overwrite a replacement claimant", async () => {
const { companyId, issueId } = await createIssue();
let markResolverStarted!: () => void;
const resolverStarted = new Promise<void>((resolve) => {
markResolverStarted = resolve;
});
let finishResolver!: () => void;
const resolverCanFinish = new Promise<void>((resolve) => {
finishResolver = resolve;
});
const slowResolve = vi.fn(async () => {
markResolverStarted();
await resolverCanFinish;
return {
ok: true as const,
snapshot: {
statusCategory: "open" as const,
statusTone: "info" as const,
statusKey: "open",
statusLabel: "Open",
ttlSeconds: 300,
},
};
});
const replacementResolve = vi.fn(async () => ({
ok: true as const,
snapshot: {
statusCategory: "closed" as const,
statusTone: "muted" as const,
statusKey: "closed",
statusLabel: "Closed",
ttlSeconds: 300,
},
}));
const scheduledService = externalObjectService(db, {
resolvers: [{ providerKey: "url", objectType: "link", resolve: slowResolve }],
github: false,
});
const replacementService = externalObjectService(db, {
resolvers: [{ providerKey: "url", objectType: "link", resolve: replacementResolve }],
github: false,
});
await scheduledService.syncIssue(issueId);
const object = await db.select().from(externalObjects).then((rows) => rows[0]!);
const claimedAt = new Date(Date.now() + 1_000);
const dueRefresh = scheduledService.refreshDueObjects(companyId, 50, claimedAt);
await resolverStarted;
const replacementResult = await replacementService.refreshObject(object.id, {
companyId,
force: true,
now: new Date(claimedAt.getTime() + 301_000),
});
finishResolver();
const dueResults = await dueRefresh;
const finalObject = await db.select().from(externalObjects).then((rows) => rows[0]!);
expect(replacementResult.refreshed).toBe(true);
expect(dueResults[0]).toMatchObject({ refreshed: false, reason: "refresh_superseded" });
expect(finalObject.statusLabel).toBe("Closed");
expect(finalObject.refreshStartedAt).toBeNull();
expect(finalObject.refreshToken).toBeNull();
expect(slowResolve).toHaveBeenCalledTimes(1);
expect(replacementResolve).toHaveBeenCalledTimes(1);
});
it("refreshes due objects for active companies only", async () => {
const active = await createIssue();
const paused = await createIssue();
await db
.update(companies)
.set({ status: "paused" })
.where(eq(companies.id, paused.companyId));
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(active.issueId);
await svc.syncIssue(paused.issueId);
const result = await svc.refreshDueObjectsForActiveCompanies(50, new Date(Date.now() + 1_000));
expect(result).toEqual({ companies: 1, checked: 1, refreshed: 1 });
expect(resolve).toHaveBeenCalledTimes(1);
const rows = await db.select().from(externalObjects);
const activeObject = rows.find((row) => row.companyId === active.companyId);
const pausedObject = rows.find((row) => row.companyId === paused.companyId);
expect(activeObject?.statusLabel).toBe("Open");
expect(pausedObject?.statusLabel).toBeNull();
});
it("removes comment mentions when a synced comment is hard-deleted", async () => {
const { companyId, issueId } = await createIssue();
const commentId = randomUUID();

View File

@ -17,6 +17,8 @@ const {
deriveAuthTrustedOriginsMock,
environmentCustomImagesServiceMock,
environmentCustomImagesServiceFactoryMock,
externalObjectsServiceMock,
externalObjectsServiceFactoryMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
@ -71,6 +73,10 @@ const {
cleanupExpiredSetupSessions: vi.fn(async () => ({ scanned: 0, timedOut: 0, failed: 0 })),
};
const environmentCustomImagesServiceFactoryMock = vi.fn(() => environmentCustomImagesServiceMock);
const externalObjectsServiceMock = {
refreshDueObjectsForActiveCompanies: vi.fn(async () => ({ companies: 0, checked: 0, refreshed: 0 })),
};
const externalObjectsServiceFactoryMock = vi.fn(() => externalObjectsServiceMock);
const routineServiceMock = {
tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })),
};
@ -98,6 +104,8 @@ const {
deriveAuthTrustedOriginsMock,
environmentCustomImagesServiceMock,
environmentCustomImagesServiceFactoryMock,
externalObjectsServiceMock,
externalObjectsServiceFactoryMock,
feedbackExportServiceMock,
feedbackServiceFactoryMock,
fakeServer,
@ -225,9 +233,14 @@ vi.mock("../services/index.js", () => ({
bootstrapExecutionPolicyFromEnv: vi.fn(async () => null),
applyManagedEnvironments: vi.fn(async () => null),
environmentCustomImageService: environmentCustomImagesServiceFactoryMock,
externalObjectService: externalObjectsServiceFactoryMock,
heartbeatService: heartbeatServiceFactoryMock,
issueService: vi.fn(() => ({ update: vi.fn(async () => null) })),
instanceSettingsService: vi.fn(() => ({
getExperimental: vi.fn(async () => ({
enableExternalObjects: true,
enableStatusCards: false,
})),
getGeneral: vi.fn(async () => ({
backupRetention: {
dailyDays: 7,
@ -444,6 +457,7 @@ describe("startServer feedback export wiring", () => {
await Promise.resolve();
expect(heartbeatServiceMock.tickTimers).not.toHaveBeenCalled();
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).toHaveBeenCalledTimes(1);
expect(routineServiceMock.tickScheduledTriggers).toHaveBeenCalledTimes(1);
expect(environmentCustomImagesServiceMock.cleanupExpiredSetupSessions).toHaveBeenCalledTimes(2);
} finally {
@ -451,6 +465,36 @@ describe("startServer feedback export wiring", () => {
}
});
it("keeps external object refresh active when heartbeat scheduling is disabled", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
heartbeatSchedulerEnabled: false,
heartbeatSchedulerIntervalMs: 30000,
}));
let intervalCallback: (() => void) | null = null;
const setIntervalSpy = vi
.spyOn(globalThis, "setInterval")
.mockImplementation(((callback: () => void) => {
intervalCallback = callback;
return 1 as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval);
try {
await startServer();
expect(heartbeatServiceFactoryMock).not.toHaveBeenCalled();
expect(intervalCallback).not.toBeNull();
intervalCallback?.();
await Promise.resolve();
await Promise.resolve();
expect(externalObjectsServiceMock.refreshDueObjectsForActiveCompanies).toHaveBeenCalledTimes(1);
expect(routineServiceMock.tickScheduledTriggers).not.toHaveBeenCalled();
expect(environmentCustomImagesServiceMock.cleanupExpiredSetupSessions).not.toHaveBeenCalled();
} finally {
setIntervalSpy.mockRestore();
}
});
it("does not replay hot-restart adoption when the orphan reaper retries", async () => {
loadConfigMock.mockReturnValue(buildTestConfig({
heartbeatSchedulerEnabled: true,

View File

@ -49,6 +49,7 @@ import {
environmentCustomImageService,
decisionService,
decisionRetentionService,
externalObjectService,
heartbeatService,
issueService,
instanceSettingsService,
@ -912,6 +913,27 @@ export async function startServer(): Promise<StartedServer> {
await Promise.allSettled([...heartbeatSchedulerInFlight]);
}
};
const startHeartbeatSchedulerInterval = (callback: () => void) => {
heartbeatSchedulerInterval = setInterval(callback, config.heartbeatSchedulerIntervalMs);
heartbeatSchedulerInterval?.unref?.();
};
const externalObjects = externalObjectService(db as any, {
pluginWorkerManager,
enabled: async () => (await instanceSettingsService(db).getExperimental()).enableExternalObjects === true,
});
const scheduleExternalObjectRefreshSweep = (now = new Date()) => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(externalObjects
.refreshDueObjectsForActiveCompanies(50, now)
.then((result) => {
if (result.checked > 0 || result.refreshed > 0) {
logger.info({ ...result }, "external-object scheduler tick refreshed due objects");
}
})
.catch((err) => {
logger.error({ err }, "external-object scheduler tick failed");
}));
};
if (heartbeat) {
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
@ -1073,7 +1095,7 @@ export async function startServer(): Promise<StartedServer> {
};
await runRetentionSweep();
heartbeatSchedulerInterval = setInterval(() => {
startHeartbeatSchedulerInterval(() => {
// Async so the suppression checks below can honor the override-aware
// resolver (e.g. worktree run-execution opt-in). The gated work is still
// wrapped in trackHeartbeatSchedulerWork with its own error handling.
@ -1106,6 +1128,9 @@ export async function startServer(): Promise<StartedServer> {
}));
}
if (heartbeatSchedulerStopped) return;
scheduleExternalObjectRefreshSweep(new Date());
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(routines
.tickScheduledTriggers(new Date())
@ -1230,7 +1255,11 @@ export async function startServer(): Promise<StartedServer> {
}));
}
})();
}, config.heartbeatSchedulerIntervalMs);
});
} else {
startHeartbeatSchedulerInterval(() => {
scheduleExternalObjectRefreshSweep(new Date());
});
}
if (config.databaseBackupEnabled) {

View File

@ -1,6 +1,7 @@
import { and, asc, eq, inArray, isNull, lte, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { and, asc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
import { companies, documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
import {
formatExternalObjectMentionSourceLabel,
type ExternalObjectCanonicalUrl,
@ -91,6 +92,8 @@ type ExternalObjectMentionRecord = typeof externalObjectMentions.$inferSelect;
const DEFAULT_REFRESH_TTL_SECONDS = 300;
const DEFAULT_RETRY_AFTER_SECONDS = 300;
const DEFAULT_REFRESH_LEASE_SECONDS = 300;
const REFRESH_LEASE_RENEW_INTERVAL_MS = 60_000;
function sourceWhere(input: ExternalObjectSourceContext) {
const conditions = [
@ -601,8 +604,10 @@ export function externalObjectService(
}
function toObjectPayload(object: ExternalObjectRecord, now = new Date()) {
const { refreshToken, ...payload } = object;
void refreshToken;
return {
...object,
...payload,
liveness: visibleLiveness(object, now),
};
}
@ -768,26 +773,37 @@ export function externalObjectService(
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
type RefreshObjectInput = {
companyId: string;
actor?: Pick<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
force?: boolean;
now?: Date;
};
function refreshOwnerWhere(object: ExternalObjectRecord, refreshToken: string) {
return and(
eq(externalObjects.id, object.id),
eq(externalObjects.companyId, object.companyId),
eq(externalObjects.refreshToken, refreshToken),
);
}
async function refreshSupersededResult(object: ExternalObjectRecord, now: Date) {
const latest = await db
.select()
.from(externalObjects)
.where(and(eq(externalObjects.id, objectId), eq(externalObjects.companyId, input.companyId)))
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.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 };
}
if (!latest) throw notFound("External object not found");
return { object: toObjectPayload(latest, now), refreshed: false, reason: "refresh_superseded" as const };
}
async function resolveObjectRefresh(
object: ExternalObjectRecord,
input: RefreshObjectInput,
now: Date,
refreshToken: string,
) {
const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object);
const resolver = pluginResult ? null : resolverRegistry.find(object);
if (!pluginResult && !resolver) {
@ -796,10 +812,13 @@ export function externalObjectService(
.set({
liveness: visibleLiveness(object, now) === "fresh" ? "stale" : object.liveness,
nextRefreshAt: addSeconds(now, DEFAULT_RETRY_AFTER_SECONDS),
refreshStartedAt: null,
refreshToken: null,
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.where(refreshOwnerWhere(object, refreshToken))
.returning();
if (!updated) return refreshSupersededResult(object, now);
return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const };
}
@ -813,10 +832,13 @@ export function externalObjectService(
lastErrorCode: result.errorCode,
lastErrorMessage: sanitizeErrorMessage(result.errorMessage),
nextRefreshAt: addSeconds(now, result.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS),
refreshStartedAt: null,
refreshToken: null,
updatedAt: now,
})
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.where(refreshOwnerWhere(object, refreshToken))
.returning();
if (!updated) return refreshSupersededResult(object, now);
publishLiveEvent({
companyId: object.companyId,
type: "external_object.updated",
@ -845,16 +867,19 @@ export function externalObjectService(
lastErrorCode: null,
lastErrorMessage: null,
nextRefreshAt: addSeconds(now, snapshot.ttlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS),
refreshStartedAt: null,
refreshToken: null,
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)))
...patch,
lastChangedAt: objectChanged(object, { ...object, ...patch }) ? now : object.lastChangedAt,
})
.where(refreshOwnerWhere(object, refreshToken))
.returning();
if (!updated) return refreshSupersededResult(object, now);
const next = updated ?? object;
if (objectChanged(object, next) && input.actor) {
await logActivity(db, {
@ -886,6 +911,100 @@ export function externalObjectService(
return { object: toObjectPayload(next, now), refreshed: true, reason: "resolved" as const };
}
async function claimObjectRefresh(
object: ExternalObjectRecord,
input: RefreshObjectInput,
now: Date,
) {
const staleRefreshStartedBefore = new Date(now.getTime() - DEFAULT_REFRESH_LEASE_SECONDS * 1000);
const leaseAvailable = or(
isNull(externalObjects.refreshStartedAt),
lte(externalObjects.refreshStartedAt, staleRefreshStartedBefore),
)!;
const refreshToken = randomUUID();
const dueOrForced = input.force
? leaseAvailable
: and(
leaseAvailable,
or(isNull(externalObjects.nextRefreshAt), lte(externalObjects.nextRefreshAt, now))!,
)!;
const [claimed] = await db
.update(externalObjects)
.set({
refreshStartedAt: now,
refreshToken,
updatedAt: now,
})
.where(and(
eq(externalObjects.id, object.id),
eq(externalObjects.companyId, object.companyId),
dueOrForced,
))
.returning();
return claimed ?? null;
}
function startRefreshLeaseRenewal(object: ExternalObjectRecord, refreshToken: string) {
const interval = setInterval(() => {
const renewedAt = new Date();
void db
.update(externalObjects)
.set({ refreshStartedAt: renewedAt, updatedAt: renewedAt })
.where(refreshOwnerWhere(object, refreshToken))
.catch((err: unknown) => {
logger.warn({ err, objectId: object.id }, "external object refresh lease renewal failed");
});
}, REFRESH_LEASE_RENEW_INTERVAL_MS);
interval.unref?.();
return () => {
clearInterval(interval);
};
}
const objectRefreshesInFlight = new Map<string, Promise<Awaited<ReturnType<typeof resolveObjectRefresh>>>>();
async function refreshObject(
objectId: string,
input: RefreshObjectInput,
) {
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 refreshKey = `${object.companyId}:${object.id}`;
const existingRefresh = objectRefreshesInFlight.get(refreshKey);
if (existingRefresh) return existingRefresh;
const claimed = await claimObjectRefresh(object, input, now);
if (!claimed) {
const latest = await db
.select()
.from(externalObjects)
.where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId)))
.then((rows) => rows[0] ?? null);
if (!latest) throw notFound("External object not found");
return { object: toObjectPayload(latest, now), refreshed: false, reason: "refresh_in_progress" as const };
}
if (!claimed.refreshToken) {
throw new Error("External object refresh claim did not return a refresh token");
}
const stopRenewingRefreshLease = startRefreshLeaseRenewal(object, claimed.refreshToken);
const refresh = resolveObjectRefresh(object, input, now, claimed.refreshToken).finally(() => {
stopRenewingRefreshLease();
objectRefreshesInFlight.delete(refreshKey);
});
objectRefreshesInFlight.set(refreshKey, refresh);
return refresh;
}
async function refreshIssueObjects(issueId: string, input: {
companyId: string;
objectIds?: string[];
@ -903,8 +1022,8 @@ export function externalObjectService(
return results;
}
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
if (!(await isEnabled())) return [];
async function refreshDueObjectsUnchecked(companyId: string, limit = 50, now = new Date()) {
const staleRefreshStartedBefore = new Date(now.getTime() - DEFAULT_REFRESH_LEASE_SECONDS * 1000);
const due = await db
.select({ id: externalObjects.id })
.from(externalObjects)
@ -913,6 +1032,10 @@ export function externalObjectService(
eq(externalObjects.companyId, companyId),
eq(externalObjects.isTerminal, false),
lte(externalObjects.nextRefreshAt, now),
or(
isNull(externalObjects.refreshStartedAt),
lte(externalObjects.refreshStartedAt, staleRefreshStartedBefore),
),
),
)
.limit(limit);
@ -927,6 +1050,27 @@ export function externalObjectService(
return results;
}
async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) {
if (!(await isEnabled())) return [];
return refreshDueObjectsUnchecked(companyId, limit, now);
}
async function refreshDueObjectsForActiveCompanies(limitPerCompany = 50, now = new Date()) {
if (!(await isEnabled())) return { companies: 0, checked: 0, refreshed: 0 };
const activeCompanies = await db
.select({ id: companies.id })
.from(companies)
.where(eq(companies.status, "active"));
let checked = 0;
let refreshed = 0;
for (const company of activeCompanies) {
const results = await refreshDueObjectsUnchecked(company.id, limitPerCompany, now);
checked += results.length;
refreshed += results.filter((result) => result.refreshed).length;
}
return { companies: activeCompanies.length, checked, refreshed };
}
return {
syncIssue,
syncComment,
@ -941,5 +1085,6 @@ export function externalObjectService(
refreshObject,
refreshIssueObjects,
refreshDueObjects,
refreshDueObjectsForActiveCompanies,
};
}

View File

@ -91,6 +91,25 @@ describe("ExternalObjectPill", () => {
expect(html).not.toContain("×");
});
it("labels detected GitHub pull requests without fetched status as not refreshed", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "unknown",
liveness: "unknown",
displayTitle: "acme/web#241",
url: "https://github.com/acme/web/pull/241",
}}
/>,
);
expect(html).toContain("PR 241 - Not yet refreshed");
expect(html).toContain('aria-label="GitHub pull request — Not yet refreshed: acme/web#241"');
expect(html).not.toContain("Not yet resolved");
});
it("uses the object link label, provider icon, and visible status when supplied", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill

View File

@ -10,7 +10,7 @@ import {
externalObjectLivenessOverlay,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectDisplayStatusLabel,
externalObjectDisplayLabel,
externalObjectLivenessLabel,
externalObjectIconForKey,
@ -114,7 +114,7 @@ export function ExternalObjectPill({
const providerLabel = externalObjectProviderLabel(object.providerKey);
const typeLabel = externalObjectTypeLabel(object.objectType);
const displayKey = externalObjectDisplayLabel(object.providerKey, object.objectType, object.displayKey);
const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory);
const statusLabel = externalObjectDisplayStatusLabel(object);
const tone = externalObjectPillTone(object, statusLabel);
const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel);
const statusIconKey = externalObjectStatusIconKey(object, statusLabel);

View File

@ -2484,6 +2484,28 @@ describe("IssueProperties", () => {
sourceLabels: ["Comment"],
},
},
{
mentionCount: 1,
sourceLabels: ["Comment"],
pill: {
providerKey: "github",
objectType: "pull_request",
displayKey: "Github PR",
iconKey: "github",
statusCategory: "unknown",
statusIconKey: null,
statusLabel: null,
liveness: "unknown",
displayTitle: "acme/web#242",
url: "https://github.com/acme/web/pull/242",
},
group: {
object: null,
mentions: [],
mentionCount: 1,
sourceLabels: ["Comment"],
},
},
],
});
await flush();
@ -2510,6 +2532,10 @@ describe("IssueProperties", () => {
expect(pullRequestLink?.className).not.toContain("paperclip-mention-chip");
expect(pullRequestLink?.className).not.toContain("rounded-full");
expect(pullRequestLink?.className).not.toContain("border");
const unrefreshedPullRequestLink = Array.from(container.querySelectorAll("a"))
.find((anchor) => anchor.getAttribute("href") === "https://github.com/acme/web/pull/242");
expect(unrefreshedPullRequestLink?.textContent).toContain("PR 242 - Not yet refreshed");
expect(unrefreshedPullRequestLink?.textContent).not.toContain("Not yet resolved");
act(() => root.unmount());
});

View File

@ -1,7 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import type { IssueExternalObjectGroup } from "../../hooks/useIssueExternalObjects";
import {
externalObjectCategoryLabel,
externalObjectDisplayStatusLabel,
externalObjectDisplayLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
@ -66,7 +66,7 @@ function githubObjectPropertyValue(url: string | null | undefined): string | nul
}
function externalObjectPropertyStatusLabel(group: IssueExternalObjectGroup): string {
return group.pill.statusLabel ?? externalObjectCategoryLabel(group.pill.statusCategory);
return externalObjectDisplayStatusLabel(group.pill);
}
function externalObjectPropertyValue(group: IssueExternalObjectGroup): string {

View File

@ -8,6 +8,7 @@ import {
import {
dominantExternalObjectTone,
externalObjectCategoryLabel,
externalObjectDisplayStatusLabel,
externalObjectDisplayLabel,
externalObjectDominantCount,
externalObjectFallbackTone,
@ -114,6 +115,42 @@ describe("external-objects helpers", () => {
expect(externalObjectDisplayLabel("github", "pull_request")).toBe("GitHub pull request");
});
it("labels unresolved known objects as not refreshed while preserving generic URL copy", () => {
expect(
externalObjectDisplayStatusLabel({
providerKey: "github",
objectType: "pull_request",
statusCategory: "unknown",
liveness: "unknown",
}),
).toBe("Not yet refreshed");
expect(
externalObjectDisplayStatusLabel({
providerKey: "url",
objectType: "link",
statusCategory: "unknown",
liveness: "unknown",
}),
).toBe("Not yet resolved");
expect(
externalObjectDisplayStatusLabel({
providerKey: "github",
objectType: "pull_request",
statusCategory: "unknown",
liveness: "fresh",
}),
).toBe("Status unavailable");
expect(
externalObjectDisplayStatusLabel({
providerKey: "github",
objectType: "pull_request",
statusCategory: "open",
liveness: "fresh",
statusLabel: "Open",
}),
).toBe("Open");
});
it("orders tones from danger down to muted", () => {
expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning"));
expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info"));

View File

@ -104,6 +104,24 @@ export function externalObjectLivenessLabel(liveness: string): string {
return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " ");
}
export function externalObjectDisplayStatusLabel(input: {
providerKey: string | null | undefined;
objectType: string | null | undefined;
statusCategory: string;
liveness: string;
statusLabel?: string | null;
}): string {
const trimmedStatusLabel = input.statusLabel?.trim();
if (trimmedStatusLabel) return trimmedStatusLabel;
const isGenericUrl = input.providerKey === "url" && input.objectType === "link";
const hasKnownObjectType = Boolean(input.providerKey && input.objectType);
if (input.statusCategory === "unknown" && hasKnownObjectType && !isGenericUrl) {
if (input.liveness === "fresh") return "Status unavailable";
return externalObjectLivenessLabel(input.liveness);
}
return externalObjectCategoryLabel(input.statusCategory);
}
/**
* Higher number = more attention-worthy. The rollups in §5 sort by tone first.
* Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`.

View File

@ -71,6 +71,7 @@ function makeObject(args: {
lastChangedAt: "2026-04-24T22:45:00.000Z",
lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null,
nextRefreshAt: null,
refreshStartedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
createdAt: "2026-04-24T20:00:00.000Z",