feat(cli): add version diagnostics and build-aware start

This commit is contained in:
2026-08-15 23:35:55 +02:00
parent 9a8755e4c3
commit 7ac99b4f7e
11 changed files with 989 additions and 306 deletions
+16 -160
View File
@@ -3,7 +3,6 @@ package setup
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -11,18 +10,14 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/project"
"github.com/aritmolab/thothii/tools/tht/internal/service"
)
const setupHealthTimeout = 5 * time.Minute
var healthPollInterval = time.Second
// Result records the completed setup phases. DescriptorPath always identifies the descriptor
// selected by this invocation, including an idempotent rerun.
type Result struct {
@@ -65,24 +60,19 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
fmt.Fprintf(output, "Configuration is ready: %s\n", result.DescriptorPath)
return result, nil
}
if err := runCompose(ctx, runner, installation, "build"); err != nil {
return Result{}, fmt.Errorf("setup image build: %w", err)
if err := service.Start(ctx, installation, runner, true); err != nil {
if strings.Contains(err.Error(), "image build") {
return Result{}, fmt.Errorf("setup %w", err)
}
return Result{}, withStartupRecovery(fmt.Errorf("setup %w", err), recoveryService(err))
}
result.Built = true
if err := runCompose(ctx, runner, installation, "up", "--detach", "--remove-orphans"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup stack start: %w", err), "core")
result.Built, result.Started, result.Healthy = true, true, true
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup doctor: %w", err), "core")
}
result.Started = true
if err := waitForHealthyServices(ctx, runner, installation); err != nil {
return Result{}, withStartupRecovery(err, recoveryService(err))
}
result.Healthy = true
if err := aggregateDoctor(ctx, runner, installation); err != nil {
return Result{}, withStartupRecovery(err, "core")
}
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
if err := pi.Doctor(ctx, controlled); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup Pi doctor: %w", err), "core")
if !report.OK {
return Result{}, withStartupRecovery(errors.New("setup doctor reported failed checks"), "core")
}
fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath)
return result, nil
@@ -136,17 +126,6 @@ func composeFailure(result compose.Result, cause error) error {
return cause
}
func aggregateDoctor(ctx context.Context, runner compose.Runner, installation config.Installation) error {
result, err := runner.Run(ctx, installation.ComposeArgs("config", "--format", "json"), nil)
if err != nil {
return fmt.Errorf("setup doctor: %w", composeFailure(result, err))
}
if err := requireVolumes(result.Stdout); err != nil {
return fmt.Errorf("setup doctor: %w", err)
}
return nil
}
func withStartupRecovery(cause error, service string) error {
if service == "" {
service = "core"
@@ -155,121 +134,13 @@ func withStartupRecovery(cause error, service string) error {
}
func recoveryService(cause error) string {
var healthFailure healthFailure
if errors.As(cause, &healthFailure) && healthFailure.service != "" {
return healthFailure.service
var healthFailure service.HealthFailure
if errors.As(cause, &healthFailure) && healthFailure.Service != "" {
return healthFailure.Service
}
return "core"
}
type serviceStatus struct {
Service string `json:"Service"`
State string `json:"State"`
Health string `json:"Health"`
ExitCode json.RawMessage `json:"ExitCode"`
}
type healthFailure struct {
service string
state string
}
func (e healthFailure) Error() string {
return fmt.Sprintf("setup health timed out waiting for %s (last state: %s)", e.service, e.state)
}
func waitForHealthyServices(ctx context.Context, runner compose.Runner, installation config.Installation) error {
healthContext, cancel := context.WithTimeout(ctx, setupHealthTimeout)
defer cancel()
lastService, lastState := "core", "unknown"
for {
result, err := runner.Run(healthContext, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
if err == nil {
statuses, parseErr := parseServiceStatuses(result.Stdout)
if parseErr == nil {
if service, state, ready := healthyServices(statuses); ready {
return nil
} else {
lastService, lastState = service, state
}
} else {
lastState = "Compose returned invalid status"
}
} else if result.ExitCode != 0 {
lastState = fmt.Sprintf("Compose exited with status %d", result.ExitCode)
} else {
lastState = "Compose status command failed"
}
select {
case <-healthContext.Done():
return healthFailure{service: lastService, state: lastState}
case <-time.After(healthPollInterval):
}
}
}
func parseServiceStatuses(value string) ([]serviceStatus, error) {
var statuses []serviceStatus
if err := json.Unmarshal([]byte(value), &statuses); err == nil && len(statuses) > 0 {
return statuses, nil
}
decoder := json.NewDecoder(strings.NewReader(value))
for {
var status serviceStatus
err := decoder.Decode(&status)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.New("Compose returned invalid service status")
}
statuses = append(statuses, status)
}
if len(statuses) == 0 {
return nil, errors.New("Compose returned invalid service status")
}
return statuses, nil
}
func healthyServices(statuses []serviceStatus) (string, string, bool) {
wanted := map[string]bool{"core": false, "frontend": false, "qdrant": false, "embedding": false, "embedding-model-init": false}
for _, status := range statuses {
if _, required := wanted[status.Service]; !required {
continue
}
if status.Service == "embedding-model-init" {
if strings.EqualFold(status.State, "exited") && exitCodeZero(status.ExitCode) {
wanted[status.Service] = true
continue
}
return status.Service, status.State, false
}
if strings.EqualFold(status.State, "running") && strings.EqualFold(status.Health, "healthy") {
wanted[status.Service] = true
continue
}
return status.Service, strings.TrimSpace(status.State + "/" + status.Health), false
}
for _, service := range []string{"core", "frontend", "qdrant", "embedding", "embedding-model-init"} {
if !wanted[service] {
return service, "not reported by Docker Compose", false
}
}
return "", "", true
}
func exitCodeZero(value json.RawMessage) bool {
if len(value) == 0 || string(value) == "null" {
return false
}
var number int
if json.Unmarshal(value, &number) == nil {
return number == 0
}
var text string
return json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) == "0"
}
func frontendURL(installation config.Installation) string {
port, err := installation.EnvironmentValue("THOTH_HTTP_PORT")
if err != nil || strings.TrimSpace(port) == "" {
@@ -281,21 +152,6 @@ func frontendURL(installation config.Installation) string {
return "http://127.0.0.1:" + port
}
func requireVolumes(renderedConfig string) error {
var document struct {
Volumes map[string]json.RawMessage `json:"volumes"`
}
if err := json.Unmarshal([]byte(renderedConfig), &document); err != nil {
return errors.New("Compose returned invalid rendered configuration")
}
for _, volume := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} {
if _, exists := document.Volumes[volume]; !exists {
return fmt.Errorf("rendered Compose configuration is missing required volume %s", volume)
}
}
return nil
}
func requireLF(root string) error {
for _, path := range []string{filepath.Join(root, "compose.yaml"), filepath.Join(root, "deploy"), filepath.Join(root, "docker")} {
if err := requireLFPath(path); err != nil {