diff --git a/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-6-report.md b/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-6-report.md new file mode 100644 index 00000000..90a7d55b --- /dev/null +++ b/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-6-report.md @@ -0,0 +1,55 @@ +# Task 6 — Version, aggregate doctor, and build-aware start + +Status: complete. + +Implemented the host-side `tht version`, aggregate `tht doctor [--json]`, and `tht start [--build]` contracts. + +- `version` is descriptor-free and reports semantic version, commit, build time, OS, and architecture. +- `doctor` emits typed, redacted checks for descriptor state, Docker/Compose, rendered volumes, file permissions, service health, workspace registry, the container-local workflow doctor, and Pi doctor. Its JSON mode writes exactly one JSON document to stdout. +- The Python workflow doctor is invoked only as `docker compose exec -T core tht doctor --json` after core is running. +- `start` uses the shared lifecycle service: default `up → health`; `--build` is `build → up → health`. +- `setup` now reuses the shared lifecycle and aggregate diagnostics rather than keeping parallel health/volume implementations. + +Verification performed without live Docker/container commands: + +```bash +cd tools/tht +go test ./internal/version ./internal/doctor ./internal/service ./cmd/tht \ + -run 'TestVersion|TestDoctor|TestStart|TestCurrent|TestRun' -count=1 +go test ./internal/setup -count=1 -run 'TestRun' -v +go test ./... -count=1 +git diff --check +``` + +All completed successfully. The intentionally fake runner coverage includes unavailable Docker, +stopped/running core, workflow failure redaction, pristine JSON output, and start ordering. + +Concerns: no live Docker validation or host installation was run, by explicit task constraint. + +## Fix round 1 + +Completed the independent-review follow-up without live Docker operations. + +- `workspace-registry` now executes a container-local, read-only Node validation of + `/data/workspace-registry/state/active.json` and every declared snapshot descriptor. It no + longer passes merely because Compose declares a volume. +- Host file permissions are checked before Docker/Compose availability and therefore remain + visible as failures when Docker is unavailable. +- Separate typed, bounded HTTP probes verify core (`curl --max-time 5`) and frontend + (`wget -T 5`) reachability, independently of Compose health. The probe is injectable in tests. +- The successful report tests assert the stable full checklist: + `descriptor`, `files`, `docker`, `compose`, `configuration`, `services`, `core-http`, + `frontend-http`, `workspace-registry`, `workflow`, `pi`. + +Additional verification: + +```bash +cd tools/tht +go test ./internal/doctor -run 'TestRun(ChecksUnsafeFilesEvenWhenDockerIsUnavailable|FailsAnInvalidContainerLocalRegistryState|ReportsEachHTTPReachabilityProbeFailure|UsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns)' -count=1 -v +go test ./internal/doctor ./internal/setup ./internal/service ./cmd/tht -count=1 +go test ./... -count=1 +git diff --check +``` + +All passed with fake runners/probes only. No live container, HTTP endpoint, or host installation +was touched. diff --git a/tools/tht/internal/doctor/report.go b/tools/tht/internal/doctor/report.go index c2d3ceee..38cbe42a 100644 --- a/tools/tht/internal/doctor/report.go +++ b/tools/tht/internal/doctor/report.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" @@ -24,6 +25,10 @@ const ( StatusSkipped = "skipped" ) +const probeTimeout = 5 * time.Second + +const registryValidationProgram = `const fs=require("node:fs");const path="/data/workspace-registry/state/active.json";const s=JSON.parse(fs.readFileSync(path,"utf8"));const hex=/^[0-9a-f]{40}$/;if(!hex.test(s.head)||!Array.isArray(s.revisions)||s.revisions.some((r)=>!r||typeof r.id!=="string"||!r.id||!hex.test(r.commit)||!hex.test(r.blob))){process.exit(1)}for(const r of s.revisions){fs.accessSync("/data/workspace-registry/snapshots/"+r.commit+"/"+r.id+".yaml",fs.constants.R_OK)}` + // Check is one named, redacted diagnostic outcome. type Check struct { Name string `json:"name"` @@ -42,14 +47,58 @@ type Runner interface { Run(context.Context, []string, io.Reader) (compose.Result, error) } +// HTTPProbeTarget identifies one local service endpoint that must answer a bounded request. +type HTTPProbeTarget struct { + Name string + Service string + URL string +} + +// HTTPProbe is injectable so reachability failures remain independently testable. +type HTTPProbe interface { + Probe(context.Context, HTTPProbeTarget) error +} + +type composeHTTPProbe struct { + installation config.Installation + runner Runner +} + +func (p composeHTTPProbe) Probe(ctx context.Context, target HTTPProbeTarget) error { + probeContext, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + var command []string + switch target.Name { + case "core": + command = []string{"exec", "-T", target.Service, "curl", "-fsS", "--max-time", "5", target.URL} + case "frontend": + command = []string{"exec", "-T", target.Service, "wget", "-q", "-T", "5", "-O", "/dev/null", target.URL} + default: + return errors.New("unknown HTTP probe target") + } + result, err := p.runner.Run(probeContext, p.installation.ComposeArgs(command...), nil) + if err != nil { + return errors.New(commandDetail(target.Name+" HTTP probe", result, err, nil)) + } + return nil +} + // Run performs diagnostics only. Expected environmental failures become failed checks so that // callers can always render a complete report; unexpected local read errors are also reported. func Run(ctx context.Context, installation config.Installation, runner Runner) (Report, error) { + return RunWithProbe(ctx, installation, runner, composeHTTPProbe{installation: installation, runner: runner}) +} + +// RunWithProbe performs diagnostics only, with an injectable bounded HTTP probe. +func RunWithProbe(ctx context.Context, installation config.Installation, runner Runner, probe HTTPProbe) (Report, error) { if runner == nil { return Report{}, errors.New("doctor requires a Docker command runner") } + if probe == nil { + return Report{}, errors.New("doctor requires an HTTP probe") + } secretValues := secretValues(installation) - report := Report{Checks: make([]Check, 0, 10)} + report := Report{Checks: make([]Check, 0, 11)} add := func(name, status, detail string) { report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)}) } @@ -59,12 +108,18 @@ func Run(ctx context.Context, installation config.Installation, runner Runner) ( } else { add("descriptor", StatusPassed, "installation descriptor is loaded") } + if err := filePermissions(installation); err != nil { + add("files", StatusFailed, err.Error()) + } else { + add("files", StatusPassed, "declared host files have safe permissions") + } if !commandCheck(ctx, runner, []string{"version", "--format", "{{.Client.Version}}"}, secretValues, add, "docker", "Docker Engine") { add("compose", StatusSkipped, "Docker Engine is unavailable") add("configuration", StatusSkipped, "Docker Engine is unavailable") - add("files", StatusSkipped, "Docker Engine is unavailable") add("services", StatusSkipped, "Docker Engine is unavailable") + add("core-http", StatusSkipped, "core is unavailable") + add("frontend-http", StatusSkipped, "frontend is unavailable") add("workspace-registry", StatusSkipped, "core is unavailable") add("workflow", StatusSkipped, "core is unavailable") add("pi", StatusSkipped, "core is unavailable") @@ -72,8 +127,9 @@ func Run(ctx context.Context, installation config.Installation, runner Runner) ( } if !commandCheck(ctx, runner, []string{"compose", "version", "--short"}, secretValues, add, "compose", "Docker Compose") { add("configuration", StatusSkipped, "Docker Compose is unavailable") - add("files", StatusSkipped, "Docker Compose is unavailable") add("services", StatusSkipped, "Docker Compose is unavailable") + add("core-http", StatusSkipped, "core is unavailable") + add("frontend-http", StatusSkipped, "frontend is unavailable") add("workspace-registry", StatusSkipped, "core is unavailable") add("workflow", StatusSkipped, "core is unavailable") add("pi", StatusSkipped, "core is unavailable") @@ -94,12 +150,6 @@ func Run(ctx context.Context, installation config.Installation, runner Runner) ( add("configuration", StatusPassed, "Compose configuration and required volumes are valid") } - if err := filePermissions(installation); err != nil { - add("files", StatusFailed, err.Error()) - } else { - add("files", StatusPassed, "declared host files have safe permissions") - } - status, statusAvailable := serviceStatus(ctx, installation, runner, secretValues, add) coreRunning := false if statusAvailable { @@ -110,22 +160,51 @@ func Run(ctx context.Context, installation config.Installation, runner Runner) ( } } if !coreRunning { + add("core-http", StatusSkipped, "core is not running") + add("frontend-http", StatusSkipped, "core is not running") add("workspace-registry", StatusSkipped, "core is not running") add("workflow", StatusSkipped, "core is not running") add("pi", StatusSkipped, "core is not running") return finalize(report), nil } - if configReady && workspaceRegistryConfigured(rendered) { - add("workspace-registry", StatusPassed, "workspace-registry volume is configured") - } else { - add("workspace-registry", StatusFailed, "workspace-registry volume is not configured") - } + reachabilityChecks(ctx, probe, secretValues, add) + registryCheck(ctx, installation, runner, secretValues, add, configReady, rendered) workflowCheck(ctx, installation, runner, secretValues, add) piCheck(ctx, installation, runner, secretValues, add) return finalize(report), nil } +func reachabilityChecks(ctx context.Context, probe HTTPProbe, secrets []string, add func(string, string, string)) { + for _, target := range []HTTPProbeTarget{ + {Name: "core", Service: "core", URL: "http://127.0.0.1:8787/health"}, + {Name: "frontend", Service: "frontend", URL: "http://127.0.0.1:8080/"}, + } { + if err := probe.Probe(ctx, target); err != nil { + add(target.Name+"-http", StatusFailed, output.SanitizeDetail(err.Error(), secrets)) + continue + } + add(target.Name+"-http", StatusPassed, target.Name+" answered a bounded HTTP probe") + } +} + +func registryCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string), configReady bool, rendered string) { + if !configReady { + add("workspace-registry", StatusFailed, "workspace-registry cannot be checked because Compose configuration is invalid") + return + } + if !workspaceRegistryDeclared(rendered) { + add("workspace-registry", StatusFailed, "workspace-registry volume is not configured") + return + } + result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "node", "-e", registryValidationProgram), nil) + if err != nil { + add("workspace-registry", StatusFailed, commandDetail("container-local workspace registry", result, err, secrets)) + return + } + add("workspace-registry", StatusPassed, "container-local active registry state and snapshots are valid") +} + func finalize(report Report) Report { report.OK = len(report.Checks) > 0 for _, check := range report.Checks { @@ -238,7 +317,7 @@ func ValidateVolumes(rendered string) error { return nil } -func workspaceRegistryConfigured(rendered string) bool { +func workspaceRegistryDeclared(rendered string) bool { var document struct { Volumes map[string]json.RawMessage `json:"volumes"` } diff --git a/tools/tht/internal/doctor/report_test.go b/tools/tht/internal/doctor/report_test.go index b9c83bca..acb8cff0 100644 --- a/tools/tht/internal/doctor/report_test.go +++ b/tools/tht/internal/doctor/report_test.go @@ -27,6 +27,22 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing. } } +// Catches Docker availability short-circuiting a host file-permission failure. +func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) { + installation := doctorInstallation(t, "") + if err := os.Chmod(installation.EnvFile, 0o644); err != nil { + t.Fatal(err) + } + + report, err := Run(context.Background(), installation, &doctorRunner{dockerUnavailable: true}) + if err != nil { + t.Fatal(err) + } + if checkStatus(report, "files") != StatusFailed { + t.Fatalf("Run() files check = %q, want failed; report = %#v", checkStatus(report, "files"), report) + } +} + // Catches attempts to run in-container diagnostics when core is not running. func TestRunSkipsContainerDiagnosticsWhenCoreIsStopped(t *testing.T) { installation := doctorInstallation(t, "") @@ -56,6 +72,7 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin if !report.OK || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" { t.Fatalf("Run() report = %#v, want successful container diagnostics", report) } + assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) calls := strings.Join(runner.calls, "\n") if !strings.Contains(calls, "exec -T core tht doctor --json") { t.Fatalf("Run() calls = %s, want core-local workflow doctor", calls) @@ -65,6 +82,40 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin } } +// Catches a claimed valid registry based only on the rendered volume declaration. +func TestRunFailsAnInvalidContainerLocalRegistryState(t *testing.T) { + installation := doctorInstallation(t, "") + runner := &doctorRunner{services: healthyServices, registryInvalid: true} + + report, err := Run(context.Background(), installation, runner) + if err != nil { + t.Fatal(err) + } + if report.OK || checkStatus(report, "workspace-registry") != StatusFailed { + t.Fatalf("Run() report = %#v, want failed registry diagnostic", report) + } + if !strings.Contains(strings.Join(runner.calls, "\n"), "workspace-registry/state/active.json") { + t.Fatalf("Run() calls = %v, want an actual registry-state read", runner.calls) + } +} + +// Catches Compose health being treated as proof that the HTTP listeners answer requests. +func TestRunReportsEachHTTPReachabilityProbeFailure(t *testing.T) { + installation := doctorInstallation(t, "") + for _, endpoint := range []string{"core", "frontend"} { + t.Run(endpoint, func(t *testing.T) { + probe := &probeStub{failures: map[string]error{endpoint: errors.New(endpoint + " unreachable")}} + report, err := RunWithProbe(context.Background(), installation, &doctorRunner{services: healthyServices}, probe) + if err != nil { + t.Fatal(err) + } + if report.OK || checkStatus(report, endpoint+"-http") != StatusFailed { + t.Fatalf("RunWithProbe() report = %#v, want failed %s HTTP check", report, endpoint) + } + }) + } +} + // Catches a workflow failure leaking a credential from a declared secret file into a report. func TestRunRedactsWorkflowDiagnosticFailures(t *testing.T) { installation := doctorInstallation(t, "WORKFLOW_TOKEN_FILE=%s\n") @@ -124,6 +175,7 @@ type doctorRunner struct { dockerUnavailable bool services string workflowFailure string + registryInvalid bool } func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { @@ -151,6 +203,11 @@ func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compo return compose.Result{Stderr: r.workflowFailure, ExitCode: 23}, errors.New("workflow failed") } return compose.Result{Stdout: `{"ok":true,"checks":[]}`}, nil + case strings.Contains(call, "workspace-registry/state/active.json"): + if r.registryInvalid { + return compose.Result{ExitCode: 23, Stderr: "invalid workspace registry"}, errors.New("registry invalid") + } + return compose.Result{Stdout: "registry is valid\n"}, nil case strings.Contains(call, "pi --version") || strings.Contains(call, "PI_VERSION") || strings.Contains(call, "io.thothii.pi.version"): return compose.Result{Stdout: "0.80.3\n"}, nil case strings.Contains(call, "test -w /home/thoth/.pi") || strings.Contains(call, "test -r /home/thoth/.pi/agent/auth.json") || strings.Contains(call, "/health"): @@ -163,6 +220,12 @@ func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compo return compose.Result{}, nil } +type probeStub struct{ failures map[string]error } + +func (p *probeStub) Probe(_ context.Context, endpoint HTTPProbeTarget) error { + return p.failures[endpoint.Name] +} + func checkStatus(report Report, name string) string { for _, check := range report.Checks { if check.Name == name { @@ -180,6 +243,17 @@ func reportText(report Report) string { return strings.Join(parts, "\n") } +func assertChecklist(t *testing.T, report Report, want []string) { + t.Helper() + got := make([]string, 0, len(report.Checks)) + for _, check := range report.Checks { + got = append(got, check.Name) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("doctor checklist = %v, want %v", got, want) + } +} + const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` const healthyServices = `[ diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go index 52588d92..8d7f0a5c 100644 --- a/tools/tht/internal/setup/run_test.go +++ b/tools/tht/internal/setup/run_test.go @@ -36,7 +36,7 @@ func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) { want := []string{ "docker engine", "docker compose", "architecture", "compose config", "compose build", - "compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor", + "compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor", } if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") { t.Fatalf("runner stages = %v, want %v", got, want) @@ -124,7 +124,7 @@ func TestRunAggregateDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core") - assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor") + assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workflow doctor", "pi doctor") } func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { @@ -133,7 +133,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core") - assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor") + assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor") } func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) { @@ -280,6 +280,12 @@ func setupStage(args []string) (string, compose.Result) { return "doctor config", compose.Result{Stdout: renderedSetupConfig} case strings.Contains(joined, "exec -T core tht doctor --json"): return "workflow doctor", compose.Result{Stdout: `{"ok":true,"components":{}}`} + case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"): + return "core HTTP", compose.Result{} + case strings.Contains(joined, "exec -T frontend wget -q -T 5 -O /dev/null http://127.0.0.1:8080/"): + return "frontend HTTP", compose.Result{} + case strings.Contains(joined, "workspace-registry/state/active.json"): + return "workspace registry", compose.Result{} case strings.Contains(joined, " ps -q core"): return "pi doctor", compose.Result{Stdout: "core-id\n"} case strings.HasPrefix(joined, "inspect --format"):