diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index c7c3c55..b83a93e 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -5,8 +5,10 @@ // Cutover state (Phase 5 → 6): the internal pipeline/topdata/erf/wiki // packages migrated from gitea/sow-tools now live in this tree, so wired // builders delegate to internal/app's legacy command surface (mapped per -// docs/command-surface.md). A builder with no migrated logic yet (depot) keeps -// the Wired=false fail-closed path: exit 70, never a faked artifact. +// docs/command-surface.md). depot is wired but bypasses that legacy surface +// entirely, delegating straight to internal/depot.Run. A builder with no +// migrated logic yet keeps the Wired=false fail-closed path: exit 70, never a +// faked artifact. package dispatch import ( @@ -91,8 +93,15 @@ var Registry = []Builder{ { Name: "depot", Bin: "crucible-depot", - Summary: "verify/move content-addressed depot blobs (SeaweedFS)", - Wired: false, // no migrated logic yet; fails closed (exit 70) + Summary: "content-addressed asset depot (local/cdn/bunny)", + Commands: []Command{ + {Name: "status", Summary: "report referenced-vs-present drift against a backend", Usage: "crucible depot status [--manifests DIR] [--source DIR] --target bunny|cdn|local"}, + {Name: "push", Summary: "upload referenced-but-absent blobs from a local depot", Usage: "crucible depot push [--manifests DIR] --source DIR --target bunny"}, + {Name: "verify", Summary: "existence sweep plus sampled download re-hash", Usage: "crucible depot verify [--manifests DIR] --target bunny|cdn [--sample N]"}, + {Name: "get", Summary: "fetch one blob with sha re-verify", Usage: "crucible depot get --target cdn|bunny|local"}, + {Name: "pull", Summary: "incremental verified pull of every referenced blob", Usage: "crucible depot pull [--manifests DIR] --dest DIR --target cdn|bunny"}, + }, + Wired: true, }, { Name: "hak", diff --git a/internal/dispatch/dispatch_test.go b/internal/dispatch/dispatch_test.go index 5810b32..b7c4e7e 100644 --- a/internal/dispatch/dispatch_test.go +++ b/internal/dispatch/dispatch_test.go @@ -150,7 +150,7 @@ func TestBuilderHelpIsOK(t *testing.T) { func TestCanonicalCommandSurface(t *testing.T) { want := map[string][]string{ - "depot": nil, + "depot": {"status", "push", "verify", "get", "pull"}, "hak": {"build", "manifest"}, "module": {"build", "extract", "validate", "compare", "manifest"}, "topdata": {"validate", "build", "package", "compare", "convert"}, @@ -172,7 +172,11 @@ func TestRegistryCommandNamesAndAliasesAreUnambiguous(t *testing.T) { for _, builder := range Registry { seen := map[string]bool{} for _, command := range builder.Commands { - if command.Name == "" || command.Summary == "" || command.AppCommand == "" || command.Usage == "" { + // depot parses its own subcommands and bypasses AppCommand routing + // entirely (see the depot special-case in runBuilder), so its + // Commands carry no AppCommand. + requireAppCommand := builder.Name != "depot" + if command.Name == "" || command.Summary == "" || command.Usage == "" || (requireAppCommand && command.AppCommand == "") { t.Errorf("%s has incomplete command metadata: %#v", builder.Name, command) } if seen[command.Name] { @@ -289,15 +293,35 @@ func TestMenuItemsSkipsUnwiredIncludesWired(t *testing.T) { t.Fatal("expected menu items") } sawModuleBuild := false + sawDepotStatus := false for _, it := range items { - if it.Args[0] == "depot" { - t.Errorf("unwired builder %q must not appear in the menu", it.Args[0]) - } if len(it.Args) == 2 && it.Args[0] == "module" && it.Args[1] == "build" { sawModuleBuild = true } + if len(it.Args) == 2 && it.Args[0] == "depot" && it.Args[1] == "status" { + sawDepotStatus = true + } } if !sawModuleBuild { t.Error("expected 'module build' in the menu") } + if !sawDepotStatus { + t.Error("expected wired builder 'depot status' in the menu") + } +} + +// TestDepotRoutesToDepotRun asserts the depot special-case in runBuilder +// reaches depot.Run rather than the generic Commands/delegateLegacy path or +// the unwired fail-closed path. With no manifests dir present, depot.Run's +// status command fails resolving the backend/manifest (exit 70), but the +// stderr text must come from depot, never the dispatcher's unwiredMsg. +func TestDepotRoutesToDepotRun(t *testing.T) { + var out, errw bytes.Buffer + code := runBuilder("depot", []string{"status", "--target", "local"}, &out, &errw) + if errw.Len() == 0 { + t.Fatalf("depot status with no manifests dir: expected an error from depot.Run, got no stderr (exit=%d)", code) + } + if strings.Contains(errw.String(), "not wired yet") { + t.Fatalf("depot status: stderr shows the dispatcher's unwired message, want depot.Run's own error:\n%s", errw.String()) + } }