feat(pi): update to the latest stable release by default

This commit is contained in:
2026-08-16 00:13:51 +02:00
parent 0cb6a3f915
commit 5a657a67e8
6 changed files with 586 additions and 55 deletions
+31 -7
View File
@@ -54,8 +54,10 @@ Commands:
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
pi restart --yes [--drain]
Recreate only core with the currently selected Pi image and verify readiness.
pi update [--version V]
Rebuild the latest stable Pi release, or an explicit version, and recreate only core.
pi update --version V --source build --yes [--drain]
Rebuild a pinned Pi version and recreate only core.
Advanced explicit build form retained for compatibility.
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
Pull an immutable candidate and recreate only core.
pi rollback --yes Restore the image recorded by the latest Pi update.
@@ -75,6 +77,11 @@ Commands:
workspace preprocess run --workspace ID [--resume RUN] [--json]
`
var (
newPiRegistryClient = pi.NewNPMRegistryClient
readPiRuntimePackageName = pi.ReadRuntimePackageName
)
func main() {
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
@@ -487,12 +494,21 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
if err != nil {
return commandUsageError(stderr, err.Error())
}
result, err := pi.Update(ctx, controlled, request)
var registry pi.RegistryClient
packageName := ""
if request.Version == "" {
packageName, err = readPiRuntimePackageName(installation.ProjectDirectory)
if err != nil {
return commandUsageError(stderr, "pi update latest-version lookup is unavailable: "+err.Error())
}
registry = newPiRegistryClient()
}
result, err := pi.UpdateWithResolvedVersion(ctx, controlled, request, packageName, registry)
if err != nil {
return piFailure(stderr, err, secretValues)
}
if result.Phase == pi.PhaseNoop {
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", request.Version)
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", result.Version)
return 0
}
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath)
@@ -647,6 +663,7 @@ func parsePiConfigureArgs(args []string) (pi.Defaults, error) {
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
sourceSpecified := false
for len(args) > 0 {
switch args[0] {
case "--version":
@@ -659,6 +676,7 @@ func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Re
return pi.Request{}, errors.New("--source requires build or pull")
}
request.Source, args = pi.Source(args[1]), args[2:]
sourceSpecified = true
case "--image":
if len(args) < 2 || request.Image != "" {
return pi.Request{}, errors.New("--image requires one digest-pinned image reference")
@@ -678,11 +696,17 @@ func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Re
return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
}
}
if request.Version == "" {
return pi.Request{}, errors.New("pi update requires --version <pinned-version>")
if !sourceSpecified {
if request.Image != "" {
return pi.Request{}, errors.New("--image is valid only with --source pull")
}
request.Source = pi.BuildSource
request.Confirm = true
request.Drain = true
return request, nil
}
if request.Source == "" {
return pi.Request{}, errors.New("pi update requires explicit --source build or pull")
if request.Version == "" {
return pi.Request{}, errors.New("pi update with --source requires --version <pinned-version>")
}
if request.Source != pi.BuildSource && request.Source != pi.PullSource {
return pi.Request{}, errors.New("--source requires build or pull")
+138 -40
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
@@ -13,6 +14,7 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/setup"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
@@ -60,7 +62,7 @@ func TestRootCommandIdentity(t *testing.T) {
name: "retired command is not an alias",
args: []string{"--installation", fixture.installationPath, retiredCommand},
wantCode: 2,
wantText: `tht: unknown command "` + retiredCommand + `"`,
wantText: "tht: unknown command \"" + retiredCommand + "\"",
},
} {
t.Run(test.name, func(t *testing.T) {
@@ -313,6 +315,35 @@ func TestParsePiRestartArgs(t *testing.T) {
}
}
func TestParsePiUpdateArgsDefaultsToLatestStableBuildForTheShortCommand(t *testing.T) {
statePath := "/var/lib/tht/update-state.json"
restartPath := "/var/lib/tht/restart-state.json"
got, err := parsePiUpdateArgs(nil, statePath, restartPath)
if err != nil {
t.Fatal(err)
}
want := pi.Request{
StatePath: statePath,
RestartStatePath: restartPath,
Source: pi.BuildSource,
Confirm: true,
Drain: true,
}
if got != want {
t.Fatalf("parsePiUpdateArgs(nil) = %#v, want %#v", got, want)
}
}
func TestParsePiUpdateArgsAllowsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
got, err := parsePiUpdateArgs([]string{"--version", "0.81.0"}, "update.json", "restart.json")
if err != nil {
t.Fatal(err)
}
if got.Version != "0.81.0" || got.Source != pi.BuildSource || !got.Confirm || !got.Drain {
t.Fatalf("parsePiUpdateArgs() = %#v, want explicit version with safe build defaults", got)
}
}
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server")
@@ -569,8 +600,8 @@ func TestRunRedactsSecretWhenDoctorFails(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 41 {
t.Errorf("run() exit code = %d, want 41", exitCode)
if exitCode != 1 {
t.Errorf("run() exit code = %d, want normalized doctor failure exit 1", exitCode)
}
if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") {
t.Errorf("doctor failure exposed secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
@@ -800,15 +831,9 @@ func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if stdout.String() != "Doctor checks passed.\n" {
t.Errorf("stdout = %q, want doctor success", stdout.String())
}
report := runDoctorJSON(t, fixture)
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
}
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
@@ -820,15 +845,9 @@ func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T)
filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"),
)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if stdout.String() != "Doctor checks passed.\n" {
t.Errorf("stdout = %q, want doctor success", stdout.String())
}
report := runDoctorJSON(t, fixture)
assertDoctorCheck(t, report, "files", doctor.StatusPassed)
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
}
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
@@ -837,18 +856,14 @@ func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
t.Setenv(
"THT_FAKE_PS",
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
"{\"Service\":\"frontend\",\"State\":\"running\",\"Health\":\"healthy\"}",
"{\"Service\":\"frontend\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
"{\"Service\":\"qdrant\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
"{\"Service\":\"embedding\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+
"{\"Service\":\"embedding-model-init\",\"State\":\"exited\",\"ExitCode\":0}",
)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if stdout.String() != "Doctor checks passed.\n" {
t.Errorf("stdout = %q, want doctor success", stdout.String())
}
report := runDoctorJSON(t, fixture)
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
}
func TestRunPreservesChildExitCodes(t *testing.T) {
@@ -883,6 +898,28 @@ func TestRunPiStatusUsesImageBundledPi(t *testing.T) {
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version")
}
func TestRunPiUpdateResolvesLatestStableVersionWhenVersionIsOmitted(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
if err := os.WriteFile(filepath.Join(fixture.projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.79.0\n"), 0o600); err != nil {
t.Fatal(err)
}
previousRegistry := newPiRegistryClient
newPiRegistryClient = func() pi.RegistryClient {
return testRegistryClient{version: "0.80.3"}
}
t.Cleanup(func() { newPiRegistryClient = previousRegistry })
var stdout, stderr bytes.Buffer
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "update"}, &stdout, &stderr); code != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
t.Fatalf("stdout = %q, want registry-selected version", stdout.String())
}
assertInvocationNotContains(t, fixture.invocations(t), "build --pull")
}
func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
@@ -942,22 +979,21 @@ func TestRunPiRestartSanitizesSuccessOutput(t *testing.T) {
}
}
func TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(t *testing.T) {
func TestRunPiUpdateAcceptsExplicitVersionWithoutAdvancedFlags(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{
"--installation", fixture.installationPath, "pi", "update", "--version", "0.81.0", "--yes",
"--installation", fixture.installationPath, "pi", "update", "--version", "0.80.3",
}, &stdout, &stderr)
if exitCode != 2 {
t.Errorf("run() exit code = %d, want 2", exitCode)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %q", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "requires explicit --source build or pull") {
t.Errorf("stderr = %q, want source guidance", stderr.String())
if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
t.Fatalf("stdout = %q, want explicit version result", stdout.String())
}
assertDockerNotInvoked(t, fixture)
}
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
@@ -1203,6 +1239,15 @@ type cliFixture struct {
envTemplate string
}
type testRegistryClient struct {
version string
err error
}
func (client testRegistryClient) LatestStable(context.Context, string) (string, error) {
return client.version, client.err
}
func newProjectWithoutInstallation(t *testing.T) string {
t.Helper()
root := t.TempDir()
@@ -1232,6 +1277,18 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "core.Dockerfile"), []byte("ARG PI_VERSION=0.80.3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectDirectory, "docker", "pi-runtime"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml")} {
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
@@ -1257,7 +1314,7 @@ case " $* " in
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
printf '%s\n' "$THT_FAKE_CONFIG"
else
printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
printf '%s\n' '{"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"}}}}'
fi ;;
*" run --rm --no-deps --no-TTY session-migrate "*)
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
@@ -1268,6 +1325,8 @@ case " $* " in
*" ps --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
*" ps --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
*" ps -q core "*) printf '%s\n' 'core-id' ;;
*" version --format {{.Client.Version}}"*) printf '%s\n' '27.0.0' ;;
*"compose version --short"*) printf '%s\n' 'v2.30.0' ;;
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
*"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
*"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;;
@@ -1278,6 +1337,7 @@ case " $* " in
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
*" tht doctor --json"*) printf '%s\n' '{"ok":true}' ;;
*"/sessions?scope="*) printf '%s\n' '[]' ;;
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;;
@@ -1321,7 +1381,7 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
t.Setenv("THT_FAKE_FAILURE", "")
t.Setenv("THT_FAKE_FAIL_ON", "")
t.Setenv("THT_FAKE_STOPPED_PS", "[]")
t.Setenv("THT_FAKE_PS", `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]`)
t.Setenv("THT_FAKE_PS", `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`)
t.Setenv("THT_FAKE_CONFIG", "")
t.Setenv("THT_FAKE_MIGRATION_FAILURE", "")
t.Setenv("THT_FAKE_MIGRATION_EXIT", "0")
@@ -1368,6 +1428,44 @@ func assertDockerNotInvoked(t *testing.T, fixture cliFixture) {
}
}
func runDoctorJSON(t *testing.T, fixture cliFixture) doctor.Report {
t.Helper()
var stdout, stderr bytes.Buffer
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor", "--json"}, &stdout, &stderr); code != 0 {
t.Fatalf("doctor exit = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String())
}
var report doctor.Report
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("doctor JSON = %q: %v", stdout.String(), err)
}
if !report.OK {
t.Fatalf("doctor report = %#v, want all checks passed", report)
}
return report
}
func assertDoctorCheck(t *testing.T, report doctor.Report, name, wantStatus string) {
t.Helper()
for _, check := range report.Checks {
if check.Name == name {
if check.Status != wantStatus {
t.Fatalf("doctor check %q = %q, want %q", name, check.Status, wantStatus)
}
return
}
}
t.Fatalf("doctor report does not contain check %q", name)
}
func assertInvocationNotContains(t *testing.T, invocations [][]string, fragment string) {
t.Helper()
for _, invocation := range invocations {
if strings.Contains(strings.Join(invocation, " "), fragment) {
t.Errorf("Docker invocation %q unexpectedly contains %q", invocation, fragment)
}
}
}
func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
t.Helper()
for _, invocation := range invocations {