import { createHmac, generateKeyPairSync } from "node:crypto"; import { expect } from "vitest"; import { denFetch } from "@openwork/behaviors"; import type { DenSession } from "@openwork/behaviors"; import { startMockGithub } from "@openwork/labs"; import type { MockGithubRepository } from "@openwork/labs"; import { localMysqlIsRunning, needs, server, test } from "@openwork/testkit"; /** * CLAIMS — APPROVED NARRATION: * 1. Push → auto-update. GitHub receives the webhook immediately, OpenWork * acknowledges it before processing, and the exact pushed commit becomes * the skill's latest stored version automatically. * 2. Rate-limited push retries itself. Two provider 429s recover without a * human retry, the event completes rather than failing, and v3 is current. * 3. Back-to-back pushes, newest wins. Serialized target work materializes * the current branch head, so an older event never overwrites v5. * 4. Rename. The connector follows the repository's stable id to its new name, * and the next push still updates the skill — the #3521 failure mode heals. * 5. Lost webhook heals. Reconciliation notices branch drift, completes a * manual resync, and imports v7 without a delivery or a human. * 6. Sync now + the story. A manual sync runs through the same worker without * duplicating an unchanged version, and the event history proves pushes, * retries, rename continuity, and reconciliation all happened. */ const localPlacement = process.env.OPENWORK_EVAL_DAYTONA !== "1" && !process.env.OPENWORK_EVAL_DEN_API_URL?.trim(); const mysqlOpen = await localMysqlIsRunning(); const title = !localPlacement ? "GitHub sync self-healing skipped — needs: local placement without OPENWORK_EVAL_DEN_API_URL" : !mysqlOpen ? "GitHub sync self-healing skipped — needs: MySQL on 127.0.0.1:3306" : "GitHub sync queues work, retries transient faults, reconciles drift, and survives repository renames"; const REQUEST_TIMEOUT_MS = 120_000; const WEBHOOK_SECRET = "spec-webhook-secret"; const INSTALLATION_ID = 777; const REPOSITORY_ID = 42; const ORIGINAL_REPOSITORY = "acme-eval/probe-plugins"; const RENAMED_REPOSITORY = "acme-eval/probe-plugins-renamed"; const SKILL_PATH = "probe-plugin/skills/probe/SKILL.md"; const TERMINAL_STATUSES = new Set(["completed", "failed", "partial", "ignored"]); const MARKETPLACE_MANIFEST = JSON.stringify({ name: "Probe marketplace", description: "Private GitHub connector marketplace fixture", owner: { name: "acme-eval" }, plugins: [{ name: "probe-plugin", description: "Probe connector plugin", source: "./probe-plugin", }], }); const PLUGIN_MANIFEST = JSON.stringify({ name: "probe-plugin", description: "Probe connector plugin", skills: ["./skills"], }); interface ApiResult { body: unknown; status: number; text: string; } interface WebhookResult extends ApiResult { deliveryId: string; durationMs: number; } interface SyncEvent { attemptCount: number; eventType: string; id: string; nextAttemptAt: string | null; sourceRevisionRef: string | null; status: string; summaryJson: Record | null; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function requireRecord(value: unknown, label: string): Record { if (!isRecord(value)) throw new Error(`${label} was not an object: ${JSON.stringify(value).slice(0, 500)}`); return value; } function requireRecords(value: unknown, label: string): Record[] { if (!Array.isArray(value) || !value.every(isRecord)) { throw new Error(`${label} was not an object array: ${JSON.stringify(value).slice(0, 500)}`); } return value.filter(isRecord); } function requireString(value: unknown, label: string): string { if (typeof value !== "string" || !value) throw new Error(`${label} was not a non-empty string.`); return value; } function requireNullableString(value: unknown, label: string): string | null { if (value === null) return null; return requireString(value, label); } function requireNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} was not a finite number.`); return value; } function requireNullableRecord(value: unknown, label: string): Record | null { if (value === null) return null; return requireRecord(value, label); } function responseItem(body: unknown, label: string): Record { return requireRecord(requireRecord(body, `${label} response`).item, `${label} item`); } function installEnvironment(values: Record): () => void { const previous = new Map(); for (const [name, value] of Object.entries(values)) { previous.set(name, process.env[name]); process.env[name] = value; } return () => { for (const [name, value] of previous) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } }; } function skillMarkdown(version: string): string { return `---\nname: probe\ndescription: Probe connector import\n---\n\nRun the probe checklist ${version}.\n`; } function repositoryFiles(skillSource: string): Record { return { ".claude-plugin/marketplace.json": MARKETPLACE_MANIFEST, "probe-plugin/.claude-plugin/plugin.json": PLUGIN_MANIFEST, [SKILL_PATH]: skillSource, "probe-plugin/skills/probe/references/notes.md": "# Notes\n\nCompanion file.\n", }; } function githubRepositories(): MockGithubRepository[] { return [ { id: 1, fullName: "acme-eval/repo-1", defaultBranch: "main", private: false }, { id: REPOSITORY_ID, fullName: ORIGINAL_REPOSITORY, defaultBranch: "develop", private: true }, { id: 43, fullName: "acme-eval/repo-43", defaultBranch: "main", private: false }, ]; } async function apiRequest( session: DenSession, path: string, input: { body?: Record; method?: "GET" | "POST"; orgId?: string } = {}, ): Promise { const headers: Record = { authorization: `Bearer ${session.token}`, }; if (input.orgId) headers["x-openwork-org-id"] = input.orgId; if (input.body) headers["content-type"] = "application/json"; const result = await denFetch(session, path, { method: input.method ?? "GET", headers, body: input.body ? JSON.stringify(input.body) : undefined, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!result.response.ok) { throw new Error(`${input.method ?? "GET"} ${path} failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`); } return { body: result.body, status: result.response.status, text: result.text }; } async function webhookRequest( session: DenSession, event: "push" | "repository", deliveryId: string, payload: Record, ): Promise { const rawBody = JSON.stringify(payload); const signature = `sha256=${createHmac("sha256", WEBHOOK_SECRET).update(rawBody).digest("hex")}`; const startedAt = Date.now(); const result = await denFetch(session, "/v1/webhooks/connectors/github", { method: "POST", headers: { "content-type": "application/json", "x-github-delivery": deliveryId, "x-github-event": event, "x-hub-signature-256": signature, }, body: rawBody, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); return { body: result.body, deliveryId, durationMs: Date.now() - startedAt, status: result.response.status, text: result.text, }; } async function resolveOrganizationId(session: DenSession): Promise { const result = await apiRequest(session, "/v1/me/orgs"); const orgs = requireRecords(requireRecord(result.body, "organization list response").orgs, "organizations"); expect(orgs).toHaveLength(1); return requireString(orgs[0]?.id, "organization id"); } function syncEvent(value: Record): SyncEvent { return { attemptCount: requireNumber(value.attemptCount, "sync event attemptCount"), eventType: requireString(value.eventType, "sync event eventType"), id: requireString(value.id, "sync event id"), nextAttemptAt: requireNullableString(value.nextAttemptAt, "sync event nextAttemptAt"), sourceRevisionRef: requireNullableString(value.sourceRevisionRef, "sync event sourceRevisionRef"), status: requireString(value.status, "sync event status"), summaryJson: requireNullableRecord(value.summaryJson, "sync event summaryJson"), }; } async function listSyncEvents(session: DenSession, orgId: string, connectorInstanceId: string): Promise { const result = await apiRequest( session, `/v1/connector-sync-events?connectorInstanceId=${encodeURIComponent(connectorInstanceId)}&limit=100`, { orgId }, ); return requireRecords(requireRecord(result.body, "sync event list").items, "sync events").map(syncEvent); } async function listVersions(session: DenSession, orgId: string, configObjectId: string): Promise[]> { const result = await apiRequest( session, `/v1/config-objects/${encodeURIComponent(configObjectId)}/versions?limit=100`, { orgId }, ); return requireRecords(requireRecord(result.body, "config object version list").items, "config object versions"); } async function listTargets(session: DenSession, orgId: string, connectorInstanceId: string): Promise[]> { const result = await apiRequest( session, `/v1/connector-instances/${encodeURIComponent(connectorInstanceId)}/targets?limit=100`, { orgId }, ); return requireRecords(requireRecord(result.body, "connector target list").items, "connector targets"); } function requireEvent(events: SyncEvent[], predicate: (event: SyncEvent) => boolean, label: string): SyncEvent { const event = events.find(predicate); if (!event) throw new Error(`${label} was missing from ${JSON.stringify(events).slice(0, 1_000)}`); return event; } function preview(value: unknown): string { const rendered = JSON.stringify(value) ?? String(value); return rendered.slice(0, 2_000); } async function pollUntil( description: string, timeoutMs: number, stepMs: number, readValue: () => Promise, accepted: (value: T) => boolean, ): Promise { const deadline = Date.now() + timeoutMs; let lastValue: T | undefined; while (Date.now() <= deadline) { lastValue = await readValue(); if (accepted(lastValue)) return lastValue; const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; await new Promise((resolve) => setTimeout(resolve, Math.min(stepMs, remainingMs))); } throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last value: ${preview(lastValue)}`); } test.skipIf(!localPlacement || !mysqlOpen)(title, async ({ evidence, place }) => { needs({}); const skillV1 = skillMarkdown("v1"); const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const privateKeyPem = String(privateKey.export({ type: "pkcs8", format: "pem" })); await using github = await startMockGithub({ installationId: INSTALLATION_ID, accountLogin: "acme-eval", repositories: githubRepositories(), repoFiles: { [ORIGINAL_REPOSITORY]: { branch: "develop", files: repositoryFiles(skillV1), }, }, }); const restoreEnvironment = installEnvironment({ GITHUB_CONNECTOR_API_BASE: github.apiUrl, GITHUB_CONNECTOR_APP_ID: "123456", GITHUB_CONNECTOR_APP_PRIVATE_KEY: privateKeyPem, GITHUB_CONNECTOR_APP_WEBHOOK_SECRET: WEBHOOK_SECRET, GITHUB_SYNC_WORKER_INTERVAL_MS: "250", GITHUB_SYNC_RETRY_BASE_MS: "500", GITHUB_SYNC_MAX_ATTEMPTS: "5", GITHUB_RECONCILE_INTERVAL_MS: "2000", GITHUB_RECONCILE_MIN_AGE_MS: "0", }); try { await using den = await server({ place }); const orgId = await resolveOrganizationId(den.admin); const selectedOrganization = await apiRequest(den.admin, "/v1/me/active-organization", { method: "POST", orgId, body: { organizationId: orgId }, }); expect(selectedOrganization.status).toBe(200); const accountResult = await apiRequest(den.admin, "/v1/connectors/github/accounts", { method: "POST", orgId, body: { installationId: INSTALLATION_ID, accountLogin: "acme-eval", accountType: "Organization", displayName: "acme-eval", }, }); expect(accountResult.status).toBe(201); const connectorAccountId = requireString(responseItem(accountResult.body, "GitHub account").id, "connector account id"); const setupResult = await apiRequest(den.admin, "/v1/connectors/github/setup", { method: "POST", orgId, body: { installationId: INSTALLATION_ID, connectorAccountId, connectorInstanceName: "Probe plugins", repositoryId: REPOSITORY_ID, repositoryFullName: ORIGINAL_REPOSITORY, branch: "develop", ref: "refs/heads/develop", mappings: [], }, }); expect([200, 201]).toContain(setupResult.status); const connectorInstanceId = requireString( requireRecord(responseItem(setupResult.body, "GitHub setup").connectorInstance, "setup connector instance").id, "connector instance id", ); const discoveryResult = await apiRequest( den.admin, `/v1/connector-instances/${encodeURIComponent(connectorInstanceId)}/discovery`, { orgId }, ); const discoveredPlugins = requireRecords( responseItem(discoveryResult.body, "GitHub discovery").discoveredPlugins, "discovered plugins", ); expect(discoveredPlugins).toHaveLength(1); const selectedKey = requireString(discoveredPlugins[0]?.key, "discovered plugin key"); const applyResult = await apiRequest( den.admin, `/v1/connector-instances/${encodeURIComponent(connectorInstanceId)}/discovery/apply`, { method: "POST", orgId, body: { autoImportNewPlugins: true, selectedKeys: [selectedKey] }, }, ); expect(applyResult.status).toBe(200); const materializedObjects = requireRecords( responseItem(applyResult.body, "discovery apply").materializedConfigObjects, "materialized config objects", ); expect(materializedObjects).toHaveLength(1); const configObjectId = requireString(materializedObjects[0]?.id, "materialized config object id"); const initialVersions = await listVersions(den.admin, orgId, configObjectId); expect(initialVersions).toHaveLength(1); expect(initialVersions[0]?.rawSourceText).toBe(skillV1.trim()); let deliverySequence = 0; const deliverWebhook = (event: "push" | "repository", payload: Record): Promise => { deliverySequence += 1; return webhookRequest(den.admin, event, `self-healing-${deliverySequence}`, payload); }; const deliverPush = (repositoryFullName: string, headSha: string): Promise => deliverWebhook("push", { ref: "refs/heads/develop", after: headSha, repository: { id: REPOSITORY_ID, full_name: repositoryFullName }, installation: { id: INSTALLATION_ID }, }); // Frame 1 — Push → auto-update, with an intentionally slow provider read. const skillV2 = skillMarkdown("v2"); const revisionV2 = github.advanceHead(ORIGINAL_REPOSITORY, repositoryFiles(skillV2)); github.injectFaults({ count: 1, status: 200, pathIncludes: "/contents/", delayMs: 1_500, }); const pushV2Ack = await deliverPush(ORIGINAL_REPOSITORY, revisionV2.headSha); const pushV2AckBody = requireRecord(pushV2Ack.body, "v2 push acknowledgement"); expect(pushV2Ack.status).toBe(202); expect(pushV2AckBody).toMatchObject({ ok: true, accepted: true, queued: true }); expect(pushV2Ack.durationMs).toBeLessThan(1_500); const eventsAfterV2 = await pollUntil( `completed push event for ${revisionV2.headSha}`, 60_000, 250, async () => (await listSyncEvents(den.admin, orgId, connectorInstanceId)).filter((event) => ( event.eventType === "push" && event.sourceRevisionRef === revisionV2.headSha )), (events) => events.some((event) => ( event.status === "completed" )), ); const eventV2 = requireEvent( eventsAfterV2, (event) => event.eventType === "push" && event.sourceRevisionRef === revisionV2.headSha, "v2 push event", ); const versionsAfterV2 = await listVersions(den.admin, orgId, configObjectId); expect(versionsAfterV2[0]?.rawSourceText).toBe(skillV2.trim()); expect(eventV2.sourceRevisionRef).toBe(revisionV2.headSha); evidence.recordAssertionEvidence( "A push is acknowledged before processing and auto-updates the exact commit", `Delivery ${pushV2Ack.deliveryId} returned HTTP ${pushV2Ack.status} with queued=true in ${pushV2Ack.durationMs}ms, before the 1500ms content read; event ${eventV2.id} completed at ${eventV2.sourceRevisionRef} and stored v2.`, pushV2Ack.status === 202 && pushV2AckBody.queued === true && pushV2Ack.durationMs < 1_500 && eventV2.status === "completed" && eventV2.sourceRevisionRef === revisionV2.headSha && versionsAfterV2[0]?.rawSourceText === skillV2.trim(), ); // Frame 2 — Two rate limits are retried by the worker. github.injectFaults({ count: 2, status: 429, retryAfterSeconds: 1 }); const skillV3 = skillMarkdown("v3"); const revisionV3 = github.advanceHead(ORIGINAL_REPOSITORY, repositoryFiles(skillV3)); const pushV3Ack = await deliverPush(ORIGINAL_REPOSITORY, revisionV3.headSha); expect(pushV3Ack.status).toBe(202); const eventsAfterV3 = await pollUntil( `retried and completed push event for ${revisionV3.headSha}`, 60_000, 250, () => listSyncEvents(den.admin, orgId, connectorInstanceId), (events) => events.some((event) => ( event.eventType === "push" && event.sourceRevisionRef === revisionV3.headSha && event.status === "completed" )), ); const eventV3 = requireEvent( eventsAfterV3, (event) => event.eventType === "push" && event.sourceRevisionRef === revisionV3.headSha, "v3 push event", ); const versionsAfterV3 = await listVersions(den.admin, orgId, configObjectId); const eventV3NotFailed = eventV3.status !== "failed"; expect(eventV3.attemptCount).toBeGreaterThanOrEqual(1); expect(eventV3NotFailed).toBe(true); expect(eventV3.status).toBe("completed"); expect(versionsAfterV3[0]?.rawSourceText).toBe(skillV3.trim()); evidence.recordAssertionEvidence( "A rate-limited push retries itself instead of ending failed", `The v3 event completed with attemptCount=${eventV3.attemptCount} after two provider 429s; final status=${eventV3.status}, never failed, and the latest source is v3.`, eventV3.attemptCount >= 1 && eventV3.status === "completed" && eventV3NotFailed && versionsAfterV3[0]?.rawSourceText === skillV3.trim(), ); // Frame 3 — The v4 delivery arrives after the provider head has already moved to v5. const skillV4 = skillMarkdown("v4"); const revisionV4 = github.advanceHead(ORIGINAL_REPOSITORY, repositoryFiles(skillV4)); const skillV5 = skillMarkdown("v5"); const revisionV5 = github.advanceHead(ORIGINAL_REPOSITORY, repositoryFiles(skillV5)); const pushV4Ack = await deliverPush(ORIGINAL_REPOSITORY, revisionV4.headSha); const pushV5Ack = await deliverPush(ORIGINAL_REPOSITORY, revisionV5.headSha); expect(pushV4Ack.status).toBe(202); expect(pushV5Ack.status).toBe(202); const eventsAfterV5 = await pollUntil( "both back-to-back push events to become terminal", 60_000, 250, () => listSyncEvents(den.admin, orgId, connectorInstanceId), (events) => [revisionV4.headSha, revisionV5.headSha].every((revision) => ( events.some((event) => event.eventType === "push" && event.sourceRevisionRef === revision && TERMINAL_STATUSES.has(event.status)) )), ); const eventV4 = requireEvent(eventsAfterV5, (event) => event.sourceRevisionRef === revisionV4.headSha, "v4 push event"); const eventV5 = requireEvent(eventsAfterV5, (event) => event.sourceRevisionRef === revisionV5.headSha, "v5 push event"); const versionsAfterV5 = await listVersions(den.admin, orgId, configObjectId); expect(eventV4.status).not.toBe("failed"); expect(eventV5.status).not.toBe("failed"); expect(versionsAfterV5[0]?.rawSourceText).toBe(skillV5.trim()); evidence.recordAssertionEvidence( "Back-to-back pushes are serialized so the newest branch head wins", `The v4 event ended ${eventV4.status}, the v5 event ended ${eventV5.status}, and the newest version-list entry is v5; the older event did not overwrite it.`, TERMINAL_STATUSES.has(eventV4.status) && TERMINAL_STATUSES.has(eventV5.status) && eventV4.status !== "failed" && eventV5.status !== "failed" && versionsAfterV5[0]?.rawSourceText === skillV5.trim(), ); // Frame 4 — Rename the provider repository while keeping stable id 42. github.renameRepository(ORIGINAL_REPOSITORY, RENAMED_REPOSITORY); const renameAck = await deliverWebhook("repository", { action: "renamed", repository: { id: REPOSITORY_ID, full_name: RENAMED_REPOSITORY }, installation: { id: INSTALLATION_ID }, changes: { repository: { name: { from: "probe-plugins" } } }, }); expect(renameAck.status).toBe(202); const renamedTargets = await pollUntil( `connector target remoteId to become ${RENAMED_REPOSITORY}`, 60_000, 250, () => listTargets(den.admin, orgId, connectorInstanceId), (targets) => targets.some((target) => target.remoteId === RENAMED_REPOSITORY), ); const targetRenamed = renamedTargets.some((target) => target.remoteId === RENAMED_REPOSITORY); expect(targetRenamed).toBe(true); const skillV6 = skillMarkdown("v6"); const revisionV6 = github.advanceHead(RENAMED_REPOSITORY, repositoryFiles(skillV6)); const pushV6Ack = await deliverPush(RENAMED_REPOSITORY, revisionV6.headSha); expect(pushV6Ack.status).toBe(202); const eventsAfterV6 = await pollUntil( `completed renamed-repository push event for ${revisionV6.headSha}`, 60_000, 250, () => listSyncEvents(den.admin, orgId, connectorInstanceId), (events) => events.some((event) => event.eventType === "push" && event.sourceRevisionRef === revisionV6.headSha && event.status === "completed"), ); const eventV6 = requireEvent(eventsAfterV6, (event) => event.sourceRevisionRef === revisionV6.headSha, "v6 push event"); const versionsAfterV6 = await listVersions(den.admin, orgId, configObjectId); expect(versionsAfterV6[0]?.rawSourceText).toBe(skillV6.trim()); evidence.recordAssertionEvidence( "GitHub sync survives a repository rename and its next push", `Stable repository id ${REPOSITORY_ID} moved the target remoteId to ${RENAMED_REPOSITORY}; the next event ${eventV6.id} completed and stored v6, covering the exact rename failure reported in #3521.`, targetRenamed && eventV6.status === "completed" && versionsAfterV6[0]?.rawSourceText === skillV6.trim(), ); // Frame 5 — Move the provider head without delivering any webhook. const eventsBeforeLostWebhook = await listSyncEvents(den.admin, orgId, connectorInstanceId); const eventIdsBeforeLostWebhook = new Set(eventsBeforeLostWebhook.map((event) => event.id)); const skillV7 = skillMarkdown("v7"); github.advanceHead(RENAMED_REPOSITORY, repositoryFiles(skillV7)); const healedState = await pollUntil( "reconciliation to complete a new manual_resync and store v7 without a webhook", 30_000, 250, async () => ({ events: await listSyncEvents(den.admin, orgId, connectorInstanceId), versions: await listVersions(den.admin, orgId, configObjectId), }), (state) => state.events.some((event) => ( !eventIdsBeforeLostWebhook.has(event.id) && event.eventType === "manual_resync" && event.status === "completed" )) && state.versions[0]?.rawSourceText === skillV7.trim(), ); const reconciliationEvent = requireEvent( healedState.events, (event) => !eventIdsBeforeLostWebhook.has(event.id) && event.eventType === "manual_resync" && event.status === "completed", "lost-webhook reconciliation event", ); expect(healedState.versions[0]?.rawSourceText).toBe(skillV7.trim()); evidence.recordAssertionEvidence( "Reconciliation heals branch drift when the webhook is lost", `No delivery was sent for v7; reconciliation event ${reconciliationEvent.id} completed and stored v7 without human action.`, reconciliationEvent.eventType === "manual_resync" && reconciliationEvent.status === "completed" && healedState.versions[0]?.rawSourceText === skillV7.trim(), ); // Frame 6 — Sync now uses the worker and leaves the unchanged version list alone. const eventsBeforeSyncNow = await listSyncEvents(den.admin, orgId, connectorInstanceId); const eventIdsBeforeSyncNow = new Set(eventsBeforeSyncNow.map((event) => event.id)); const versionsBeforeSyncNow = await listVersions(den.admin, orgId, configObjectId); const syncNowResult = await apiRequest( den.admin, `/v1/connector-instances/${encodeURIComponent(connectorInstanceId)}/sync-now`, { method: "POST", orgId }, ); expect(syncNowResult.status).toBe(200); const enqueuedCount = requireNumber(responseItem(syncNowResult.body, "sync now").enqueuedCount, "sync now enqueued count"); expect(enqueuedCount).toBeGreaterThanOrEqual(1); const eventsAfterSyncNow = await pollUntil( "the sync-now manual_resync event to become completed or ignored", 60_000, 250, () => listSyncEvents(den.admin, orgId, connectorInstanceId), (events) => events.some((event) => ( !eventIdsBeforeSyncNow.has(event.id) && event.eventType === "manual_resync" && (event.status === "completed" || event.status === "ignored") )), ); const syncNowEvent = requireEvent( eventsAfterSyncNow, (event) => !eventIdsBeforeSyncNow.has(event.id) && event.eventType === "manual_resync" && (event.status === "completed" || event.status === "ignored"), "sync-now event", ); const versionsAfterSyncNow = await listVersions(den.admin, orgId, configObjectId); expect(syncNowEvent.status).not.toBe("failed"); expect(versionsAfterSyncNow).toHaveLength(versionsBeforeSyncNow.length); const fullEvents = await listSyncEvents(den.admin, orgId, connectorInstanceId); const finalTargets = await listTargets(den.admin, orgId, connectorInstanceId); const hasCompletedPush = fullEvents.some((event) => event.eventType === "push" && event.status === "completed"); const hasRetriedEvent = fullEvents.some((event) => event.attemptCount >= 1); const hasRenameStory = fullEvents.some((event) => event.eventType === "repository") || finalTargets.some((target) => target.remoteId === RENAMED_REPOSITORY); const hasManualResync = fullEvents.some((event) => event.eventType === "manual_resync"); expect(hasCompletedPush).toBe(true); expect(hasRetriedEvent).toBe(true); expect(hasRenameStory).toBe(true); expect(hasManualResync).toBe(true); evidence.recordAssertionEvidence( "Sync now preserves an unchanged version and the event history tells the full self-healing story", `Sync now enqueued ${enqueuedCount} target(s), event ${syncNowEvent.id} ended ${syncNowEvent.status}, versions stayed at ${versionsAfterSyncNow.length}, and history contains a completed push, a retried event, rename continuity, and manual reconciliation.`, enqueuedCount >= 1 && syncNowEvent.status !== "failed" && versionsAfterSyncNow.length === versionsBeforeSyncNow.length && hasCompletedPush && hasRetriedEvent && hasRenameStory && hasManualResync, ); } finally { restoreEnvironment(); } });