Self-heal process for missing repos and missing data #209
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#209
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Full plan in comments below (planning subagent output). Summary: (a) empty repos (manifest, no refs — like acme/waveB) stop being treated as errors: guided empty state, zero toasts, zero tasks; (b) refs-present-but-objects-missing: detect via fsck, classify, degrade gracefully, guide the admin; re-fetch only from explicitly configured upstream; give-up rule with stalled flag. No resurrection promises.
TICKET 1 — Self-heal process for missing repos / missing data
0. Problem statement (verified live 2026-09-06)
acme/waveBhas a manifest withhead:null, branches:0, tags:0— a repo thatexists (manifest present, summary 200) but has zero refs. Tree / resolve /
commits all 404 (
ErrNotFound: "unborn HEAD" —internal/api/bind_wal.go:181,"HEAD" —
:187). The UI surfaces these 404s as error-tray toasts(
web/src/lib/data.js:start()line 121: every fetch failure →reportError)instead of guiding the user. There is no working "create empty repo" flow
(see Ticket 2), so an empty repo is a dead end: it looks broken, not new.
Two distinct states are being conflated and must be handled separately:
not damage. Every repo passes through it:
Registry.createSlow(
internal/wal/registry.go:224-263) doesPutCreateofManifest{HeadSeq:0, MinSeq:0, Revision:1}with no refs, then inits thelocal bare repo. "Heal" here means stop treating it as an error —
guided empty state, never toasts.
cache). Recovery Buzzword-Bingo ("self-heal") must not promise resurrection:
with no upstream copy, missing objects are unrecoverable, full stop. The
plan is: detect (fsck) → classify → degrade gracefully → guide the admin,
with re-fetch only where a byte source actually exists.
1. How
waveBcame to exist (repo creation today — all paths)Determined from code, not guessed. Every path below ends in the same
empty-manifest state, which is why "empty" is normal:
endpoint EXISTS, the UI flow does not).
PUT /{o}/{r}orPUT …/api(docs/go/06_server_http.md:207,internal/api/summary.go:53-85 repoPut): gateAuthWrite(
require_write),?object_format=sha1|sha256(400 on bogus,internal/api/gaps5_test.go:381),Create→ 201{owner,name,full_name},exists → 409 "repository already exists". CLI twin:
walhub repo create <REPO> [--object-format](cmd/walhub/main.go:64,docs/go/11_config_cli.md:384). Both produce exactly the waveB state(manifest, HeadSeq 0, no refs). There is no UI page that calls it —
no "New repository" button, no
repo.create()inweb/sdk/src/repo.js(that file has refs/tree/blob/commits/commit/overview only), no route in
web/src/pages/. So: creatable by API/CLI, undiscoverable in UI.true—internal/config/firstrun.go:18,docs/go/06_server_http.md:248).Two interception points, both
require_write-gated BEFORE creation:GET …/info/refs?service=git-receive-packon unknown repo + AutoCreate→ advertises an empty ref list instead of 404, creating the repo
(
internal/server/smart.go:143-157). The push that follows fills it.POST …/git-receive-pack→engine.Repo(ctx, id, create=true, …)(
smart.go:419-428); not-found with auto-create off → 404.An empty repo survives from this path iff the push never landed (client
aborted after
info/refs, push rejected by policy, pack too large —smart.go:414-418413, push-pipeline refusal e.g. managed refsinternal/git/managed.go). waveB is plausibly one of these residues.web/src/lib/data.js:62-71documents it:a fork writes
repos/<o>/<r>/fork.jsonbefore the child manifestexists, so listings name a child whose manifest-gated reads 404. The
inverse (manifest without refs) arises if the fork target was created
(manifest
Createwon the CAS) but no refs were ever pushed/copied.docs/features/10_git_import.md):POST /api/v1/repos/imports→
repo-importtask → target manifest created, then content fetched. Afailed/never-run import leaves manifest-without-refs.
internal/api/*_test.go,cmd/walhub/*_test.gocreatedemo/empty-style repos routinely; a dev-server pointed at a test store(or a copied data dir) inherits them.
Net: waveB needs no exotic explanation — empty-manifest is the normal
post-create state, and at least four production paths strand it when the
first push never arrives. Ticket 2 (placeholder) turns this from residue
into a first-class state.
2. What "heal" can and cannot mean (hard-nosed)
head:null, branches/tags 0git fsckfails locally but bucket packs intactupstream.gitconfiguredmissing[]non-emptyrepairunit: fetch 500-oid batches via §7.9 helper, publish repair pack, setrepaired_seq(internal/maintain/repair.go,plan.go:263-268)Upstream.Git == ""→ repair predicate false, damage sits foreverThe existing machinery already covers (b1) implicitly (sync replays from the
bucket; local state is a cache —
registry.go openSlowstep 4) and (b2)(the repair unit). The gap this ticket closes: (a) UX + (b3) visibility.
3. Design
3.1 State classification (one probe, no LIST — AGENTS law 4/6)
Add a pure classifier on data the summary path already fetches (no new hot-path
round trips; summary today = 1 manifest GET + local ref read):
Emptyis decided inline in the summary handler from the manifest + refcounts it already holds. Cost: 0 new requests (law 6 safe).
Degradedis decided ONLY from the cachedfsck.pbreport(
internal/maintain/util.go:getFsckReport— one conditional GET, off thehot path; fsck unit 6 runs on its existing interval predicate
plan.go:186-189, never inline on a request). No request goroutine everruns
git fsck(law 3 / 14 §14.11 rule 5).summaryBody.health: "empty" | "healthy" | "degraded"(+ optionalmissing_totalwhen degraded). Oldclients ignore it.
head:nullstays the wire signal for unborn (frozen —summary.go:16"the one sanctioned null").3.2 API behavior per state (no new endpoints except one admin op)
GET …/api(summary): always 200 for existing repos incl. empty (alreadytrue). Adds
health. SWR + ETag(head sha / "empty") unchanged.resolve/tree/blob/commits/commiton an empty repo:stay 404 (frozen wire behavior; the SDK and
bind_wal_test.go:440-444pin "unborn HEAD" → ErrNotFound), BUT the 404 body gains a stable
machine-readable marker, e.g. plain-text prefix
empty repository:(plain-texterrors are the frozen convention — features README P-conventions / 07 §2).
Rationale: the UI must distinguish "empty, guide me" from "broken, toast
me" without parsing prose; a prefix is additive and greppable.
GET …/overview(WAL health JSON, no-store): include the fsck summary(
missing_total,repaired_seq, last auditat) whenfsck.pbexists —it already loads it for the snapshot (
maintain.go:285-297); surfacing isa read-only projection. This is the admin's machine interface.
POST …/api/ops/repair-check(require_write; joins existing(repo,kind)task semantics, §9.4)? Prefer reusing the existing fsckop over a new endpoint: the ops surface already exposes maintenance
units (
GET …/opslistsOpSpec;POST …/ops/{op}starts/joins).Specify:
POST …/api/ops/fscktriggers an out-of-schedule audit as theexisting
fsckkind (Seam 5, see 3.3), SSE-attachable. Only addrepair-checkif the ops table cannot addressfsckon demand — decideat implementation time, note in Decisions.
3.3 Task design (Seam 5 —
14_extensibility.md §14.7)No new periodic task for empty repos. Empty is not damage; a sweeper
would burn LIST/GET budget against human-rate state (law 6) and violate
"no LIST on a hot path" for zero benefit. On-demand + existing cadence only:
KindFsck(internal/maintain/units.go:32).Trigger:
POST …/ops/fsck(joins in-flight same(repo,kind)per thefrozen join semantics — a second click attaches, never duplicates).
Bounds: one unit per repo per pass (loop discipline, 14 §14.7
Concurrency);
git fsck --connectivity-only --no-danglingexact argv(
fsck.go:55-60); missing list bounded atfsckMissingBound(100k,units.go:65) with unboundedmissing_total. Report overwrite tofsck.pb(frozen overwritable family — no spec change needed).Cost per run: local subprocess + 1 Overwrite PUT. Gives up: never — an
audit always completes with a report; repair is what gives up (below).
KindRepairunit, predicate UNCHANGED(
plan.go:265-268:RepairedSeq==0 && (Missing||Total) && Upstream.Git != ""). No new kind, no new lease (repair is lease-free bydesign — cheap + idempotent,
repair.go:16-18). Bounds: 500-oid batches(
repairBatch), publish via ordinary CAS ladder,repaired_seq=headdisarms re-fire (
repair.go:52-53). When upstream is absent the unitsimply never fires — that is the (b3) case, handled by 3.4, not by
forcing a fetch from nothing.
extension, and it is configuration, not magic. If
Upstream.Gitpointsat a peer that has the objects (a fork parent, a second walhub via the
follow path —
follow.go), the existing repair unit already fetches fromit. What the plan adds: document that
upstream.gitMAY target afork-network sibling, and the
repair-checkresponse SHOULD name theconfigured upstream (or "none configured") so the admin knows where a
repair would fetch from. No cross-repo object snooping, no implicit
peer discovery (that would be LIST-by-another-name and a privacy hole
across private repos — P6
require_readapplies to every read).pass structure (one unit per repo per pass; publish failure keeps
repaired_seq==0→ next pass retries —wave4b_test.go:512). After Nconsecutive error outcomes (suggest N=5, config
maintenance.repair_retries,default 5), the unit stops retrying and the
fsck.pbconsumer surfacesrepair_stalled:true+ last error inoverview. The data stays as-is;the admin guidance (3.4) takes over. A stalled repair must never block
checkpoint/bundles/compaction (priority order already guarantees repair
is #2 and skippable via
Skip).3.4 (b3) admin guidance + graceful degradation (the actual new UX)
When
health==degradedwith no repair path (no upstream / stalled):Repo.jsx): amber (not red) banner under the header:"Some objects are missing (fsck: N missing). Reads may fail; pushes of
new refs still work. Details in Settings → WAL." Links to the WAL page.
Never a toast; never blocks navigation.
degraded marker renders an inline notice ("this object is missing from
the store — see WAL health") instead of
reportError. Mechanism: extendthe
tolerateMissingpattern (data.js:77-82) with atolerateDegradedwrapper keyed on the new 404 prefix / the summary
healththe shellalready holds (no extra fetch — the shell's summary entry is shared via
context).
overviewfsck projection:missing_total, bounded sample of missingoids,
repaired_seq, last audit time, configured upstream (or "none —set
upstream.gitto enable repair"), stalled flag + last error, and theexact CLI to re-run the audit (
POST …/ops/fsck, plus thewalhubSeam-7 twin if added). This is the admin repair surface — read + trigger,
no new mutation semantics.
keep working (publish path never consults fsck); reads of missing objects
404 with the marker; reads of present objects are unaffected. Document
that deletion of the repo (
DELETE …/api, admin,summary.go:87-101)remains available as the last resort, with the fork/GC semantics of
01_identity_permissions.md §5.1(children unaffected).3.5 (a) guided empty state (closes the waveB complaint)
Repo.jsx:496already renders<span class="pill">empty</span>on
head:null— keep, and add anEmptyRepoGuideblock on the Code tabwhen
summary.health=="empty"(equivalently!head && !branches && !tags):clone URL (reuse
CloneMenuURL builders — serverclone_urlverbatim,issue #124 rule),
git remote add origin <url>+git push -u origin main(copy buttons, same
copyTexthelper), plus protocol toggle parity.No recipes fetch needed; static commands only.
NOT issue the doomed
resolve/commits?n=1fetch at all (the "by-designempty-repo
commits?n=1fetch" noted indocs/go/12_web_ui.md:517becomesa suppressed fetch:
selArgs()-style null means "do not fetch" —Tree.jsx:103-105already establishes the pattern). Redirect/:owner/:repo/tree/*etc. to the Code-tab empty guide when empty.This kills the toasts at the source instead of filtering them downstream.
useResolvedshort-circuits when the cached summary forthe repo is known-empty (read the
repo:{full}cache entry; no new fetch).Fallback (summary not yet loaded): the 404-with-
empty repository:prefixmaps to silent + guide, never
reportError.reportErroritself isuntouched (frozen tray behavior for real errors).
4. How waveB specifically gets diagnosed under this plan
GET /acme/waveB/api→ 200,health:"empty"→ shell shows empty guide(no toasts). Verdict: never broken, just unborn.
overviewshowsmissing_total, upstream "none", repair never fires → amber banner +WAL-tab guidance ("set upstream.git or restore from a clone via push of
the missing refs").
GET …/api/wal/ log segmentsshow zero PUSH entries;
meta/import.json/fork.jsonpresencedistinguishes import/fork residue. No new endpoint — existing WAL reads.
5. EVIDENCE / perf notes
health:"empty"derives frommanifest+refs the summary already loads (law 6: warm-refs budget intact).
The suppressed
commits?n=1/resolve fetches remove 1–2 requests perempty-repo page load — measurable improvement, assert in sim/Tier-2 e2e:
"empty repo Code tab issues 0 manifest-gated reads beyond summary".
overviewfsck projection: +0 requests whenfsck.pbabsent (probe missis the existing snapshot load); +0 when present (already loaded per pass —
projection only). No hot-path change:
overviewis no-store admin page.ops/fsck: bounded 1 subprocess + 1 PUT,(repo,kind)single-flight dedupes stampedes; NOT on any push/fetch path.
oid fetch —
FetchObjectsAsPack,units.go:159). Round-trip budgets(push ≤5, warm refs 1) untouched — record "no change" entries in
docs/EVIDENCE.mdwith the harness (internal/devtools/) per AGENTS.md.(≥95% pkg gate,
-race); UI:node --testfor the empty-guide renderpredicate + suppressed-fetch logic; real-browser check (AGENTS ladder #8)
of
/acme/waveB, a degraded fixture, and/setupconsole-clean.6. Acceptance criteria
acme/waveB(or fixture-identical empty repo) loads/,/tree/*,/commitswith zero toasts, an "empty" pill, and a push-guide blockshowing the verbatim server
clone_url+push -u origin main.resolve/tree/commitson empty repos still 404 for API clients (frozen),with the
empty repository:marker prefix.health(empty|healthy|degraded); no existingfield changes; discovery/SDK updated (
repo.get()typedef).fsck.pbwith missing, no upstream): amberbanner, inline (not toast) read errors, WAL tab shows missing_total +
"no upstream configured" + re-audit trigger; pushes of new refs succeed.
(already covered by
maintain_test.go:83+— regression, not new).make cover≥95% holds for touched packages;make simbudgets passunchanged; real Chromium drive clean console (except the suppressed —
i.e. absent — empty fetch).
docs/go/07_api.md(health field + 404 marker),
docs/go/10_maintenance.md(give-up rule +repair_stalled),docs/go/12_web_ui.md(empty guide + banner),Decisions appends.
7. Risks / non-goals
"reconstruct" commits from
commitrender caches, reflogs, or peerguessing is rejected — caches are not truth (law 4) and guessing breaks
hash integrity. Detection + guidance only.
upstream.git. Never auto-discover "peers" via listings — cross-reporeads bypass P6 visibility reasoning.
emptiness; it must NOT globally mute 404s (real "repo deleted under you"
flows — #200
tolerateMissing→ "not found" shell — depend on them).(
fullCopygate,plan.go:287); on-demand trigger needs the sameTryAcquireper-repo semaphore discipline as git handlers (503 +Retry-After when busy), never a blocking wait.
fsck.pb, already frozen) —keeps the 14 §14.11 frozen-list untouched. If
repair_stalledneedsdurability beyond the in-memory snapshot, it rides inside
fsck.pbas anadditive optional field (14 §14.12 field rule), not a new key.
8. Work breakdown (implementer waves)
healthfield + 404 marker +overviewfsckprojection (+ httptest). 2. Ops: on-demand
fscktrigger wiring (if notalready addressable) + stall rule + config key. 3. Web: empty guide,
fetch suppression, degraded banner + WAL section (+ node tests). 4. Docs
REVIEW — Ticket 1 self-heal plan (against CODE, not docs)
Verdict: PROCEED-WITH-FIXES (no redesign needed; 3 blocking scoping/correctness fixes, all cuttable).
VERIFIED CORRECT (checked in tree):
fsck --connectivity-only --no-dangling. Confirmed.fsck(ops.go:13), opStart is AuthWrite (ops.go:62-65), gaps_test.go:360 proves POST, Wal.jsx already drives ops.run. So: NO new repair-check endpoint — close that option in Decisions, reuse wins.BLOCKING:
repair_stalleddurability needs a real schema decision. fsck.pb is protobuf under law 5 (append-only, golden fixtures). "Rides inside fsck.pb as an additive optional field, no spec change" still requires a new field NUMBER, proto edit, fixture regen + round-trip test, and a Decisions entry (+02_storage_protobuf.md note). Alternatively derive stalled-ness read-side (report age > X with upstream set and RepairedSeq==0) with ZERO new state. Pick one; don't hand-wave it.maintenance.repair_retries) contradicts tested behavior: wave4b_test.go:512 pins "publish failure keeps repaired_seq at 0 so the next pass retries", and the plan.go predicate has no counter. A stop-after-N changes the predicate and needs: counter durability (in-memory resets on restart = restart-dependent behavior; durable = another fsck.pb field -> B2), config spec (11_config_cli.md + setup schema + env overlay — missing from the §6 doc list), and test updates. RECOMMENDATION for v1: cut the give-up, keep retry-forever, surface derived-stalled read-side per B2-alt. Detection+guidance (the ticket's actual gap) works either way.SHOULD-FIX:
health: empty|healthy|degradedvs existing overviewHealth.status: ok|degraded|error(api/env.go:206-212, sdk types.js:33). Scope them explicitly in 07_api.md (summary-health = repo state; overview-Health = dashboard). Also fix the ETag claim: empty repos get etag "" today (summary.go:46-49), not '"empty"' — define (don't misdescribe) ETag with the new field.empty repository:prefix precisely: exact predicate (HeadSeq==0 && refs==0 at handler time), enumerate touched error sites (bind_wal.go:181,187,230,317,336,349,370,388,392), audit per-handler cost (most hold the snapshot; name any that gains a GET), and assert damage-404s (refs>0, objects missing) NEVER carry the prefix or the UI misclassifies degraded as empty.### Concurrencysubsection (hazard + avoidance). Material exists (§7 risks: TryAcquire/503+Retry-After per smart.go:134, never fsck on a request goroutine, (repo,kind) join) — promote it to normative form.NITS:
CROSS-PLAN (joint with #210): #209 health-from-refs composes cleanly with stale-marker-on-real IFF the joint summary shape is defined — see #210 review B1 (blocking there, tracked here as dependency). Order: #209 health first, #210 adds the placeholder projection onto it. Do not let #209's classifier branch on any marker it doesn't own yet.
Plan revision R1 (review findings — R1 wins on conflict)
Blocking resolutions (normative)
repair_stalled: derive read-side (report age > X + upstream set +RepairedSeq==0) — zero new protobuf state, no fixture regen.wave4b_test.go:512retry-forever stays.Joint with #210
healthfield + placeholder projection defined in the #210 R1 (single shared shape); #209 implementshealth(empty|healthy|degraded) first.Should-fix adoptions
Single
ok|degraded|erroroverview vocabulary vs summaryempty|healthy|degradedscoped in07_api.md; ETag empty→""as today; 404-marker predicate pinned + all 9bind_wal.gosites costed; frontend cache peek exported;### Concurrencysubsection added;POST …/ops/fsckconfirmed already addressable (no new endpoint);repair-checkoption closed.waves 1-3 implemented in PR #216 (branch feat/issue-209): #216 — health on summary, empty-repo 404 marker at all 9 sites, overview fsck projection, EmptyRepoGuide + suppression + degraded banner/WAL section, docs + EVIDENCE E13. R1 resolutions honored (B1 +1 stated, B2 derived stall, B3 give-up cut). Do NOT merge yet — awaiting review.
Review: PR #216 (feat/issue-209, commit
31d00b3) — self-healSpec checked: plan (comment 1801) + code-review (1808, PROCEED-WITH-FIXES) + R1 (1812, normative: B1 +1 stated, B2 derived stall, B3 give-up cut, joint shape to #210). Verified in scratch worktree at the exact PR commit; main worktree untouched (still clean).
R1 rulings — all honored
TestSummaryOverviewRoundTrips(overview ± report = exactly 1 probe). Correct scope: no-store admin page, off law-6 paths.repair_stalledderived read-side (fsckProjection: upstream set +RepairedSeq==0+ missing signal + report older than onefsck_interval; nil timestamp never stalls). Zero protobuf change —internal/store/protountouched, law 5 clean.plan.go:265-268untouched). Retry-forever stands.fsck.pb. #209-first ordering respected.""-when-unborn as-today +~degradedsuffix; 9/9bind_wal.gosites enumerated in 07 §9.10 with per-handler cost;peekCachedexported (S3);### Concurrencyinhealth.go+ 10_maintenance §9.3;POST …/ops/fsckreuse confirmed (norepair-check), closed in Decisions.Review checklist (per-item verdicts)
ManifestSnapshot(+0); thefsck.pbprobe is behindif health != RepoHealthEmpty(summary.go:40) — branch verified. Non-empty summary +1, openly stated in E13.~degradedflip busts SWR (pinned byTestSummaryDegraded304: stale sha → 200, suffixed → 304); unborn""unchanged (wire test pins absent ETag).Head==nil && Branches==0 && Tags==0 && (nil || HeadSeq==0)), fail-closed on any manifest/snapshot error. Damage-404s keep identical format strings (verified in diff) +TestEmptyMarkerSitespins frozen prefixes and asserts the marker never appears on damage.ops.run("fsck"). No new bucket families, no LIST (grep clean on new code), git argv untouched.useResolved: Step-0 suppression only on known-empty summary; resolve-catch maps only marker 404s; sha-step maps only 404 + known-degraded — real errors still reach the tray.tolerateDegradedrequires known-degraded, no global mute (#200 shell intact).probeFsck → GetBytespath; verdict explicitly scoped ("budgets never call here"); empty +0 / non-empty +1 / overview +1 all asserted.go.mod/go.sum/package.json/pnpm-lockdiff empty. Docs + Decisions appended (law 12).Verification (scratch worktree, no browser per instructions)
gofmt -lclean,go vet ./internal/api/...clean.go test -race -count=1 ./internal/api/...→ ok; coverage 95.3% (≥95% gate holds; onlyinternal/apitouched on the Go side).node --test web/test/unit/*.test.js→ 410/410 pass (incl. newempty-degraded.test.js10/10). Note: scratch worktree had noweb/node_modules; ran via a temporary symlink to main's install, removed afterward. Suite takes ~4 min.internal/walor publish/sync changes; E13 covers the cost claim). No browser drive (noted as instructed).Nits (non-blocking, left for author — no push)
overview.go,health.go,health209_test.go, 07 §9.1/§12.1, 10 §9.3): the probe passesGetOptions{}(noIfNoneMatch), i.e. a plain exact-key GET. Costed correctly as +1; R1 used the same term, so this is terminology only.SummaryData.MissingTotal(env.go:394-396) is never populated in non-test code (wire body carries its own). Dead internal field — drop it, or keep if #210's placeholder projection intends to ride the view struct.fsckHasMissingincludesProblems>0(fsck stderr non-missing lines), so a problems-only old report + upstream derivesrepair_stalledeven though the repair predicate (plan.go:266) only fires on Missing/MissingTotal. Self-consistent with summaryhealth(same helper); flag if the admin copy should distinguish corruption from missing.useResolvedruns, so one resolve may still fire and map silently via the marker fallback (zero toasts either way — covered by test "empty-prefix fallback…summary not loaded"). "Zero doomed requests" holds once the shell entry exists.No code pushed (nothing found that warrants a branch push; nits are author judgment + possible #210 interaction).
MERGE RECOMMENDATION: ready to merge
Implemented in PR #216 (review: all R1 rulings verified, probes costed, masking impossible; 95.3% + 410/410), merged. Closing.