From e4e3e886995052e32a768027f24b898cc62a547b Mon Sep 17 00:00:00 2001 From: coji Date: Wed, 8 Apr 2026 00:27:55 +0900 Subject: [PATCH] feat(repo-ui): broken repository recovery (#283 PR 5/7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX 哲学: 通常時は repository 一覧/詳細に installation 名を出さない (PR 4 の Vercel 哲学を継承)。canonical installation を失って `github_installation_id IS NULL` になった broken state の repository だけを救済導線として可視化する。 repositories list (settings/repositories): - github_app モードかつ github_installation_id IS NULL の repository に "Needs reconnection" バッジ + tooltip - 1-click "Reassign" ボタンで canonical reassignment helper を再実行 - 候補 1 件 → 自動 reassign + バッジ消失 (success toast) - 候補 0 件 → "Reinstall the GitHub App and try again" (error toast) - 候補 2+ 件 → "Disconnect unwanted installations to resolve" (error toast) - loader が getIntegration().method を返し、columns が github_app モードのみバッジを出す mutation (app/services/github-app-membership.server.ts): - reassignBrokenRepository(orgId, repositoryId, source) helper を追加 - canonical reassignment helper と同じ eligibility ルール - 結果を { reassigned | no_candidates | ambiguous | not_broken } の discriminated union で返す - tenant first / shared second + audit log route mutation wrapper (settings/repositories._index/mutations.server.ts): - reassignBrokenRepositoryFromUI() で source='manual_reassign' を固定して呼ぶ batch CLI (batch/commands/reassign-broken-repositories.ts): - batch/cli.ts に `reassign-broken-repositories [--repository ]` コマンド追加 - 全 broken repo を順次救済、結果を集計表示 - source='cli_repair' tests (github-app-membership.server.test.ts): - reassignBrokenRepository の 6 ケース追加 (1/0/2+ 候補, not_broken, suspended/uninitialized link 除外) Co-Authored-By: Claude Opus 4.6 (1M context) --- app/libs/github-account.ts | 12 ++ .../+components/repo-columns.tsx | 66 +++++-- .../settings/repositories._index/index.tsx | 89 +++++++-- .../github-app-membership.server.test.ts | 179 +++++++++++++++++- app/services/github-app-membership.server.ts | 133 +++++++++++-- batch/cli.ts | 35 +++- .../commands/reassign-broken-repositories.ts | 84 ++++++++ 7 files changed, 558 insertions(+), 40 deletions(-) create mode 100644 batch/commands/reassign-broken-repositories.ts diff --git a/app/libs/github-account.ts b/app/libs/github-account.ts index 7a6b4810..ccbc023f 100644 --- a/app/libs/github-account.ts +++ b/app/libs/github-account.ts @@ -30,3 +30,15 @@ export const buildInstallationSettingsUrl = ( isPersonalAccount(link) ? `https://github.com/settings/installations/${link.installationId}` : `https://github.com/organizations/${encodeURIComponent(link.githubOrg)}/settings/installations` + +/** + * Predicate for the "broken repository" state: a repository whose canonical + * GitHub App installation was lost (e.g. the installation was uninstalled and + * canonical reassignment found 0 or 2+ candidates). Shared between the + * repositories list UI and the batch CLI command. + */ +export const isRepositoryBroken = ( + repo: { githubInstallationId: number | null }, + integrationMethod: 'token' | 'github_app' | null, +): boolean => + integrationMethod === 'github_app' && repo.githubInstallationId === null diff --git a/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx b/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx index bc5ba2a5..a99bcdc0 100644 --- a/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx +++ b/app/routes/$orgSlug/settings/repositories._index/+components/repo-columns.tsx @@ -1,5 +1,5 @@ import type { ColumnDef } from '@tanstack/react-table' -import { ExternalLinkIcon } from 'lucide-react' +import { AlertTriangleIcon, ExternalLinkIcon } from 'lucide-react' import { Link, useFetcher } from 'react-router' import { match } from 'ts-pattern' import { @@ -8,14 +8,53 @@ import { SelectItem, SelectTrigger, SelectValue, + Tooltip, + TooltipContent, + TooltipTrigger, } from '~/app/components/ui' import { Badge } from '~/app/components/ui/badge' +import { Button } from '~/app/components/ui/button' import { Checkbox } from '~/app/components/ui/checkbox' +import { isRepositoryBroken } from '~/app/libs/github-account' import type { TeamRow } from '../../teams._index/queries.server' import type { RepositoryRow } from '../queries.server' import { DataTableColumnHeader } from './data-table-column-header' import { RepoRowActions } from './repo-row-actions' +function NeedsReconnectionBadge({ repositoryId }: { repositoryId: string }) { + const fetcher = useFetcher() + const isReassigning = fetcher.state !== 'idle' + return ( + + + + + + Needs reconnection + + + + The GitHub App installation that owned this repository was removed. + Click Reassign to try another active installation, or reinstall the + GitHub App. + + + + + + + + + ) +} + function TeamSelect({ repositoryId, currentTeamId, @@ -56,6 +95,7 @@ function TeamSelect({ export const createColumns = ( orgSlug: string, teams: TeamRow[], + integrationMethod: 'token' | 'github_app' | null, ): ColumnDef[] => [ { id: 'select', @@ -86,20 +126,24 @@ export const createColumns = ( ), cell: ({ row }) => { - const { owner, repo, provider } = row.original + const { id, owner, repo, provider } = row.original const repoUrl = match(provider) .with('github', () => `https://github.com/${owner}/${repo}`) .otherwise(() => '') + const isBroken = isRepositoryBroken(row.original, integrationMethod) return ( - - {owner}/{repo} - - + + + {owner}/{repo} + + + {isBroken && } + ) }, enableHiding: false, diff --git a/app/routes/$orgSlug/settings/repositories._index/index.tsx b/app/routes/$orgSlug/settings/repositories._index/index.tsx index d0ccb091..bf22761a 100644 --- a/app/routes/$orgSlug/settings/repositories._index/index.tsx +++ b/app/routes/$orgSlug/settings/repositories._index/index.tsx @@ -7,6 +7,8 @@ import { z } from 'zod' import { isOrgOwner } from '~/app/libs/auth.server' import { getErrorMessage } from '~/app/libs/error-message' import { orgContext } from '~/app/middleware/context' +import { reassignBrokenRepository } from '~/app/services/github-app-membership.server' +import { getIntegration } from '~/app/services/github-integration-queries.server' import ContentSection from '../+components/content-section' import { listTeams } from '../teams._index/queries.server' import { createColumns } from './+components/repo-columns' @@ -42,17 +44,27 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { per_page: searchParams.get('per_page'), }) - const { data: repositories, pagination } = await listFilteredRepositories({ - organizationId: organization.id, - repo, - teamId: team || undefined, - currentPage, - pageSize, - sortBy, - sortOrder, - }) + const [{ data: repositories, pagination }, teams, integration] = + await Promise.all([ + listFilteredRepositories({ + organizationId: organization.id, + repo, + teamId: team || undefined, + currentPage, + pageSize, + sortBy, + sortOrder, + }), + listTeams(organization.id), + getIntegration(organization.id), + ]) - const teams = await listTeams(organization.id) + const integrationMethod: 'token' | 'github_app' | null = + integration?.method === 'github_app' + ? 'github_app' + : integration?.method === 'token' + ? 'token' + : null return { organization, @@ -60,6 +72,7 @@ export const loader = async ({ request, context }: Route.LoaderArgs) => { pagination, teams, canAddRepositories: isOrgOwner(membership.role), + integrationMethod, } } @@ -80,9 +93,15 @@ const bulkUpdateTeamSchema = z.object({ teamId: nullableTeamId, }) +const reassignBrokenSchema = z.object({ + intent: z.literal('reassignBroken'), + repositoryId: z.string().min(1), +}) + const actionSchema = z.discriminatedUnion('intent', [ updateTeamSchema, bulkUpdateTeamSchema, + reassignBrokenSchema, ]) export const action = async ({ request, context }: Route.ActionArgs) => { @@ -132,6 +151,50 @@ export const action = async ({ request, context }: Route.ActionArgs) => { { message: `${repositoryIds.length}件のチームを変更しました` }, ) }) + .with({ intent: 'reassignBroken' }, async ({ repositoryId }) => { + try { + const result = await reassignBrokenRepository({ + organizationId: organization.id, + repositoryId, + source: 'manual_reassign', + }) + return match(result) + .with({ status: 'reassigned' }, () => + dataWithSuccess( + { ok: true, lastResult: null }, + { message: 'Repository reassigned to an active installation.' }, + ), + ) + .with({ status: 'no_candidates' }, () => + dataWithError( + { ok: false, lastResult: null }, + { + message: + 'No active installation can see this repository. Reinstall the GitHub App and try again.', + }, + ), + ) + .with({ status: 'ambiguous' }, ({ candidateCount }) => + dataWithError( + { ok: false, lastResult: null }, + { + message: `${candidateCount} installations can see this repository. Manual reassignment is required — disconnect the unwanted installations to resolve.`, + }, + ), + ) + .with({ status: 'not_broken' }, () => + dataWithSuccess( + { ok: true, lastResult: null }, + { message: 'Repository is already assigned.' }, + ), + ) + .exhaustive() + } catch (e) { + console.error('Failed to reassign broken repository:', e) + const message = getErrorMessage(e) + return dataWithError({ ok: false, lastResult: null }, { message }) + } + }) .exhaustive() } @@ -142,10 +205,14 @@ export default function OrganizationRepositoryIndexPage({ pagination, teams, canAddRepositories, + integrationMethod, }, }: Route.ComponentProps) { const slug = organization.slug - const columns = useMemo(() => createColumns(slug, teams), [slug, teams]) + const columns = useMemo( + () => createColumns(slug, teams, integrationMethod), + [slug, teams, integrationMethod], + ) return ( { expect(events).toHaveLength(0) }) }) + +describe('reassignBrokenRepository', () => { + const seedBrokenRepo = async () => { + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const tenantDb = getTenantDb(ORG_ID) + await tenantDb + .insertInto('repositories') + .values({ + id: REPO_ID, + integrationId: 'int-1', + provider: 'github', + owner: 'octo', + repo: 'hello', + githubInstallationId: null, + updatedAt: '2026-04-07T00:00:00Z', + }) + .execute() + } + + beforeEach(async () => { + await db.deleteFrom('githubAppLinkEvents').execute() + await db.deleteFrom('githubAppLinks').execute() + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const tenantDb = getTenantDb(ORG_ID) + await tenantDb.deleteFrom('repositoryInstallationMemberships').execute() + await tenantDb.deleteFrom('repositories').execute() + }) + + test('1 eligible candidate → reassigned + canonical_reassigned event', async () => { + await insertLink(ALT_INSTALLATION) + await seedBrokenRepo() + await insertMembership(ALT_INSTALLATION) + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + + expect(result).toEqual({ + status: 'reassigned', + installationId: ALT_INSTALLATION, + }) + + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const repo = await getTenantDb(ORG_ID) + .selectFrom('repositories') + .select('githubInstallationId') + .where('id', '=', REPO_ID) + .executeTakeFirstOrThrow() + expect(repo.githubInstallationId).toBe(ALT_INSTALLATION) + + const events = await db + .selectFrom('githubAppLinkEvents') + .selectAll() + .execute() + expect(events.map((e) => e.eventType)).toEqual(['canonical_reassigned']) + }) + + test('0 candidates → no_candidates, no audit event written', async () => { + await seedBrokenRepo() + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + + expect(result).toEqual({ status: 'no_candidates' }) + + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const repo = await getTenantDb(ORG_ID) + .selectFrom('repositories') + .select('githubInstallationId') + .where('id', '=', REPO_ID) + .executeTakeFirstOrThrow() + expect(repo.githubInstallationId).toBeNull() + + const events = await db + .selectFrom('githubAppLinkEvents') + .selectAll() + .execute() + expect(events).toHaveLength(0) + }) + + test('2+ candidates → ambiguous, no audit event written', async () => { + await insertLink(ALT_INSTALLATION) + await insertLink(SECOND_ALT_INSTALLATION) + await seedBrokenRepo() + await insertMembership(ALT_INSTALLATION) + await insertMembership(SECOND_ALT_INSTALLATION) + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + + expect(result).toEqual({ status: 'ambiguous', candidateCount: 2 }) + + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const repo = await getTenantDb(ORG_ID) + .selectFrom('repositories') + .select('githubInstallationId') + .where('id', '=', REPO_ID) + .executeTakeFirstOrThrow() + expect(repo.githubInstallationId).toBeNull() + + const events = await db + .selectFrom('githubAppLinkEvents') + .selectAll() + .execute() + expect(events).toHaveLength(0) + }) + + test('not_broken: repo already has a canonical installation → no event', async () => { + await insertLink(ALT_INSTALLATION) + const { getTenantDb } = await import('~/app/services/tenant-db.server') + const tenantDb = getTenantDb(ORG_ID) + await tenantDb + .insertInto('repositories') + .values({ + id: REPO_ID, + integrationId: 'int-1', + provider: 'github', + owner: 'octo', + repo: 'hello', + githubInstallationId: ALT_INSTALLATION, + updatedAt: '2026-04-07T00:00:00Z', + }) + .execute() + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + expect(result).toEqual({ status: 'not_broken' }) + + const events = await db + .selectFrom('githubAppLinkEvents') + .selectAll() + .execute() + expect(events).toHaveLength(0) + }) + + test('suspended link is excluded from candidates', async () => { + await insertLink(ALT_INSTALLATION, { + suspendedAt: '2026-04-07T00:00:00Z', + }) + await seedBrokenRepo() + await insertMembership(ALT_INSTALLATION) + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + expect(result).toEqual({ status: 'no_candidates' }) + }) + + test('uninitialized link is excluded from candidates', async () => { + await insertLink(ALT_INSTALLATION, { membershipInitializedAt: null }) + await seedBrokenRepo() + await insertMembership(ALT_INSTALLATION) + + const result = await reassignBrokenRepository({ + organizationId: ORG_ID, + repositoryId: REPO_ID, + source: 'manual_reassign', + }) + expect(result).toEqual({ status: 'no_candidates' }) + }) +}) diff --git a/app/services/github-app-membership.server.ts b/app/services/github-app-membership.server.ts index a16e1e34..d4b5399c 100644 --- a/app/services/github-app-membership.server.ts +++ b/app/services/github-app-membership.server.ts @@ -1,6 +1,9 @@ import { db } from '~/app/services/db.server' import type { GithubAppLinkEventSource } from '~/app/services/github-app-link-events.server' -import { tryLogGithubAppLinkEvent } from '~/app/services/github-app-link-events.server' +import { + logGithubAppLinkEvent, + tryLogGithubAppLinkEvent, +} from '~/app/services/github-app-link-events.server' import { getTenantDb } from '~/app/services/tenant-db.server' import type { OrganizationId } from '~/app/types/organization' @@ -13,6 +16,115 @@ export type ReassignmentSource = Extract< | 'manual_reassign' > +/** + * Active GitHub App installation ids that are eligible to receive a canonical + * reassignment for a repository: not deleted, not suspended, and with their + * `repository_installation_memberships` initialized. + */ +async function fetchEligibleInstallationIds( + organizationId: OrganizationId, + options: { excludeInstallationId?: number } = {}, +): Promise<{ ids: Set; hasUninitializedLink: boolean }> { + let linkQuery = db + .selectFrom('githubAppLinks') + .select(['installationId', 'suspendedAt', 'membershipInitializedAt']) + .where('organizationId', '=', organizationId) + .where('deletedAt', 'is', null) + if (options.excludeInstallationId !== undefined) { + linkQuery = linkQuery.where( + 'installationId', + '!=', + options.excludeInstallationId, + ) + } + const links = await linkQuery.execute() + const ids = new Set( + links + .filter((l) => !l.suspendedAt && l.membershipInitializedAt !== null) + .map((l) => l.installationId), + ) + const hasUninitializedLink = links.some( + (l) => l.membershipInitializedAt === null, + ) + return { ids, hasUninitializedLink } +} + +export type ReassignBrokenRepositoryResult = + | { status: 'reassigned'; installationId: number } + | { status: 'no_candidates' } + | { status: 'ambiguous'; candidateCount: number } + | { status: 'not_broken' } + +/** + * Try to assign a canonical installation to a single repository whose + * `github_installation_id` is currently `NULL`. Used by the "Try auto-reassign" + * UI button and the `reassign-repository-installation` CLI command. + * + * Eligibility rules match {@link reassignCanonicalAfterLinkLoss}: candidate + * link must be active, non-suspended, and have `membership_initialized_at` set; + * membership row must be active. + * + * Returns a discriminated result so callers can show the appropriate UI: + * - `reassigned`: a single eligible candidate was found, repo is now fixed + * - `no_candidates`: no installation can see this repo; user must reinstall + * - `ambiguous`: 2+ candidates, manual choice needed + * - `not_broken`: repository already has a `github_installation_id` set + */ +export async function reassignBrokenRepository(input: { + organizationId: OrganizationId + repositoryId: string + source: Extract +}): Promise { + const { organizationId, repositoryId, source } = input + const tenantDb = getTenantDb(organizationId) + + const repo = await tenantDb + .selectFrom('repositories') + .select(['id', 'githubInstallationId']) + .where('id', '=', repositoryId) + .executeTakeFirst() + if (!repo) return { status: 'not_broken' } + if (repo.githubInstallationId !== null) return { status: 'not_broken' } + + const { ids: eligibleSet } = + await fetchEligibleInstallationIds(organizationId) + + const memberships = await tenantDb + .selectFrom('repositoryInstallationMemberships') + .select(['installationId']) + .where('repositoryId', '=', repositoryId) + .where('deletedAt', 'is', null) + .execute() + const candidates = memberships + .map((m) => m.installationId) + .filter((id) => eligibleSet.has(id)) + + if (candidates.length === 1) { + const nextCanonical = candidates[0] + await tenantDb + .updateTable('repositories') + .set({ githubInstallationId: nextCanonical }) + .where('id', '=', repositoryId) + .execute() + await logGithubAppLinkEvent({ + organizationId, + installationId: nextCanonical, + eventType: 'canonical_reassigned', + source, + status: 'success', + details: { repositoryId, candidateCount: 1, recoveredFromBroken: true }, + }) + return { status: 'reassigned', installationId: nextCanonical } + } + + // Skip the audit log entry for the no-candidates / ambiguous cases: there is + // no installation to attribute the event to (and the audit table requires a + // non-null `installationId`). The function return value already conveys the + // outcome to the UI / CLI caller, which surfaces it via toast / console. + if (candidates.length === 0) return { status: 'no_candidates' } + return { status: 'ambiguous', candidateCount: candidates.length } +} + /** * Replace `repository.github_installation_id` when a link is lost. By default * operates on every repository whose canonical is still `lostInstallationId` @@ -46,21 +158,10 @@ export async function reassignCanonicalAfterLinkLoss(input: { }): Promise { const { organizationId, lostInstallationId, source, repositoryIds } = input - const links = await db - .selectFrom('githubAppLinks') - .select(['installationId', 'suspendedAt', 'membershipInitializedAt']) - .where('organizationId', '=', organizationId) - .where('deletedAt', 'is', null) - .where('installationId', '!=', lostInstallationId) - .execute() - const eligibleSet = new Set( - links - .filter((l) => !l.suspendedAt && l.membershipInitializedAt !== null) - .map((l) => l.installationId), - ) - const hasUninitializedLink = links.some( - (l) => l.membershipInitializedAt === null, - ) + const { ids: eligibleSet, hasUninitializedLink } = + await fetchEligibleInstallationIds(organizationId, { + excludeInstallationId: lostInstallationId, + }) const tenantDb = getTenantDb(organizationId) diff --git a/batch/cli.ts b/batch/cli.ts index 9d06bbe0..f9ad2c13 100644 --- a/batch/cli.ts +++ b/batch/cli.ts @@ -145,6 +145,32 @@ const report = command( }, ) +const reassignBrokenRepositories = command( + { + name: 'reassign-broken-repositories', + parameters: ['[organization id]'], + flags: { + repository: { + type: String, + description: + 'Reassign a single repository by id (default: every broken repo)', + }, + }, + help: { + description: + 'Reassign repositories whose canonical GitHub App installation was lost. Picks a new canonical from the membership table when exactly one eligible candidate exists.', + }, + }, + async (argv) => { + const { reassignBrokenRepositoriesCommand } = + await import('./commands/reassign-broken-repositories') + await reassignBrokenRepositoriesCommand({ + organizationId: argv._.organizationId, + repositoryId: argv.flags.repository, + }) + }, +) + process.on('unhandledRejection', async (error) => { captureExceptionToSentry(error, { tags: { component: 'batch-cli' } }) await flushSentryNode() @@ -152,5 +178,12 @@ process.on('unhandledRejection', async (error) => { }) cli({ - commands: [crawl, processCmd, classify, backfill, report], + commands: [ + crawl, + processCmd, + classify, + backfill, + report, + reassignBrokenRepositories, + ], }) diff --git a/batch/commands/reassign-broken-repositories.ts b/batch/commands/reassign-broken-repositories.ts new file mode 100644 index 00000000..d1eb78f9 --- /dev/null +++ b/batch/commands/reassign-broken-repositories.ts @@ -0,0 +1,84 @@ +import consola from 'consola' +import { match } from 'ts-pattern' +import { reassignBrokenRepository } from '~/app/services/github-app-membership.server' +import { getTenantDb } from '~/app/services/tenant-db.server' +import { requireOrganization } from './helpers' +import { shutdown } from './shutdown' + +interface ReassignBrokenRepositoriesCommandProps { + organizationId?: string + repositoryId?: string +} + +/** + * Operator command to recover repositories whose canonical + * `github_installation_id` was lost. Walks every broken repository in the org + * (or a single one if `repositoryId` is given) and asks + * {@link reassignBrokenRepository} to assign a new canonical from the + * membership table. + */ +export async function reassignBrokenRepositoriesCommand( + props: ReassignBrokenRepositoriesCommandProps, +) { + const result = await requireOrganization(props.organizationId) + if (!result) return + + const { orgId } = result + const tenantDb = getTenantDb(orgId) + + try { + let query = tenantDb + .selectFrom('repositories') + .select(['id', 'owner', 'repo']) + .where('githubInstallationId', 'is', null) + if (props.repositoryId) { + query = query.where('id', '=', props.repositoryId) + } + const broken = await query.execute() + + if (broken.length === 0) { + consola.info('No broken repositories found.') + return + } + consola.info(`Found ${broken.length} broken repositories. Reassigning...`) + + const counts = { + reassigned: 0, + no_candidates: 0, + ambiguous: 0, + not_broken: 0, + } + for (const repo of broken) { + const label = `${repo.owner}/${repo.repo}` + try { + const outcome = await reassignBrokenRepository({ + organizationId: orgId, + repositoryId: repo.id, + source: 'cli_repair', + }) + counts[outcome.status]++ + match(outcome) + .with({ status: 'reassigned' }, ({ installationId }) => + consola.success(`${label} → installation ${installationId}`), + ) + .with({ status: 'ambiguous' }, ({ candidateCount }) => + consola.warn( + `${label}: ${candidateCount} candidates, manual disconnect required`, + ), + ) + .with({ status: 'no_candidates' }, () => + consola.warn(`${label}: no active installation can see this repo`), + ) + .with({ status: 'not_broken' }, () => + consola.info(`${label}: already assigned`), + ) + .exhaustive() + } catch (e) { + consola.error(`${label}:`, e) + } + } + consola.info('Summary:', counts) + } finally { + await shutdown() + } +}