···11+/**
22+ * Derive a user-facing health summary for a repo mapping.
33+ *
44+ * The dashboard needs to answer three questions for someone who isn't in the
55+ * codebase: is this repo fine, is it still catching up, or is it stuck (and if
66+ * so, what, roughly, do I do about it)? The raw `status` / `lastError` columns
77+ * carry protocol-level detail that means nothing to a user, so we map them to a
88+ * small, stable set of states plus a plain-English message. The raw error is
99+ * kept alongside for the "copy this when reporting" path.
1010+ */
1111+1212+export type RepoHealthState =
1313+ /** Enrolled and at least one ref has synced. Nothing to do. */
1414+ | 'ok'
1515+ /** Enrolled but nothing has synced yet: backfill/first push still in flight. */
1616+ | 'waiting'
1717+ /** Not yet mirrored on tangled (enrolment hasn't completed). */
1818+ | 'enrolling'
1919+ /** User paused sync from the dashboard. */
2020+ | 'paused'
2121+ /** Informational, not a failure (e.g. renamed on GitHub). */
2222+ | 'info'
2323+ /** Sync is stuck and won't recover on its own without action. */
2424+ | 'error'
2525+2626+export interface RepoHealth {
2727+ state: RepoHealthState
2828+ /** One short sentence a non-technical user can act on. */
2929+ message: string
3030+ /** Whether this repo should be surfaced in the "needs attention" summary. */
3131+ needsAttention: boolean
3232+}
3333+3434+export interface RepoHealthInput {
3535+ status: string
3636+ lastError: string | null
3737+ disabledAt: string | null
3838+ tangledRepoDid: string | null
3939+ refCount: number
4040+}
4141+4242+/**
4343+ * Map a known internal error string to a plain-English explanation. Returns
4444+ * `null` for anything unrecognised so the caller can fall back to a generic
4545+ * message while still exposing the raw text for reporting.
4646+ *
4747+ * Keyed on substrings of the errors we actually emit (see `sync-push`,
4848+ * `repo-mapping`, `receive-pack`, `tangled-repo`); keep in sync when new
4949+ * terminal messages are added.
5050+ */
5151+export function explainRepoError(raw: string): string | null {
5252+ const err = raw.toLowerCase()
5353+5454+ if (err.includes('sufficient access permissions') || err.includes('accesscontrol')) {
5555+ return 'Tangled rejected the mirror, usually because a repo with this name already exists on your account. Rename or remove the existing one, then resync.'
5656+ }
5757+ if (err.includes('invalid path sequence')) {
5858+ return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.'
5959+ }
6060+ if (err.includes('repository access blocked') || err.includes('repo no longer exists')) {
6161+ return 'The mirror on tangled is no longer reachable. Try a resync; if it persists, let us know.'
6262+ }
6363+ if (err.includes('rejected our ssh key') || err.includes('auth-rejected') || err.includes('authentication methods failed')) {
6464+ return 'Tangled rejected our SSH key. Rotating the key from this dashboard usually fixes it.'
6565+ }
6666+ if (err.includes('exceeded') && (err.includes('bytes') || err.includes('size limit'))) {
6767+ return 'A push was larger than the size limit and couldn\'t be mirrored.'
6868+ }
6969+ if (err.includes('pack signature mismatch') || err.includes('handshake') || err.includes('no advertisement') || err.includes('connection lost')) {
7070+ return 'A transient connection problem with tangled interrupted the sync. It will retry automatically; resync to hurry it along.'
7171+ }
7272+ return null
7373+}
7474+7575+export function repoHealth(input: RepoHealthInput): RepoHealth {
7676+ if (input.disabledAt) {
7777+ return { state: 'paused', message: 'Sync is paused. Re-enable to resume mirroring.', needsAttention: false }
7878+ }
7979+8080+ // Rename notices (and any future advisory) are prefixed `info:` by the
8181+ // webhook handler so they can ride the `lastError` column without a schema
8282+ // change. They are not failures.
8383+ if (input.lastError?.startsWith('info:')) {
8484+ return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false }
8585+ }
8686+8787+ if (input.status === 'error') {
8888+ const explained = input.lastError ? explainRepoError(input.lastError) : null
8989+ return {
9090+ state: 'error',
9191+ message: explained ?? 'Sync is stuck. Try a resync; if it keeps failing, report it with the details below.',
9292+ needsAttention: true,
9393+ }
9494+ }
9595+9696+ if (!input.tangledRepoDid) {
9797+ return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false }
9898+ }
9999+100100+ if (input.refCount === 0) {
101101+ return { state: 'waiting', message: 'Enrolled, waiting for the first sync. New installs backfill in the background.', needsAttention: false }
102102+ }
103103+104104+ return { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false }
105105+}
+75
test/unit/repo-health.spec.ts
···11+import { describe, expect, it } from 'vitest'
22+import { explainRepoError, repoHealth, type RepoHealthInput } from '../../server/utils/repo-health'
33+44+function input(overrides: Partial<RepoHealthInput> = {}): RepoHealthInput {
55+ return {
66+ status: 'active',
77+ lastError: null,
88+ disabledAt: null,
99+ tangledRepoDid: 'did:plc:repo',
1010+ refCount: 1,
1111+ ...overrides,
1212+ }
1313+}
1414+1515+describe('repoHealth', () => {
1616+ it('reports ok for an active mapping with synced refs', () => {
1717+ const h = repoHealth(input())
1818+ expect(h.state).toBe('ok')
1919+ expect(h.needsAttention).toBe(false)
2020+ })
2121+2222+ it('reports waiting for an enrolled mapping that has synced nothing yet', () => {
2323+ const h = repoHealth(input({ refCount: 0 }))
2424+ expect(h.state).toBe('waiting')
2525+ expect(h.needsAttention).toBe(false)
2626+ })
2727+2828+ it('reports enrolling before the tangled repo exists', () => {
2929+ const h = repoHealth(input({ tangledRepoDid: null, refCount: 0 }))
3030+ expect(h.state).toBe('enrolling')
3131+ })
3232+3333+ it('reports paused when disabled, regardless of status', () => {
3434+ const h = repoHealth(input({ disabledAt: new Date().toISOString(), status: 'error' }))
3535+ expect(h.state).toBe('paused')
3636+ expect(h.needsAttention).toBe(false)
3737+ })
3838+3939+ it('treats info-prefixed messages as advisory, not errors', () => {
4040+ const h = repoHealth(input({ lastError: 'info: renamed on github from a/b to a/c; tangled mirror name unchanged' }))
4141+ expect(h.state).toBe('info')
4242+ expect(h.needsAttention).toBe(false)
4343+ expect(h.message).toBe('renamed on github from a/b to a/c; tangled mirror name unchanged')
4444+ })
4545+4646+ it('flags error status as needing attention with a human-readable message', () => {
4747+ const h = repoHealth(input({
4848+ status: 'error',
4949+ lastError: 'knot knot1.tangled.sh returned 400: {"error":"AccessControl","message":"DID does not have sufficient access permissions for this operation"}',
5050+ }))
5151+ expect(h.state).toBe('error')
5252+ expect(h.needsAttention).toBe(true)
5353+ expect(h.message).toMatch(/already exists/i)
5454+ })
5555+5656+ it('falls back to a generic message for an unrecognised error', () => {
5757+ const h = repoHealth(input({ status: 'error', lastError: 'something we have never seen' }))
5858+ expect(h.state).toBe('error')
5959+ expect(h.message).toMatch(/report it/i)
6060+ })
6161+})
6262+6363+describe('explainRepoError', () => {
6464+ it('maps the production error strings we actually emit', () => {
6565+ expect(explainRepoError('receive-pack: unpack error (pack signature mismatch detected)')).toMatch(/transient connection/i)
6666+ expect(explainRepoError('receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)')).toMatch(/transient connection/i)
6767+ expect(explainRepoError('knot rejected our ssh key; stopping sync')).toMatch(/ssh key/i)
6868+ expect(explainRepoError('Repository name contains invalid path sequence')).toMatch(/can.t be mirrored/i)
6969+ expect(explainRepoError('pack exceeded the configured size limit')).toMatch(/size limit/i)
7070+ })
7171+7272+ it('returns null for anything unrecognised', () => {
7373+ expect(explainRepoError('totally novel failure')).toBeNull()
7474+ })
7575+})