···183183// with a long repo list sees the problems without scrolling.
184184const HEALTH_ORDER: Record<string, number> = {
185185 error: 0,
186186- enrolling: 1,
187187- info: 2,
188188- paused: 3,
189189- ok: 4,
186186+ behind: 1,
187187+ enrolling: 2,
188188+ info: 3,
189189+ paused: 4,
190190+ ok: 5,
190191}
191192const sortedRepos = computed<DashboardRepo[]>(() => {
192193 const list = data.value?.repos ?? []
···361362 <template v-if="data.summary.enrolling > 0">{{ data.summary.enrolling }} still setting up.</template>
362363 </span>
363364 </div>
364364- <div v-else-if="data.summary.enrolling > 0" class="summary" role="status">
365365+ <div v-else-if="data.summary.behind > 0 || data.summary.enrolling > 0" class="summary" role="status">
365366 <span aria-hidden="true">◐</span>
366366- <span>{{ data.summary.enrolling }} {{ data.summary.enrolling === 1 ? 'repository is' : 'repositories are' }} still setting up on tangled. This is normal right after installing.</span>
367367+ <span>
368368+ <template v-if="data.summary.behind > 0">{{ data.summary.behind }} {{ data.summary.behind === 1 ? 'repository is' : 'repositories are' }} catching up. </template>
369369+ <template v-if="data.summary.enrolling > 0">{{ data.summary.enrolling }} still setting up on tangled. </template>
370370+ This clears on its own.
371371+ </span>
367372 </div>
368373 <div v-else-if="data.summary.total > 0" class="summary summary--ok" role="status">
369374 <span aria-hidden="true">●</span>
···849854850855.badge-ok { border-color: var(--color-ok); color: var(--color-ok); }
851856.badge-ok::before { content: "●"; }
857857+.badge-behind,
852858.badge-enrolling { border-color: var(--color-warn); color: var(--color-warn); }
859859+.badge-behind::before,
853860.badge-enrolling::before { content: "◐"; }
854861.badge-info { border-color: var(--color-accent-dim); color: var(--color-accent-dim); }
855862.badge-info::before { content: "ℹ"; }
+30-2
server/api/me/dashboard.get.ts
···11import { sql } from 'drizzle-orm'
22-import { installation, repoMapping, sshKey, userIdentity } from '#server/db/schema'
22+import { installation, job, repoMapping, sshKey, userIdentity } from '#server/db/schema'
33import { useDb } from '#server/utils/db'
44import { type RepoHealth, repoHealth } from '#server/utils/repo-health'
55import { requireSession } from '#server/utils/server-session'
···2323export interface DashboardSummary {
2424 total: number
2525 ok: number
2626+ behind: number
2627 enrolling: number
2728 needsAttention: number
2829}
···5051 const session = await requireSession(event)
5152 const db = useDb()
52535353- const [identityRow, installRows, repoRows, sshKeyRows] = await Promise.all([
5454+ const [identityRow, installRows, repoRows, sshKeyRows, jobRows] = await Promise.all([
5455 db.select({ did: userIdentity.did, handle: userIdentity.handle })
5556 .from(userIdentity)
5657 .where(sql`${userIdentity.did} = ${session.did}`)
···7576 .from(sshKey)
7677 .where(sql`${sshKey.installationId} = ${session.installationId} AND ${sshKey.did} = ${session.did}`)
7778 .limit(1),
7979+ // Outstanding sync work per repo. A queued/running ref job means the mirror
8080+ // is behind (a push hasn't landed yet); a failed one means it's stuck. The
8181+ // `repo_mapping` row alone can't tell us this: `lastError` is only set on
8282+ // terminal rejections, and a repo pending its first relayed push looks
8383+ // identical to a fully-synced one. Keyed off the job payload's repo id.
8484+ db.select({
8585+ // bigint and count(*) both arrive as strings over the wire; coerce below.
8686+ githubRepoId: sql<string>`(${job.payload}->>'githubRepoId')::bigint`,
8787+ pending: sql<string>`count(*) filter (where ${job.status} in ('queued', 'running'))`,
8888+ failing: sql<string>`count(*) filter (where ${job.status} = 'failed')`,
8989+ })
9090+ .from(job)
9191+ .where(sql`
9292+ (${job.payload}->>'installationId')::bigint = ${session.installationId}
9393+ AND ${job.kind} in ('github.push', 'github.create', 'github.delete')
9494+ AND ${job.status} in ('queued', 'running', 'failed')
9595+ `)
9696+ .groupBy(sql`(${job.payload}->>'githubRepoId')::bigint`),
7897 ])
79988099 const installRow = installRows[0] ?? null
81100 const sshKeyRow = sshKeyRows[0] ?? null
101101+102102+ const jobStateByRepo = new Map<number, { pending: number, failing: number }>()
103103+ for (const j of jobRows) {
104104+ if (j.githubRepoId == null) continue
105105+ jobStateByRepo.set(Number(j.githubRepoId), { pending: Number(j.pending), failing: Number(j.failing) })
106106+ }
8210783108 const repos: DashboardRepo[] = repoRows.map(row => {
84109 // Schema audit prefers no new column for "last push time": `updatedAt`
···108133 lastError: row.lastError,
109134 disabledAt,
110135 tangledRepoDid: row.tangledRepoDid,
136136+ pendingJobs: jobStateByRepo.get(row.githubRepoId)?.pending ?? 0,
137137+ failingJobs: jobStateByRepo.get(row.githubRepoId)?.failing ?? 0,
111138 }),
112139 }
113140 })
···115142 const summary: DashboardSummary = {
116143 total: repos.length,
117144 ok: repos.filter(r => r.health.state === 'ok').length,
145145+ behind: repos.filter(r => r.health.state === 'behind').length,
118146 enrolling: repos.filter(r => r.health.state === 'enrolling').length,
119147 needsAttention: repos.filter(r => r.health.needsAttention).length,
120148 }
+20-10
server/utils/repo-health.ts
···1010 */
11111212export type RepoHealthState =
1313- /** Mirrored on tangled (the knot holds the repo). Nothing to do. */
1313+ /** Mirrored on tangled and no outstanding sync work. Nothing to do. */
1414 | 'ok'
1515+ /** Mirrored, but a push/ref update is still queued (catching up). */
1616+ | 'behind'
1517 /** Enrolment hasn't produced a tangled mirror yet. */
1618 | 'enrolling'
1719 /** User paused sync from the dashboard. */
···3436 lastError: string | null
3537 disabledAt: string | null
3638 tangledRepoDid: string | null
3939+ /** Count of queued/running ref-sync jobs for this repo. */
4040+ pendingJobs: number
4141+ /** Count of failed ref-sync jobs for this repo. */
4242+ failingJobs: number
3743}
38443945/**
···8591 return { state: 'info', message: input.lastError.slice('info:'.length).trim(), needsAttention: false }
8692 }
87938888- if (input.status === 'error') {
9494+ // A stuck mapping, or a failed ref-sync job for an otherwise-active mapping:
9595+ // either way sync won't recover on its own.
9696+ if (input.status === 'error' || input.failingJobs > 0) {
8997 const explained = input.lastError ? explainRepoError(input.lastError) : null
9098 return {
9199 state: 'error',
9292- message: explained ?? 'Sync is stuck. Try a resync; if it keeps failing, report it with the details below.',
100100+ message: explained ?? 'A recent sync failed and needs attention. Try a resync; if it keeps failing, report it with the details below.',
93101 needsAttention: true,
94102 }
95103 }
961049797- // The knot clones the whole repo from `source` at enrolment, so a mapping
9898- // with a `tangledRepoDid` and `active` status is mirrored regardless of
9999- // whether we've relayed a push since. `refCount` counts only pushes we've
100100- // observed after enrolment, so it is NOT a completeness signal: a repo that
101101- // simply hasn't been pushed to since enrolment still has `refCount === 0`
102102- // while being fully up to date on the knot.
103105 if (!input.tangledRepoDid || input.status !== 'active') {
104106 return { state: 'enrolling', message: 'Setting up the mirror on tangled. This can take a minute.', needsAttention: false }
105107 }
106108107107- return { state: 'ok', message: 'Mirrored and syncing.', needsAttention: false }
109109+ // Mirrored, but a push hasn't landed on the knot yet. This is the honest
110110+ // "not up to date *right now*" signal: the knot cloned the whole repo at
111111+ // enrolment, so the mapping is otherwise complete, but an outstanding job
112112+ // means a newer commit is still in flight.
113113+ if (input.pendingJobs > 0) {
114114+ return { state: 'behind', message: 'Catching up: a recent push is still syncing to tangled.', needsAttention: false }
115115+ }
116116+117117+ return { state: 'ok', message: 'Mirrored and up to date.', needsAttention: false }
108118}
+19
test/unit/repo-health.spec.ts
···77 lastError: null,
88 disabledAt: null,
99 tangledRepoDid: 'did:plc:repo',
1010+ pendingJobs: 0,
1111+ failingJobs: 0,
1012 ...overrides,
1113 }
1214}
···2527 // "waiting for the first sync".
2628 const h = repoHealth(input({ tangledRepoDid: 'did:plc:repo' }))
2729 expect(h.state).toBe('ok')
3030+ })
3131+3232+ it('reports behind when a push is still queued for a mirrored repo', () => {
3333+ const h = repoHealth(input({ pendingJobs: 1 }))
3434+ expect(h.state).toBe('behind')
3535+ expect(h.needsAttention).toBe(false)
3636+ })
3737+3838+ it('flags a failing sync job as an error even when the mapping status is active', () => {
3939+ const h = repoHealth(input({ failingJobs: 2 }))
4040+ expect(h.state).toBe('error')
4141+ expect(h.needsAttention).toBe(true)
4242+ })
4343+4444+ it('prefers the error state over behind when both failing and pending jobs exist', () => {
4545+ const h = repoHealth(input({ pendingJobs: 3, failingJobs: 1 }))
4646+ expect(h.state).toBe('error')
2847 })
29483049 it('reports enrolling before the tangled repo exists', () => {