feat: manage embedded pi with thothctl

This commit is contained in:
2026-08-04 18:20:39 +02:00
parent c90299f24d
commit 20e59b8d32
8 changed files with 1122 additions and 1 deletions
+120
View File
@@ -0,0 +1,120 @@
package pi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
)
// Runner is the narrow, shell-free command boundary shared with thothctl.
type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error)
}
// Status reports the image-bundled Pi version without using a host Pi executable.
func Status(ctx context.Context, runner Runner) (string, error) {
result, err := runCompose(ctx, runner, "exec", "-T", "core", "pi", "--version")
if err != nil {
return "", commandError("Pi version check", result, err)
}
version := strings.TrimSpace(result.Stdout)
if version == "" {
return "", errors.New("Pi version check returned no version")
}
return version, nil
}
// Doctor verifies the installation-side invariants Pi needs before an update.
func Doctor(ctx context.Context, runner Runner) error {
if _, err := renderedCore(ctx, runner); err != nil {
return err
}
if _, err := Status(ctx, runner); err != nil {
return err
}
for _, check := range [][]string{
{"exec", "-T", "core", "sh", "-ceu", "test -w /home/thoth/.pi"},
{"exec", "-T", "core", "sh", "-ceu", "test -r /home/thoth/.pi/agent/auth.json"},
{"exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health"},
} {
result, err := runCompose(ctx, runner, check...)
if err != nil {
return commandError("Pi preflight check", result, err)
}
}
return nil
}
// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint.
func Test(ctx context.Context, runner Runner) error {
if _, err := Status(ctx, runner); err != nil {
return err
}
for _, path := range []string{"health", "models", "settings"} {
result, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/"+path)
if err != nil {
return commandError("Pi smoke check", result, err)
}
var payload any
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil {
return fmt.Errorf("Pi smoke check returned invalid %s response", path)
}
if _, ok := payload.(map[string]any); !ok {
return fmt.Errorf("Pi smoke check returned invalid %s response", path)
}
}
return nil
}
func renderedCore(ctx context.Context, runner Runner) (Image, error) {
result, err := runCompose(ctx, runner, "config", "--format", "json")
if err != nil {
return Image{}, commandError("Compose configuration check", result, err)
}
var document struct {
Services map[string]struct {
Image string `json:"image"`
Environment map[string]any `json:"environment"`
} `json:"services"`
}
if err := json.Unmarshal([]byte(result.Stdout), &document); err != nil {
return Image{}, errors.New("Compose returned invalid rendered configuration")
}
core, exists := document.Services["core"]
if !exists || core.Image == "" {
return Image{}, errors.New("rendered Compose configuration has no core image")
}
endpoint, exists := core.Environment["THT_LLM_URL"].(string)
if !exists || strings.TrimSpace(endpoint) == "" {
return Image{}, errors.New("THT_LLM_URL must be configured before Pi lifecycle operations")
}
digest := sha256.Sum256([]byte(result.Stdout))
return Image{Reference: core.Image, ConfigurationSHA: fmt.Sprintf("%x", digest[:])}, nil
}
func runCompose(ctx context.Context, runner Runner, args ...string) (compose.Result, error) {
return runner.Run(ctx, append([]string{"compose"}, args...), nil)
}
func commandError(label string, result compose.Result, err error) error {
if result.ExitCode != 0 {
return commandFailure{message: fmt.Sprintf("%s failed (exit %d)", label, result.ExitCode), exitCode: result.ExitCode}
}
return commandFailure{message: fmt.Sprintf("%s failed", label)}
}
type commandFailure struct {
message string
exitCode int
}
func (e commandFailure) Error() string { return e.message }
// ExitCode exposes a Docker child exit code without exposing its output.
func (e commandFailure) ExitCode() int { return e.exitCode }
@@ -0,0 +1,30 @@
package pi
import (
"context"
"strings"
"testing"
)
func TestDoctorRequiresExternalEndpointAuthPiStateAndHealth(t *testing.T) {
fake := newFakeRunner()
if err := Doctor(context.Background(), fake); err != nil {
t.Fatalf("Doctor() error = %v", err)
}
for _, command := range []string{"pi --version", "test -w /home/thoth/.pi", "test -r /home/thoth/.pi/agent/auth.json", "/health"} {
assertCalled(t, fake.calls, command)
}
}
func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) {
fake := newFakeRunner()
if err := Test(context.Background(), fake); err != nil {
t.Fatalf("Test() error = %v", err)
}
for _, command := range []string{"pi --version", "/health", "/models", "/settings"} {
assertCalled(t, fake.calls, command)
}
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") {
t.Fatalf("probe commands expose secret: %s", got)
}
}
+106
View File
@@ -0,0 +1,106 @@
// Package pi implements host-side lifecycle operations for the Pi bundled in core.
package pi
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
const stateFileVersion = 1
// Phase describes the durable point reached by a Pi update.
type Phase string
const (
PhasePreflight Phase = "preflight"
PhaseBuilding Phase = "building"
PhaseRecreated Phase = "recreated"
PhaseVerified Phase = "verified"
PhaseRolledBack Phase = "rolled_back"
PhaseFailed Phase = "failed"
PhaseNoop Phase = "noop"
)
// Image is the non-secret recovery identity of a core image and its mounted volume names.
type Image struct {
ID string `json:"id"`
Reference string `json:"reference"`
Volumes []string `json:"volumes"`
ConfigurationSHA string `json:"configuration_sha256,omitempty"`
}
// Target records the immutable input selected by the operator. Source is either build or a
// digest-pinned image reference; it intentionally never contains credentials.
type Target struct {
Version string `json:"version"`
Source string `json:"source"`
}
// State is recovery metadata stored below the installation project. It never stores environment
// values, secret paths, credentials, or command output.
type State struct {
Version int `json:"version"`
Phase Phase `json:"phase"`
UpdatedAt time.Time `json:"updated_at"`
Target Target `json:"target,omitempty"`
Previous Image `json:"previous"`
Candidate Image `json:"candidate,omitempty"`
Error string `json:"error,omitempty"`
}
func readState(path string) (State, error) {
contents, err := os.ReadFile(path)
if err != nil {
return State{}, err
}
var state State
if err := json.Unmarshal(contents, &state); err != nil {
return State{}, errors.New("update recovery state is invalid")
}
if state.Version != stateFileVersion || state.Previous.ID == "" || state.Previous.Reference == "" {
return State{}, errors.New("update recovery state is incomplete")
}
return state, nil
}
func writeState(path string, state State) error {
if state.Previous.ID == "" || state.Previous.Reference == "" {
return errors.New("refusing to write incomplete update recovery state")
}
state.Version = stateFileVersion
state.UpdatedAt = time.Now().UTC()
contents, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode update recovery state: %w", err)
}
contents = append(contents, '\n')
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return errors.New("could not create update recovery directory")
}
temporary, err := os.CreateTemp(directory, ".update-state-*.tmp")
if err != nil {
return errors.New("could not write update recovery state")
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return errors.New("could not protect update recovery state")
}
if _, err := temporary.Write(contents); err != nil {
temporary.Close()
return errors.New("could not write update recovery state")
}
if err := temporary.Close(); err != nil {
return errors.New("could not write update recovery state")
}
if err := os.Rename(temporaryName, path); err != nil {
return errors.New("could not finalize update recovery state")
}
return nil
}
+318
View File
@@ -0,0 +1,318 @@
package pi
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
var (
ErrConfirmationRequired = errors.New("update requires --yes after reviewing the planned Pi version")
ErrActiveSessions = errors.New("active sessions must be drained before updating Pi; use --drain only after they are complete")
ErrInterruptedUpdate = errors.New("a previous Pi update is incomplete; run pi rollback --yes before starting another update")
versionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][0-9A-Za-z.-]+)?$`)
digestPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*@sha256:[a-f0-9]{64}$`)
)
// Source chooses whether the candidate is built from this checkout or pulled from an immutable image.
type Source string
const (
BuildSource Source = "build"
PullSource Source = "pull"
)
// Request contains only non-secret operator inputs.
type Request struct {
StatePath string
Version string
Source Source
Image string
Confirm bool
Drain bool
}
// Result summarizes the completed, failed, or recovered transaction without command output.
type Result struct {
Phase Phase
StatePath string
}
// Update performs a recoverable core-only Pi update using the default Compose command layout.
func Update(ctx context.Context, runner Runner, request Request) (Result, error) {
if request.StatePath == "" {
return Result{}, errors.New("update state path is required")
}
if !request.Confirm {
return Result{StatePath: request.StatePath}, ErrConfirmationRequired
}
if !versionPattern.MatchString(request.Version) {
return Result{StatePath: request.StatePath}, errors.New("Pi version must be an explicit pinned version")
}
if request.Source == "" {
request.Source = BuildSource
}
if request.Source != BuildSource && request.Source != PullSource {
return Result{StatePath: request.StatePath}, errors.New("Pi update source must be build or pull")
}
if request.Source == PullSource && !digestPattern.MatchString(request.Image) {
return Result{StatePath: request.StatePath}, errors.New("pulled Pi image must use an immutable sha256 digest")
}
if old, err := readState(request.StatePath); err == nil && old.Phase != PhaseVerified && old.Phase != PhaseRolledBack && old.Phase != PhaseNoop {
return Result{StatePath: request.StatePath}, ErrInterruptedUpdate
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return Result{StatePath: request.StatePath}, err
}
running, err := activeSessions(ctx, runner)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
if running {
if !request.Drain {
return Result{StatePath: request.StatePath}, ErrActiveSessions
}
running, err = activeSessions(ctx, runner)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
if running {
return Result{StatePath: request.StatePath}, ErrActiveSessions
}
}
if err := Doctor(ctx, runner); err != nil {
return Result{StatePath: request.StatePath}, err
}
currentVersion, err := Status(ctx, runner)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
if currentVersion == request.Version {
return Result{Phase: PhaseNoop, StatePath: request.StatePath}, nil
}
configured, err := renderedCore(ctx, runner)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
previous, err := runningImage(ctx, runner, configured.Reference)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
previous.ConfigurationSHA = configured.ConfigurationSHA
state := State{Phase: PhasePreflight, Target: Target{Version: request.Version, Source: sourceValue(request)}, Previous: previous}
if err := writeState(request.StatePath, state); err != nil {
return Result{StatePath: request.StatePath}, err
}
state.Phase = PhaseBuilding
if err := writeState(request.StatePath, state); err != nil {
return Result{StatePath: request.StatePath}, err
}
if err := prepareCandidate(ctx, runner, request, previous.Reference); err != nil {
state.Phase, state.Error = PhaseFailed, "candidate image preparation failed"
_ = writeState(request.StatePath, state)
return Result{Phase: PhaseFailed, StatePath: request.StatePath}, err
}
if err := recreateCore(ctx, runner); err != nil {
state.Phase, state.Error = PhaseFailed, "core recreation failed"
_ = writeState(request.StatePath, state)
return Result{Phase: PhaseFailed, StatePath: request.StatePath}, err
}
state.Phase = PhaseRecreated
state.Candidate, _ = runningImage(ctx, runner, previous.Reference)
if err := writeState(request.StatePath, state); err != nil {
return Result{Phase: PhaseRecreated, StatePath: request.StatePath}, err
}
if err := verifyCandidate(ctx, runner, request.Version, previous.Volumes, previous.ConfigurationSHA); err != nil {
return rollbackAfterFailure(ctx, runner, request.StatePath, state, err)
}
state.Phase, state.Error = PhaseVerified, ""
if err := writeState(request.StatePath, state); err != nil {
return Result{Phase: PhaseVerified, StatePath: request.StatePath}, err
}
return Result{Phase: PhaseVerified, StatePath: request.StatePath}, nil
}
// Rollback restores the image recorded in durable update state. It is safe for interrupted runs.
func Rollback(ctx context.Context, runner Runner, statePath string, confirm bool) (Result, error) {
if !confirm {
return Result{StatePath: statePath}, ErrConfirmationRequired
}
state, err := readState(statePath)
if err != nil {
return Result{StatePath: statePath}, err
}
if err := restore(ctx, runner, state.Previous); err != nil {
state.Phase, state.Error = PhaseFailed, "rollback failed"
_ = writeState(statePath, state)
return Result{Phase: PhaseFailed, StatePath: statePath}, err
}
state.Phase, state.Error = PhaseRolledBack, ""
if err := writeState(statePath, state); err != nil {
return Result{Phase: PhaseRolledBack, StatePath: statePath}, err
}
return Result{Phase: PhaseRolledBack, StatePath: statePath}, nil
}
func rollbackAfterFailure(ctx context.Context, runner Runner, statePath string, state State, cause error) (Result, error) {
if restoreErr := restore(ctx, runner, state.Previous); restoreErr != nil {
state.Phase, state.Error = PhaseFailed, "candidate verification and automatic rollback failed"
_ = writeState(statePath, state)
return Result{Phase: PhaseFailed, StatePath: statePath}, fmt.Errorf("candidate verification failed; automatic rollback also failed")
}
state.Phase, state.Error = PhaseRolledBack, ""
_ = writeState(statePath, state)
return Result{Phase: PhaseRolledBack, StatePath: statePath}, fmt.Errorf("candidate verification failed; previous core image was restored")
}
func sourceValue(request Request) string {
if request.Source == PullSource {
return request.Image
}
return string(BuildSource)
}
func activeSessions(ctx context.Context, runner Runner) (bool, error) {
result, err := runCompose(ctx, runner, "exec", "-T", "core", "tht", "session", "list", "--json")
if err != nil {
return false, commandError("active-session check", result, err)
}
var sessions []struct {
Status string `json:"status"`
Archived bool `json:"archived"`
}
if err := json.Unmarshal([]byte(result.Stdout), &sessions); err != nil {
return false, errors.New("active-session check returned invalid session data")
}
for _, session := range sessions {
if !session.Archived && session.Status == "open" {
return true, nil
}
}
return false, nil
}
func runningImage(ctx context.Context, runner Runner, reference string) (Image, error) {
container, err := runCompose(ctx, runner, "ps", "-q", "core")
if err != nil || strings.TrimSpace(container.Stdout) == "" {
return Image{}, commandError("running core image check", container, err)
}
id := strings.TrimSpace(container.Stdout)
image, err := runner.Run(ctx, []string{"inspect", "--format", "{{.Image}}", id}, nil)
if err != nil || strings.TrimSpace(image.Stdout) == "" {
return Image{}, commandError("running core image check", image, err)
}
mounts, err := runner.Run(ctx, []string{"inspect", "--format", "{{range .Mounts}}{{if eq .Type \"volume\"}}{{.Name}}{{\"\\n\"}}{{end}}{{end}}", id}, nil)
if err != nil {
return Image{}, commandError("core volume check", mounts, err)
}
volumes := nonEmptyLines(mounts.Stdout)
if len(volumes) == 0 {
return Image{}, errors.New("core has no named volumes to preserve")
}
return Image{ID: strings.TrimSpace(image.Stdout), Reference: reference, Volumes: volumes}, nil
}
func prepareCandidate(ctx context.Context, runner Runner, request Request, reference string) error {
if request.Source == BuildSource {
result, err := runCompose(ctx, runner, "build", "--pull", "--build-arg", "PI_VERSION="+request.Version, "core")
if err != nil {
return commandError("Pi image build", result, err)
}
return nil
}
pull, err := runner.Run(ctx, []string{"pull", request.Image}, nil)
if err != nil {
return commandError("Pi image pull", pull, err)
}
tag, err := runner.Run(ctx, []string{"image", "tag", request.Image, reference}, nil)
if err != nil {
return commandError("Pi image tag", tag, err)
}
return nil
}
func recreateCore(ctx context.Context, runner Runner) error {
result, err := runCompose(ctx, runner, "up", "--detach", "--no-deps", "--force-recreate", "core")
if err != nil {
return commandError("core recreation", result, err)
}
return nil
}
func verifyCandidate(ctx context.Context, runner Runner, wanted string, previousVolumes []string, configurationSHA string) error {
health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health")
if err != nil {
return commandError("core health check", health, err)
}
version, err := Status(ctx, runner)
if err != nil {
return err
}
if version != wanted {
return errors.New("candidate Pi version does not match requested pinned version")
}
if err := Test(ctx, runner); err != nil {
return err
}
configured, err := renderedCore(ctx, runner)
if err != nil {
return err
}
if configured.ConfigurationSHA != configurationSHA {
return errors.New("external endpoint configuration changed during Pi update")
}
after, err := runningImage(ctx, runner, configured.Reference)
if err != nil {
return err
}
if !sameStrings(previousVolumes, after.Volumes) {
return errors.New("core volume set changed during Pi update")
}
return nil
}
func restore(ctx context.Context, runner Runner, previous Image) error {
tag, err := runner.Run(ctx, []string{"image", "tag", previous.ID, previous.Reference}, nil)
if err != nil {
return commandError("rollback image restore", tag, err)
}
if err := recreateCore(ctx, runner); err != nil {
return err
}
configured, err := renderedCore(ctx, runner)
if err != nil {
return err
}
after, err := runningImage(ctx, runner, configured.Reference)
if err != nil {
return err
}
if !sameStrings(previous.Volumes, after.Volumes) {
return errors.New("core volume set changed during rollback")
}
return nil
}
func nonEmptyLines(text string) []string {
var values []string
for _, value := range strings.Split(text, "\n") {
if value = strings.TrimSpace(value); value != "" {
values = append(values, value)
}
}
sort.Strings(values)
return values
}
func sameStrings(left, right []string) bool {
left, right = append([]string(nil), left...), append([]string(nil), right...)
sort.Strings(left)
sort.Strings(right)
return strings.Join(left, "\x00") == strings.Join(right, "\x00")
}
+256
View File
@@ -0,0 +1,256 @@
package pi
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
)
func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) {
fake := newFakeRunner()
dir := t.TempDir()
result, err := Update(context.Background(), fake, Request{
StatePath: filepath.Join(dir, ".thothctl", "update-state.json"),
Version: "0.81.0",
Source: BuildSource,
Confirm: true,
})
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if result.Phase != PhaseVerified {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified)
}
assertCalled(t, fake.calls, "compose build --pull --build-arg PI_VERSION=0.81.0 core")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core")
assertNotCalled(t, fake.calls, "frontend")
if got := string(readStateBytes(t, result.StatePath)); strings.Contains(got, "secret") || !strings.Contains(got, `"phase": "verified"`) {
t.Fatalf("state = %q, want credential-free verified metadata", got)
}
if got := string(readStateBytes(t, result.StatePath)); strings.Contains(got, "llm.example.invalid") {
t.Fatalf("state = %q, want an endpoint-free configuration digest", got)
}
}
func TestUpdatePullsOnlyDigestPinnedSource(t *testing.T) {
fake := newFakeRunner()
digest := "registry.example.invalid/thothii-core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
_, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: PullSource, Image: digest, Confirm: true})
if err == nil {
t.Fatal("Update() error = nil, want Pi version verification failure from unchanged fake image")
}
assertCalled(t, fake.calls, "pull "+digest)
assertCalled(t, fake.calls, "image tag "+digest+" thothii-core:local")
fake = newFakeRunner()
_, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: PullSource, Image: "registry.example.invalid/thothii-core:latest", Confirm: true})
if err == nil || !strings.Contains(err.Error(), "immutable sha256 digest") {
t.Fatalf("Update() error = %v, want digest-pinning rejection", err)
}
assertNotCalled(t, fake.calls, "compose")
}
func TestUpdateIsNoOpWhenDesiredVersionAlreadyRuns(t *testing.T) {
fake := newFakeRunner()
fake.version = "0.80.3"
result, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.80.3", Source: BuildSource, Confirm: true})
if err != nil {
t.Fatalf("Update() error = %v", err)
}
if result.Phase != PhaseNoop {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseNoop)
}
assertNotCalled(t, fake.calls, "compose build")
}
func TestUpdateRollsBackAfterPostRecreateFailures(t *testing.T) {
for _, failure := range []string{"health", "version", "smoke"} {
t.Run(failure, func(t *testing.T) {
fake := newFakeRunner()
fake.fail = failure
statePath := filepath.Join(t.TempDir(), "state.json")
result, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true})
if err == nil {
t.Fatal("Update() error = nil, want verification failure")
}
if result.Phase != PhaseRolledBack {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack)
}
assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core")
if strings.Join(fake.volumes, ",") != "settings,pi-state,sessions,workspace-registry" {
t.Fatalf("volumes changed: %v", fake.volumes)
}
if got := string(readStateBytes(t, statePath)); !strings.Contains(got, `"phase": "rolled_back"`) {
t.Fatalf("state = %q, want rollback metadata", got)
}
})
}
}
func TestUpdateDoesNotRecreateWhenPreflightOrBuildFails(t *testing.T) {
for _, failure := range []string{"preflight", "build"} {
t.Run(failure, func(t *testing.T) {
fake := newFakeRunner()
fake.fail = failure
result, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true})
if err == nil {
t.Fatal("Update() error = nil, want failure")
}
if result.Phase == PhaseRolledBack {
t.Fatalf("pre-recreate failure unexpectedly rolled back: %+v", result)
}
assertNotCalled(t, fake.calls, "force-recreate")
})
}
}
func TestUpdateRequiresConfirmationAndDrainsActiveSessions(t *testing.T) {
fake := newFakeRunner()
_, err := Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource})
if !errors.Is(err, ErrConfirmationRequired) {
t.Fatalf("Update() error = %v, want confirmation error", err)
}
assertNotCalled(t, fake.calls, "compose")
fake = newFakeRunner()
fake.activeSessions = true
_, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true})
if !errors.Is(err, ErrActiveSessions) {
t.Fatalf("Update() error = %v, want active-sessions error", err)
}
fake = newFakeRunner()
fake.activeSessions = true
_, err = Update(context.Background(), fake, Request{StatePath: filepath.Join(t.TempDir(), "state.json"), Version: "0.81.0", Source: BuildSource, Confirm: true, Drain: true})
if err != nil {
t.Fatalf("Update() with drain error = %v", err)
}
assertCalled(t, fake.calls, "compose exec -T core tht session list --json")
}
func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) {
fake := newFakeRunner()
statePath := filepath.Join(t.TempDir(), "state.json")
writeStateForTest(t, statePath, State{Version: 1, Phase: PhaseRecreated, Previous: Image{ID: "sha256:old", Reference: "thothii-core:local", Volumes: []string{"settings", "pi-state", "sessions", "workspace-registry"}}})
result, err := Rollback(context.Background(), fake, statePath, true)
if err != nil {
t.Fatalf("Rollback() error = %v", err)
}
if result.Phase != PhaseRolledBack {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseRolledBack)
}
assertCalled(t, fake.calls, "image tag sha256:old thothii-core:local")
assertCalled(t, fake.calls, "compose up --detach --no-deps --force-recreate core")
}
func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) {
fake := newFakeRunner()
statePath := filepath.Join(t.TempDir(), "state.json")
writeStateForTest(t, statePath, State{Phase: PhaseRecreated, Previous: Image{ID: "sha256:old", Reference: "thothii-core:local", Volumes: []string{"settings"}}})
_, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true})
if !errors.Is(err, ErrInterruptedUpdate) {
t.Fatalf("Update() error = %v, want interrupted update error", err)
}
assertNotCalled(t, fake.calls, "compose")
}
type fakeRunner struct {
calls []string
fail string
version string
activeSessions bool
built bool
volumes []string
}
func newFakeRunner() *fakeRunner {
return &fakeRunner{version: "0.80.3", volumes: []string{"settings", "pi-state", "sessions", "workspace-registry"}}
}
func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
call := strings.Join(args, " ")
f.calls = append(f.calls, call)
if f.fail == "preflight" && strings.Contains(call, "config --format json") {
return compose.Result{ExitCode: 1}, errors.New("provider token=secret")
}
if f.fail == "build" && strings.Contains(call, "compose build") {
return compose.Result{ExitCode: 1}, errors.New("build token=secret")
}
if f.fail == "health" && f.built && strings.Contains(call, "curl -fsS http://127.0.0.1:8787/health") {
return compose.Result{ExitCode: 1}, errors.New("health token=secret")
}
if f.fail == "version" && f.built && strings.Contains(call, "pi --version") && strings.Contains(call, "exec") {
return compose.Result{ExitCode: 1}, errors.New("version token=secret")
}
if f.fail == "smoke" && strings.Contains(call, "curl -fsS http://127.0.0.1:8787/models") {
return compose.Result{ExitCode: 1}, errors.New("smoke token=secret")
}
switch {
case strings.Contains(call, "config --format json"):
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`}, nil
case strings.Contains(call, "ps -q core"):
return compose.Result{Stdout: "core-container\n"}, nil
case strings.Contains(call, "inspect --format {{.Image}}"):
return compose.Result{Stdout: "sha256:old\n"}, nil
case strings.Contains(call, "inspect --format {{range .Mounts}}"):
return compose.Result{Stdout: strings.Join(f.volumes, "\n") + "\n"}, nil
case strings.Contains(call, "tht session list --json"):
if f.activeSessions {
f.activeSessions = false
return compose.Result{Stdout: `[{"status":"open","archived":false}]`}, nil
}
return compose.Result{Stdout: "[]"}, nil
case strings.Contains(call, "compose build"):
f.built = true
f.version = "0.81.0"
return compose.Result{}, nil
case strings.Contains(call, "pi --version"):
return compose.Result{Stdout: f.version + "\n"}, nil
case strings.Contains(call, "/models"):
return compose.Result{Stdout: `{"models":[{"id":"model","provider":"provider"}]}`}, nil
case strings.Contains(call, "/settings"):
return compose.Result{Stdout: `{"provider":"provider","model":"model","thinking":"medium"}`}, nil
case strings.Contains(call, "/health"):
return compose.Result{Stdout: `{"status":"ok"}`}, nil
}
return compose.Result{}, nil
}
func assertCalled(t *testing.T, calls []string, want string) {
t.Helper()
for _, call := range calls {
if strings.Contains(call, want) {
return
}
}
t.Fatalf("calls %v did not include %q", calls, want)
}
func assertNotCalled(t *testing.T, calls []string, prohibited string) {
t.Helper()
for _, call := range calls {
if strings.Contains(call, prohibited) {
t.Fatalf("calls %v unexpectedly included %q", calls, prohibited)
}
}
}
func readStateBytes(t *testing.T, path string) []byte {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return contents
}
func writeStateForTest(t *testing.T, path string, state State) {
t.Helper()
if err := writeState(path, state); err != nil {
t.Fatal(err)
}
}