fix(server): wake milestones from GitHub repository restoration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 10:15:49 -05:00
1 parent bdf7ae7e10
commit b634353059
4 files changed
+43 -18

No files matched your search

@@ -1127,3 +1127,28 @@ Final resource checkpoint: all 20 projection units pass at the corrected path in
/tmp/sleep28-resource-build.log. Combined selected evidence is 116 distinct
milestone/projection/provider cases, plus the 109 propagated configuration-owner
cases. Full remote checks/reviews remain required for the new head.
## GitHub repository restoration follow-up
GitHub's signed repository callback has a separate restoration path. Its real
owner regression fails in /tmp/sleep28-resource-github-before.log. The provider
module now receives a narrow restoreResourceConversations callback from the
existing resource owner, using the same transaction and issue notification.
Provider payload parsing stays in github/resources.ts. All three resource cases
pass in /tmp/sleep28-resource-github-tests.log; five GitHub integration cases
pass in /tmp/sleep28-resource-github-acceptance.log, including rename/transfer,
concurrent installation events, foreign-installation canonical inventory,
credential rotation, and paused endpoints. Server build/typecheck passes in
/tmp/sleep28-resource-github-build.log. Installation recovery already invokes
reconcileProviderResourceRows; its restoration uses the covered inventory path.
Next concrete producer: telegram/resources.ts::migrateTelegramResourceConversations
sets moved conversations active before the generic availability transition runs,
so that transition cannot discover the earlier unavailable state. Preserve the
existing migration semantics and notify its affected issue scopes before the
migration write. Normal chat admission also writes links and activates the
conversation in chat-channels.ts; establish its wake ordering explicitly. The
Slack waiting-to-active helpers preserve selector eligibility (both states are
already admitted). Email uses explicit publication mode, which the lifecycle
selector and lineage resolver exclude. These dispositions narrow the remaining
configuration audit; they do not authorize removing the periodic scan yet.
@@ -49,16 +49,20 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
}
const publications = () => db.select().from(chatPublications).where(eq(chatPublications.issueId, f.issueId));
it.each(["inventory", "lifecycle"] as const)("wakes only reopened conversations after %s commits, preserving rollback and completed history", async path => {
it.each(["inventory", "lifecycle", "github repository"] as const)("wakes only reopened conversations after %s commits, preserving rollback and completed history", async path => {
const provider = path === "github repository" ? "github" : "slack";
const resourceType = provider === "github" ? "repository" : "channel";
const providerResourceId = provider === "github" ? "owner/repo" : "recovered-channel";
await db.update(chatEndpoints).set({ provider }).where(eq(chatEndpoints.id, f.endpointId));
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
responsibleUserId: f.userId, status: "failed", errorCode: "test_run_failed", updatedAt: new Date(),
contextSnapshot: { issueId: f.issueId, source: "chat:slack", endpointId: f.endpointId, wakeCommentId: f.commentId },
contextSnapshot: { issueId: f.issueId, source: `chat:${provider}`, endpointId: f.endpointId, wakeCommentId: f.commentId },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId));
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId));
const [resource] = await db.insert(chatEndpointResources).values({ companyId: f.companyId, endpointId: f.endpointId,
type: "channel", providerResourceId: "recovered-channel", label: "Recovered", availability: "unavailable", enabled: true,
type: resourceType, providerResourceId, label: "Recovered", availability: "unavailable", enabled: true,
}).returning();
await db.update(chatConversations).set({ resourceId: resource.id, state: "unavailable" }).where(eq(chatConversations.id, f.conversationId));
const completedId = randomUUID();
@@ -76,7 +80,9 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
endpointRecord: async () => ({ endpoint }), withCredentialMutationLease: withLease, runtimeGeneration: () => 0, maxErrorText: 1000,
});
if (path === "inventory") {
await resources.reconcileProviderResourceRows(endpoint, { provider: "slack", resources: [{ type: "channel", providerResourceId: "recovered-channel", label: "Recovered" }] }, lease);
await resources.reconcileProviderResourceRows(endpoint, { provider, resources: [{ type: resourceType, providerResourceId, label: "Recovered" }] }, lease);
} else if (path === "github repository") {
await resources.reconcileGitHubWebhookRepository(endpoint, { repository: { id: 123, full_name: providerResourceId, name: "repo", owner: { id: 321, login: "owner" } } }, lease);
} else {
await resources.applyProviderAvailability({ currentEndpoint: endpoint, credentialLease: lease, staleResourceEffect: false,
claimed: delivery, candidate: { attempts: 0 }, effect: { kind: "resource", provider: "slack", providerEventId: delivery.providerEventId,
@@ -99,7 +105,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
try {
await withLease(endpoint, lease => createChatResourceReconciliation(db, {
endpointRecord: async () => ({ endpoint }), withCredentialMutationLease: withLease, runtimeGeneration: () => 0, maxErrorText: 1000,
}).reconcileProviderResourceRows(endpoint, { provider: "slack", resources: [{ type: "channel", providerResourceId: "recovered-channel", label: "Recovered" }] }, lease));
}).reconcileProviderResourceRows(endpoint, { provider, resources: [{ type: resourceType, providerResourceId, label: "Recovered" }] }, lease));
expect(notices).toEqual([]);
} finally { unsubscribe(); }
await flush(); await quiet();
@@ -201,11 +201,12 @@ export async function reconcileGitHubRepositoryCoordinates(tx: DbTransaction, en
type GitHubResourceOptions = {
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
upsertProviderResourceRow: (tx: DbTransaction, endpoint: EndpointRow, item: ChatProviderResourceInventoryItem) => Promise<{ id: string } | null>;
restoreResourceConversations: (tx: DbTransaction, endpoint: EndpointRow, resourceId: string) => Promise<void>;
updateResourceSelection: (endpointId: string, updates: Array<{ id: string; enabled: boolean }>, actorUserId?: string | null, options?: { allGitHubRepositoriesEnabled?: boolean }) => Promise<void>;
};
export function createGitHubResources(db: Db, options: GitHubResourceOptions) {
const { endpointRecord, upsertProviderResourceRow, updateResourceSelection } = options;
const { endpointRecord, upsertProviderResourceRow, restoreResourceConversations, updateResourceSelection } = options;
async function reconcileGitHubWebhookRepository(
endpoint: EndpointRow,
payload: unknown,
@@ -220,17 +221,7 @@ export function createGitHubResources(db: Db, options: GitHubResourceOptions) {
// A correctly signed repository callback is current provider proof. Keep
// Paperclip's enabled choice, and reopen only conversations previously
// quarantined for provider availability loss.
await tx
.update(chatConversations)
.set({ state: "active", updatedAt: new Date() })
.where(
and(
eq(chatConversations.companyId, endpoint.companyId),
eq(chatConversations.endpointId, endpoint.id),
eq(chatConversations.resourceId, resource.id),
eq(chatConversations.state, "unavailable"),
),
);
await restoreResourceConversations(tx, endpoint, resource.id);
await credentialLease.assertOwned(tx);
});
}
@@ -604,7 +604,10 @@ export function createChatResourceReconciliation(db: Db, options: ResourceReconc
});
}
const { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories } = createGitHubResources(db, { endpointRecord, upsertProviderResourceRow, updateResourceSelection });
const { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories } = createGitHubResources(db, {
endpointRecord, upsertProviderResourceRow, updateResourceSelection,
restoreResourceConversations: (tx, endpoint, resourceId) => setResourceConversationAvailability(tx, endpoint, resourceId, true, new Date()),
});
return { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability };
}