From b634353059cddb5add37f86a50f73b1954ad6f32 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 10:15:49 -0500 Subject: [PATCH] fix(server): wake milestones from GitHub repository restoration Co-Authored-By: Paperclip --- ...26-10-25-chat-run-milestone-sleep-audit.md | 25 +++++++++++++++++++ .../chat-run-milestone-resource-work.test.ts | 16 ++++++++---- .../chat-channels/github/resources.ts | 15 +++-------- .../chat-channels/resource-reconciliation.ts | 5 +++- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md index 734869b10b1..e6f7853d194 100644 --- a/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md +++ b/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md @@ -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. diff --git a/server/src/__tests__/chat-run-milestone-resource-work.test.ts b/server/src/__tests__/chat-run-milestone-resource-work.test.ts index 2236f5952bd..c760d6168c7 100644 --- a/server/src/__tests__/chat-run-milestone-resource-work.test.ts +++ b/server/src/__tests__/chat-run-milestone-resource-work.test.ts @@ -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(); diff --git a/server/src/services/chat-channels/github/resources.ts b/server/src/services/chat-channels/github/resources.ts index 5abe2384cd2..f8599487835 100644 --- a/server/src/services/chat-channels/github/resources.ts +++ b/server/src/services/chat-channels/github/resources.ts @@ -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; updateResourceSelection: (endpointId: string, updates: Array<{ id: string; enabled: boolean }>, actorUserId?: string | null, options?: { allGitHubRepositoriesEnabled?: boolean }) => Promise; }; 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); }); } diff --git a/server/src/services/chat-channels/resource-reconciliation.ts b/server/src/services/chat-channels/resource-reconciliation.ts index 17eb86ec6d9..448aa463e9e 100644 --- a/server/src/services/chat-channels/resource-reconciliation.ts +++ b/server/src/services/chat-channels/resource-reconciliation.ts @@ -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 }; }