diff --git a/hub/internal/sysfacts/sysfacts.go b/hub/internal/sysfacts/sysfacts.go index e17bd383..00e2b0a6 100644 --- a/hub/internal/sysfacts/sysfacts.go +++ b/hub/internal/sysfacts/sysfacts.go @@ -59,6 +59,28 @@ type ConfigBundle struct { Drift []string `json:"drift"` } +// DiskTrim is the agent's weekly guest disk trim stanza (R-444, `09` §3 decision 139): the host report's TOP-LEVEL +// `guest_disk_trim` object (agent internal/hub GuestDiskTrimStatus). A field-by-field mirror of the agent's type, +// checked BOTH ways by the wire-contract gate (SUBTREE_MIRRORS in scripts/wire_contract_gate.py), so a rename or a new +// field on either side convicts. +type DiskTrim struct { + Schedule string `json:"schedule"` + Guests []GuestTrim `json:"guests"` +} + +// GuestTrim is one guest's LAST trim attempt. `ok` with `last_attempt_at` is that attempt's verdict; `last_ok_at` is +// the last attempt that succeeded ("" = never) — staleness is judged on it, never on the attempt time alone. +type GuestTrim struct { + VMID int `json:"vmid"` + LastAttemptAt string `json:"last_attempt_at"` // RFC3339 + OK bool `json:"ok"` + BytesTrimmed int64 `json:"bytes_trimmed"` + Mounts int `json:"mounts"` + DurationSeconds float64 `json:"duration_seconds"` + LastOKAt string `json:"last_ok_at"` // RFC3339, "" = never + Error string `json:"error"` +} + // System is the whole stanza. Present is false for a report from an agent older than v0.142.0. type System struct { Present bool @@ -70,10 +92,14 @@ type System struct { Host Host Guest Guest Bundle ConfigBundle + // DiskTrim is the top-level `guest_disk_trim` stanza (R-444). Independent of the `system` stanza: it is read even + // when Present is false. nil = the agent sent none (an agent with no trimmer). + DiskTrim *DiskTrim } type wire struct { - System *struct { + DiskTrim *DiskTrim `json:"guest_disk_trim"` + System *struct { PVEVersion string `json:"pve_version"` KernelVersion string `json:"kernel_version"` VMID int `json:"vmid"` @@ -97,7 +123,11 @@ func Parse(reportJSON string) System { Host: Host{Debian: Unknown, KernelRunning: Unknown, KernelNextBoot: Unknown}, Guest: Guest{Debian: Unknown, DockerEngine: Unknown, Containerd: Unknown, LiveRestore: Unknown}} var w wire - if json.Unmarshal([]byte(reportJSON), &w) != nil || w.System == nil { + if json.Unmarshal([]byte(reportJSON), &w) != nil { + return out + } + out.DiskTrim = w.DiskTrim + if w.System == nil { return out } out.Present = true diff --git a/hub/internal/sysfacts/sysfacts_test.go b/hub/internal/sysfacts/sysfacts_test.go index b9046c54..b98a5188 100644 --- a/hub/internal/sysfacts/sysfacts_test.go +++ b/hub/internal/sysfacts/sysfacts_test.go @@ -41,3 +41,32 @@ func TestParse_AbsentAndPartialAreUnknown(t *testing.T) { t.Fatal("unknown kernels must not read as 'differs'") } } + +// R-444: the top-level `guest_disk_trim` stanza is read on its own — with or without a `system` stanza — and an absent +// stanza stays nil (the page says "—", never a made-up trim). The fixture is the agent's TestReportJSONShape output shape. +func TestParse_DiskTrim(t *testing.T) { + s := Parse(`{"host":{},"guest_disk_trim":{"schedule":"weekly, Wednesday from 10:00","guests":[ +{"vmid":9201,"last_attempt_at":"2026-10-07T08:30:00Z","ok":true,"bytes_trimmed":90143313920,"mounts":2,"duration_seconds":24.4,"last_ok_at":"2026-10-07T08:30:00Z"}, +{"vmid":9202,"last_attempt_at":"2026-10-07T08:35:00Z","ok":false,"bytes_trimmed":0,"mounts":0,"duration_seconds":1.2,"error":"pct fstrim: exit 255"}]}}`) + if s.Present { + t.Error("no system stanza: Present must stay false") + } + d := s.DiskTrim + if d == nil || d.Schedule != "weekly, Wednesday from 10:00" || len(d.Guests) != 2 { + t.Fatalf("guest_disk_trim misread: %+v", d) + } + g, f := d.Guests[0], d.Guests[1] + if g.VMID != 9201 || !g.OK || g.BytesTrimmed != 90143313920 || g.Mounts != 2 || g.DurationSeconds != 24.4 || + g.LastAttemptAt != "2026-10-07T08:30:00Z" || g.LastOKAt != "2026-10-07T08:30:00Z" { + t.Errorf("guest 9201 misread: %+v", g) + } + if f.OK || f.Error != "pct fstrim: exit 255" || f.LastOKAt != "" { + t.Errorf("guest 9202 misread: %+v", f) + } + if s = Parse(full); s.DiskTrim != nil { + t.Errorf("a report with no guest_disk_trim must read nil, got %+v", s.DiskTrim) + } + if s = Parse(`{"system":{"pve_version":"x"},"guest_disk_trim":{"schedule":"w","guests":[]}}`); !s.Present || s.DiskTrim == nil || len(s.DiskTrim.Guests) != 0 { + t.Errorf("with a system stanza the trim must still be read: %+v", s) + } +} diff --git a/hub/internal/web/system.go b/hub/internal/web/system.go index 82f9398f..3e9d3035 100644 --- a/hub/internal/web/system.go +++ b/hub/internal/web/system.go @@ -40,6 +40,7 @@ type systemRow struct { Agent cell // R-530: the box's agent against the vouched one // guest GuestDebian, GuestRelease, GuestPending, GuestRestart cell + Trim cell // R-444: the last `pct fstrim` of the guest // docker Engine, Containerd, LiveRestore, DockerRelease cell // last leg @@ -112,6 +113,77 @@ func buildFloorRows(ovs []store.CustomerFloorOverride, global string, now time.T func plain(s string) cell { return cell{Text: s} } +// trimStaleAfter is when a guest's last SUCCESSFUL disk trim reads amber: the boxes trim weekly (`09` §3 decision +// 139), so two missed weeks are worth a look. +const trimStaleAfter = 14 * 24 * time.Hour + +// trimCell is the "Last disk trim" cell (R-444): per guest, the last successful `pct fstrim` (from `last_ok_at`) and what +// the newest attempt freed. Amber when the newest attempt failed (its error is shown) or the last success is older than +// 14 days — judged on `last_ok_at`, never on the attempt time (an attempt is not a result). "—" when the agent sends no +// stanza (no trimmer); an unreadable time is "unknown", never a guess. +func trimCell(dt *sysfacts.DiskTrim, now time.Time) cell { + if dt == nil { + return cell{Text: "—", Title: "the agent reports no disk trim (an agent without the weekly trim)"} + } + if len(dt.Guests) == 0 { + return cell{Text: "none yet", Title: "the weekly trim runs (" + dt.Schedule + ") and has not trimmed a guest yet"} + } + out := cell{Title: "schedule: " + dt.Schedule} + var texts []string + for _, g := range dt.Guests { + t, warn, why := guestTrimText(g, now) + if len(dt.Guests) > 1 { + t = fmt.Sprintf("%d: %s", g.VMID, t) + } + texts = append(texts, t) + if warn { + out.Class = "warn" + out.Title = fmt.Sprintf("guest %d: %s — %s", g.VMID, why, out.Title) + } + } + out.Text = strings.Join(texts, " / ") + return out +} + +func guestTrimText(g sysfacts.GuestTrim, now time.Time) (text string, warn bool, why string) { + var okAt time.Time + if g.LastOKAt != "" { + t, err := time.Parse(time.RFC3339, g.LastOKAt) + if err != nil { + return "unknown", true, fmt.Sprintf("unreadable last_ok_at %q", g.LastOKAt) + } + okAt = t + } + attAt, aerr := time.Parse(time.RFC3339, g.LastAttemptAt) + if aerr != nil { + return "unknown", true, fmt.Sprintf("unreadable last_attempt_at %q", g.LastAttemptAt) + } + if g.OK { + text = fmt.Sprintf("%s · %.1f GiB", ago(attAt, now), float64(g.BytesTrimmed)/(1<<30)) + if !okAt.IsZero() { + text = fmt.Sprintf("%s · %.1f GiB", ago(okAt, now), float64(g.BytesTrimmed)/(1<<30)) + } else { + okAt = attAt + } + } else { + last := "never ok" + if !okAt.IsZero() { + last = "last ok " + ago(okAt, now) + } + errText := g.Error + if errText == "" { + errText = "no error text" + } + text = fmt.Sprintf("FAILED %s: %s (%s)", ago(attAt, now), errText, last) + warn, why = true, "the newest trim failed" + } + if !okAt.IsZero() && now.Sub(okAt) > trimStaleAfter { + warn = true + why = strings.TrimPrefix(why+"; ", "; ") + "the last successful trim is older than 14 days (the boxes trim weekly)" + } + return text, warn, why +} + // bundleCell is the "Root files" cell (R-840): the box's config bundle against the vouched agent's. Amber while behind, // red from the alarm's wait on, amber when a file was changed by hand (drift); "unknown" is never coloured as a fact. func bundleCell(f sysfacts.System, vouchedAgent, vouchedSHA string, since time.Time, after time.Duration, now time.Time) cell { @@ -239,6 +311,7 @@ func buildSystemRows(lines []osupdates.FleetLine, facts map[string]sysfacts.Syst r.GuestRelease = plain(orDash(l.Guest.ReleaseID)) r.GuestPending = plain(fmt.Sprint(l.Guest.Pending)) r.GuestRestart = plain(fmt.Sprint(l.Guest.RestartNeeded)) + r.Trim = trimCell(f.DiskTrim, now) r.Engine, r.Containerd = unknownCell(f.Guest.DockerEngine), unknownCell(f.Guest.Containerd) r.LiveRestore = unknownCell(f.Guest.LiveRestore) if f.Guest.LiveRestore == "off" { @@ -312,16 +385,16 @@ func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[ERROR] system page: floor overrides: %v", oerr) } data := map[string]interface{}{ - "Rows": rows, + "Rows": rows, "GlobalFloor": global, "VouchedAgent": man.AgentVersion, "Floors": buildFloorRows(ovs, global, time.Now()), - "Releases": view.Releases(), - "Cancelled": view.CancelledReleases(), - "Candidates": view.Candidates(), - "Flash": r.URL.Query().Get("flash"), - "FlashErr": r.URL.Query().Get("err"), - "CSRFToken": s.getCSRFToken(r), + "Releases": view.Releases(), + "Cancelled": view.CancelledReleases(), + "Candidates": view.Candidates(), + "Flash": r.URL.Query().Get("flash"), + "FlashErr": r.URL.Query().Get("err"), + "CSRFToken": s.getCSRFToken(r), } if err := s.templates.ExecuteTemplate(w, "system.html", data); err != nil { s.logger.Printf("[ERROR] system.html template: %v", err) diff --git a/hub/internal/web/system_trim_test.go b/hub/internal/web/system_trim_test.go new file mode 100644 index 00000000..1eb8c1c9 --- /dev/null +++ b/hub/internal/web/system_trim_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "fmt" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts" +) + +// R-444 (`09` §3 decision 139): the System page's "Last disk trim" cell — one case per branch. Staleness is judged on +// last_ok_at, never on the attempt time ("presence is not success"). +func TestTrimCell(t *testing.T) { + now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + at := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) } + day := 24 * time.Hour + dt := func(gs ...sysfacts.GuestTrim) *sysfacts.DiskTrim { + return &sysfacts.DiskTrim{Schedule: "weekly", Guests: gs} + } + cases := []struct { + name string + dt *sysfacts.DiskTrim + text string + class string + titleHas string + }{ + {"agent sends nothing", nil, "—", "", "reports no disk trim"}, + {"stanza, no guest yet", dt(), "none yet", "", "has not trimmed a guest yet"}, + {"recent ok", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(2 * day), LastOKAt: at(2 * day), OK: true, BytesTrimmed: 32427003904}), + "2 days ago · 30.2 GiB", "", "schedule: weekly"}, + {"ok but older than 14 days", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(15 * day), LastOKAt: at(15 * day), OK: true, BytesTrimmed: 1 << 30}), + "15 days ago · 1.0 GiB", "warn", "older than 14 days"}, + {"exactly 14 days is not stale", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(14 * day), LastOKAt: at(14 * day), OK: true}), + "14 days ago · 0.0 GiB", "", ""}, + {"newest failed, recent success before it", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), LastOKAt: at(8 * day), OK: false, Error: "pct fstrim: exit 255"}), + "FAILED 24 h ago: pct fstrim: exit 255 (last ok 8 days ago)", "warn", "the newest trim failed"}, + {"failed and never ok", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), OK: false}), + "FAILED 24 h ago: no error text (never ok)", "warn", "the newest trim failed"}, + {"a recent ATTEMPT does not hide a 20-day-old success", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(1 * day), LastOKAt: at(20 * day), OK: false, Error: "busy"}), + "FAILED 24 h ago: busy (last ok 20 days ago)", "warn", "older than 14 days"}, + {"two guests, one amber", dt( + sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: at(2 * day), LastOKAt: at(2 * day), OK: true, BytesTrimmed: 2 << 30}, + sysfacts.GuestTrim{VMID: 9202, LastAttemptAt: at(1 * day), OK: false, Error: "x"}), + "9201: 2 days ago · 2.0 GiB / 9202: FAILED 24 h ago: x (never ok)", "warn", "guest 9202"}, + {"unreadable time", dt(sysfacts.GuestTrim{VMID: 9201, LastAttemptAt: "yesterday", OK: true}), "unknown", "warn", "unreadable last_attempt_at"}, + } + for _, c := range cases { + got := trimCell(c.dt, now) + if got.Text != c.text || got.Class != c.class || !strings.Contains(got.Title, c.titleHas) { + t.Errorf("%s: got %+v, want text %q class %q title containing %q", c.name, got, c.text, c.class, c.titleHas) + } + } +} + +// The cell reaches the rendered page, read from the stored host report (the endpoint the operator's browser calls). +func TestSystemPage_LastDiskTrim(t *testing.T) { + s, st, _ := systemServer(t) + when := time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339) + body := fmt.Sprintf(`{"host":{"cpu_percent":1},"guest_disk_trim":{"schedule":"weekly","guests":[{"vmid":9201,"last_attempt_at":%q,"ok":true,"bytes_trimmed":32427003904,"mounts":2,"duration_seconds":24.4,"last_ok_at":%q}]}}`, when, when) + if err := st.UpsertHost(&store.Host{HostID: "trim-1", CustomerID: "c-trim", APIKey: "k-trim"}); err != nil { + t.Fatal(err) + } + if err := st.SaveHostReport("trim-1", "c-trim", []byte(body), store.HostReportDenorm{AgentVersion: "0.150.0", CloudflaredStatus: "running"}); err != nil { + t.Fatal(err) + } + b := getSystem(t, s) + if !strings.Contains(b, ">Last disk trim") { + t.Error("the System page lacks the Last disk trim column") + } + if !strings.Contains(b, "20 days ago · 30.2 GiB") { + t.Errorf("the trim-1 box's trim is not on the page") + } + if !strings.Contains(b, `class="c-warn" title="guest 9201: the last successful trim is older than 14 days`) { + t.Error("a 20-day-old trim must be amber, with its reason") + } + if !strings.Contains(b, `title="the agent reports no disk trim`) { + t.Error("a box whose agent sends no trim must read — with its reason") + } +} diff --git a/hub/internal/web/templates/system.html b/hub/internal/web/templates/system.html index c8372eee..5d5e8dcd 100644 --- a/hub/internal/web/templates/system.html +++ b/hub/internal/web/templates/system.html @@ -104,11 +104,11 @@ BoxRing / updatesTunnel ProxmoxKernel (running)Kernel (next boot)DebianFelhom releasePendingNot coveredHeldReboot neededkernel.panicOopsCrash restarts 24 hCrash guardRoot filesAgent - Guest DebianFelhom releasePendingRestart needed + Guest DebianFelhom releasePendingRestart neededLast disk trim Dockercontainerdlive-restoreDocker release Last OS leg - hostguestDocker engine + hostguestDocker engine {{range .Rows}} @@ -136,7 +136,7 @@ {{template "sys_cell" .Held}}{{template "sys_cell" .RebootSince}}{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}} {{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}{{template "sys_cell" .Bundle}}{{template "sys_cell" .Agent}} {{.GuestDebian.Text}} - {{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}} + {{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}}{{template "sys_cell" .Trim}} {{.Engine.Text}} {{template "sys_cell" .Containerd}}{{template "sys_cell" .LiveRestore}}{{template "sys_cell" .DockerRelease}} {{.LastLeg.Text}} diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index daeb214e..f55b1255 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -63,7 +63,9 @@ COVERS = { "must convict (it passed for months as `language` did); the genuine article — the same " "name in a struct tag beside a `//` inside a string literal — must pass; and R-315: the " "agent RENAMING its mirror's `superseded_at` tag (the old name still occurs as a local-API " - "map key) must convict on the field-by-field mirror check"), + "map key) must convict on the field-by-field mirror check; and R-444: the agent renaming " + "`guest_disk_trim.guests.ok` to `succeeded` (a name the hub carries elsewhere) or DROPPING `last_ok_at` (a field the " + "hub reads and would show as never-ok for ever) must convict on the subtree mirror"), "stands": ("R-819: a stand citing a register id that exists in NEITHER register, and a stand " "marked 'walked' whose only source is a register row (a green dot from a label); " "plus the genuine article — a stand citing only a CLOSED row, which must PASS"), @@ -750,6 +752,47 @@ for _mode, _want in (("renamed", 10), ("genuine", 0)): print(" ok %-20s %s" % ("wire-mirror/" + _mode, "decoy rejected" if _want else "genuine accepted")) +# ── wire-contract subtree mirror (R-444) ───────────────────────────────────────────────────────── +# +# `guest_disk_trim` is checked field by field BOTH ways against hub sysfacts.DiskTrim. Two decoys on the agent's parsed +# GuestDiskTrim body (no tree copy): `ok` renamed to `succeeded` — the name check passes it, since `succeeded` occurs in the hub — and +# `last_ok_at` dropped, which only the reverse direction can see. The genuine article must pass. +_WCS = r""" +import os, sys +sys.path.insert(0, "scripts") +import wire_contract_gate as g +mode = sys.argv[1] +orig, agent = g.build_index, os.path.abspath(g.REPOS["agent"]) +def patched(root): + by_dir, by_name = orig(root) + if mode != "genuine" and os.path.abspath(root) == agent: + k = ("internal/hub", "GuestDiskTrim") + b = by_dir[k] + if mode == "renamed": + assert 'json:"ok"' in b, "mutation target missing" + b = b.replace('json:"ok"', 'json:"succeeded"') + else: + assert 'json:"last_ok_at' in b, "mutation target missing" + b = "\n".join(l for l in b.split("\n") if 'json:"last_ok_at' not in l) + by_dir[k] = b + by_name["GuestDiskTrim"] = [(d, b if d == k[0] else x) for d, x in by_name["GuestDiskTrim"]] + return by_dir, by_name +g.build_index = patched +rc, conv = g.run(quiet=True) +want = {"renamed": "guest_disk_trim.guests.succeeded", "dropped": "guest_disk_trim.guests.last_ok_at"}.get(mode) +hit = any(d == want for _, _, m in conv for _, d in m) +print("rc=%d convicted=%s" % (rc, hit)) +sys.exit(0 if rc == 0 else (10 if hit else 11)) +""" +for _mode, _want in (("renamed", 10), ("dropped", 10), ("genuine", 0)): + ran += 1 + _p = subprocess.run([sys.executable, "-c", _WCS, _mode], cwd=ROOT, capture_output=True, text=True) + if _p.returncode != _want: + fails.append("wire-trim/%s: rc=%d, want %d (10 = the mutated trim field convicted, 0 = passed)\n%s" + % (_mode, _p.returncode, _want, (_p.stdout + _p.stderr)[-500:])) + else: + print(" ok %-20s %s" % ("wire-trim/" + _mode, "decoy rejected" if _want else "genuine accepted")) + # ── stands (R-819) ─────────────────────────────────────────────────────────────────────────────── # # check_stands.py was red and in no runner. Its decoys are stand files written as a session would write diff --git a/scripts/wire_contract_gate.py b/scripts/wire_contract_gate.py index 46bd9d58..82f0b8dd 100644 --- a/scripts/wire_contract_gate.py +++ b/scripts/wire_contract_gate.py @@ -105,6 +105,17 @@ MIRRORS = { "hub -> controller (report ACK, `escrow` object)": ("internal/report", "EscrowStatus"), } +# R-444 (2026-10-06): a SUBTREE of a name-checked root whose receiver decodes it into one named mirror type gets the +# field-by-field check in BOTH directions — every emitted path under it must be a json path of the mirror, AND every +# mirror path must be emitted (a field the hub reads and the agent never sends would leave the page on "—" for ever). +# Both directions, because some leaves here are short names (`ok`, `error`, `vmid`) the name check cannot judge: +# `ok` occurs everywhere, so a renamed `ok` would pass it. Keyed by (root label, dotted subtree path): +# (receiver package dir, receiver type). A subtree the emitter does not carry YET prints PENDING — the receiver was +# built first, against a provisional shape — and is neither a pass nor a conviction of the subtree. +SUBTREE_MIRRORS = { + ("agent -> hub (POST /host-report)", "guest_disk_trim"): ("internal/sysfacts", "DiskTrim"), +} + # Tag names whose literal string carries no information in a repo-wide search. NOT CHECKED. # Listed rather than silently skipped: each one is a hole. GENERIC = { @@ -630,6 +641,29 @@ def run(root_override=None, quiet=False): convictions.append((label, receiver, missing)) continue kinds.append((label, "name-reachability only (a tag found ANYWHERE in %s passes)" % receiver)) + emitted_paths = {d for _, d in tags} + for (slabel, sub), (mdir, mtype) in sorted(SUBTREE_MIRRORS.items()): + if slabel != label: + continue + rby_dir, rby_name = indexes[receiver] + if (mdir, mtype) not in rby_dir: + die("wire-contract gate INCONCLUSIVE: declared subtree mirror %s.%s not found in %s/%s\n" + " A mirror that cannot be resolved is not a pass — fix SUBTREE_MIRRORS or the type." + % (receiver, mtype, receiver, mdir)) + if sub not in emitted_paths: + kinds.append((" └ " + sub, "PENDING — the emitter does not carry `%s` yet; %s.%s is unchecked" + % (sub, mdir, mtype))) + continue + want = {sub + "." + d for _, d in walk(rby_dir, rby_name, mdir, mtype)} + got = {d for d in emitted_paths if d.startswith(sub + ".")} + for d in sorted(got - want): + checked += 1 + missing.append((d.split(".")[-1], d)) + for d in sorted(want - got): + checked += 1 + missing.append((d.split(".")[-1] + " (read by the receiver, never emitted)", d)) + kinds.append((" └ " + sub, "FIELD-BY-FIELD both ways against %s %s.%s (%d path(s))" + % (receiver, mdir, mtype, len(want | got)))) for tag, dotted in sorted(seen_tags.items()): if tag in GENERIC: skipped += 1