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. Select closed backend defaults interactively on a TTY; all flags are required otherwise.
pi restart --yes [--drain] pi restart --yes [--drain]
Recreate only core with the currently selected Pi image and verify readiness. 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] 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] pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
Pull an immutable candidate and recreate only core. Pull an immutable candidate and recreate only core.
pi rollback --yes Restore the image recorded by the latest Pi update. 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] workspace preprocess run --workspace ID [--resume RUN] [--json]
` `
var (
newPiRegistryClient = pi.NewNPMRegistryClient
readPiRuntimePackageName = pi.ReadRuntimePackageName
)
func main() { func main() {
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) 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 { if err != nil {
return commandUsageError(stderr, err.Error()) 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 { if err != nil {
return piFailure(stderr, err, secretValues) return piFailure(stderr, err, secretValues)
} }
if result.Phase == pi.PhaseNoop { 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 return 0
} }
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath) 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) { func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath} request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
sourceSpecified := false
for len(args) > 0 { for len(args) > 0 {
switch args[0] { switch args[0] {
case "--version": 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") return pi.Request{}, errors.New("--source requires build or pull")
} }
request.Source, args = pi.Source(args[1]), args[2:] request.Source, args = pi.Source(args[1]), args[2:]
sourceSpecified = true
case "--image": case "--image":
if len(args) < 2 || request.Image != "" { if len(args) < 2 || request.Image != "" {
return pi.Request{}, errors.New("--image requires one digest-pinned image reference") 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]) return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
} }
} }
if request.Version == "" { if !sourceSpecified {
return pi.Request{}, errors.New("pi update requires --version <pinned-version>") 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 == "" { if request.Version == "" {
return pi.Request{}, errors.New("pi update requires explicit --source build or pull") return pi.Request{}, errors.New("pi update with --source requires --version <pinned-version>")
} }
if request.Source != pi.BuildSource && request.Source != pi.PullSource { if request.Source != pi.BuildSource && request.Source != pi.PullSource {
return pi.Request{}, errors.New("--source requires build or pull") return pi.Request{}, errors.New("--source requires build or pull")
+138 -40
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -13,6 +14,7 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config" "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/pi"
"github.com/aritmolab/thothii/tools/tht/internal/setup" "github.com/aritmolab/thothii/tools/tht/internal/setup"
"github.com/aritmolab/thothii/tools/tht/internal/testsupport" "github.com/aritmolab/thothii/tools/tht/internal/testsupport"
@@ -60,7 +62,7 @@ func TestRootCommandIdentity(t *testing.T) {
name: "retired command is not an alias", name: "retired command is not an alias",
args: []string{"--installation", fixture.installationPath, retiredCommand}, args: []string{"--installation", fixture.installationPath, retiredCommand},
wantCode: 2, wantCode: 2,
wantText: `tht: unknown command "` + retiredCommand + `"`, wantText: "tht: unknown command \"" + retiredCommand + "\"",
}, },
} { } {
t.Run(test.name, func(t *testing.T) { 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) { func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
fixture := newCLIFixture(t, "") fixture := newCLIFixture(t, "")
fixture.setProfile(t, "server") fixture.setProfile(t, "server")
@@ -569,8 +600,8 @@ func TestRunRedactsSecretWhenDoctorFails(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr) exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 41 { if exitCode != 1 {
t.Errorf("run() exit code = %d, want 41", exitCode) t.Errorf("run() exit code = %d, want normalized doctor failure exit 1", exitCode)
} }
if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") { if strings.Contains(stdout.String()+stderr.String(), "doctor-secret") {
t.Errorf("doctor failure exposed secret: stdout=%q stderr=%q", stdout.String(), stderr.String()) 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 := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t) fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer report := runDoctorJSON(t, fixture)
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr) assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
assertDoctorCheck(t, report, "services", doctor.StatusPassed)
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())
}
} }
func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) { func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T) {
@@ -820,15 +845,9 @@ func TestRunDoctorDoesNotDereferenceSymlinksDuringLineEndingCheck(t *testing.T)
filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"), filepath.Join(fixture.projectDirectory, "legacy-workspace.yaml"),
) )
var stdout, stderr bytes.Buffer report := runDoctorJSON(t, fixture)
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr) assertDoctorCheck(t, report, "files", doctor.StatusPassed)
assertDoctorCheck(t, report, "configuration", doctor.StatusPassed)
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())
}
} }
func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) { func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
@@ -837,18 +856,14 @@ func TestRunDoctorAcceptsComposeJSONLinesServiceStatus(t *testing.T) {
t.Setenv( t.Setenv(
"THT_FAKE_PS", "THT_FAKE_PS",
"{\"Service\":\"core\",\"State\":\"running\",\"Health\":\"healthy\"}\n"+ "{\"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 report := runDoctorJSON(t, fixture)
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr) assertDoctorCheck(t, report, "services", doctor.StatusPassed)
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())
}
} }
func TestRunPreservesChildExitCodes(t *testing.T) { func TestRunPreservesChildExitCodes(t *testing.T) {
@@ -883,6 +898,28 @@ func TestRunPiStatusUsesImageBundledPi(t *testing.T) {
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version") 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) { func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t) 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 := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t) fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{ 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) }, &stdout, &stderr)
if exitCode != 2 { if exitCode != 0 {
t.Errorf("run() exit code = %d, want 2", exitCode) t.Fatalf("run() exit code = %d, stderr = %q", exitCode, stderr.String())
} }
if !strings.Contains(stderr.String(), "requires explicit --source build or pull") { if !strings.Contains(stdout.String(), "Pi already runs requested version 0.80.3") {
t.Errorf("stderr = %q, want source guidance", stderr.String()) t.Fatalf("stdout = %q, want explicit version result", stdout.String())
} }
assertDockerNotInvoked(t, fixture)
} }
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) { func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
@@ -1203,6 +1239,15 @@ type cliFixture struct {
envTemplate string 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 { func newProjectWithoutInstallation(t *testing.T) string {
t.Helper() t.Helper()
root := t.TempDir() 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 { if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
t.Fatal(err) 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")} { 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 { if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -1257,7 +1314,7 @@ case " $* " in
if [ -n "${THT_FAKE_CONFIG:-}" ]; then if [ -n "${THT_FAKE_CONFIG:-}" ]; then
printf '%s\n' "$THT_FAKE_CONFIG" printf '%s\n' "$THT_FAKE_CONFIG"
else 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 ;; fi ;;
*" run --rm --no-deps --no-TTY session-migrate "*) *" run --rm --no-deps --no-TTY session-migrate "*)
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then 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 --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
*" ps --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' ;; *" 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}" ;; *" 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 {{.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}]' ;; *"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-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;; *"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;; *"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
*" tht doctor --json"*) printf '%s\n' '{"ok":true}' ;;
*"/sessions?scope="*) printf '%s\n' '[]' ;; *"/sessions?scope="*) printf '%s\n' '[]' ;;
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;; *"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"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_FAILURE", "")
t.Setenv("THT_FAKE_FAIL_ON", "") t.Setenv("THT_FAKE_FAIL_ON", "")
t.Setenv("THT_FAKE_STOPPED_PS", "[]") 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_CONFIG", "")
t.Setenv("THT_FAKE_MIGRATION_FAILURE", "") t.Setenv("THT_FAKE_MIGRATION_FAILURE", "")
t.Setenv("THT_FAKE_MIGRATION_EXIT", "0") 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) { func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
t.Helper() t.Helper()
for _, invocation := range invocations { for _, invocation := range invocations {
+214
View File
@@ -0,0 +1,214 @@
package pi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
piRegistryURL = "https://registry.npmjs.org"
registryBodyLimit = 5 << 20
registryTimeout = 10 * time.Second
)
var packageNamePattern = regexp.MustCompile(`^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$`)
// RegistryClient resolves a published, stable Pi package version without involving a host npm
// executable. It is deliberately narrow so the update transaction can resolve a version before
// it acquires a lifecycle lock or contacts Docker.
type RegistryClient interface {
LatestStable(ctx context.Context, packageName string) (string, error)
}
type npmRegistryClient struct {
client *http.Client
}
// NewNPMRegistryClient returns the bounded HTTPS client for the authoritative npm registry.
func NewNPMRegistryClient() RegistryClient {
return newNPMRegistryClient(nil)
}
func newNPMRegistryClient(client *http.Client) *npmRegistryClient {
if client == nil {
client = &http.Client{Timeout: registryTimeout}
}
return &npmRegistryClient{client: client}
}
// LatestStable returns the greatest released semantic version. npm's dist-tag is not trusted as
// it can point at a prerelease; selecting from the published versions keeps the default stable.
func (c *npmRegistryClient) LatestStable(ctx context.Context, packageName string) (string, error) {
if !packageNamePattern.MatchString(packageName) {
return "", errors.New("Pi package name is invalid")
}
endpoint, err := registryPackageURL(packageName)
if err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", fmt.Errorf("create Pi registry request: %w", err)
}
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
return "", fmt.Errorf("query Pi registry: %w", err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return "", errors.New("Pi package was not found in the registry")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("Pi registry returned HTTP %d", response.StatusCode)
}
var payload struct {
Versions map[string]json.RawMessage `json:"versions"`
}
body, err := io.ReadAll(io.LimitReader(response.Body, registryBodyLimit+1))
if err != nil {
return "", fmt.Errorf("read Pi registry response: %w", err)
}
if len(body) > registryBodyLimit {
return "", errors.New("Pi registry response is too large")
}
decoder := json.NewDecoder(strings.NewReader(string(body)))
if err := decoder.Decode(&payload); err != nil {
return "", errors.New("Pi registry response is invalid")
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return "", errors.New("Pi registry response is invalid")
}
var selected semanticVersion
found := false
for rawVersion, rawMetadata := range payload.Versions {
if !json.Valid(rawMetadata) {
return "", errors.New("Pi registry response is invalid")
}
version, err := parseSemanticVersion(rawVersion)
if err != nil || version.prerelease != "" {
continue
}
if !found || selected.less(version) {
selected, found = version, true
}
}
if !found {
return "", errors.New("Pi registry contains no stable semantic version")
}
return selected.raw, nil
}
func registryPackageURL(packageName string) (string, error) {
base, err := url.Parse(piRegistryURL)
if err != nil || base.Scheme != "https" || base.Host != "registry.npmjs.org" {
return "", errors.New("Pi registry endpoint is not an authoritative HTTPS URL")
}
base.Path = "/" + packageName
base.RawPath = "/" + url.PathEscape(packageName)
return base.String(), nil
}
// ReadRuntimePackageName obtains the exact Pi package from the dependency manifest copied by
// docker/core.Dockerfile. Version discovery therefore follows the runtime the image actually
// builds, rather than a second, independently maintained package name.
func ReadRuntimePackageName(projectDirectory string) (string, error) {
contents, err := os.ReadFile(filepath.Join(projectDirectory, "docker", "pi-runtime", "package.json"))
if err != nil {
return "", fmt.Errorf("read Pi runtime package manifest: %w", err)
}
var manifest struct {
Dependencies map[string]string `json:"dependencies"`
}
if err := json.Unmarshal(contents, &manifest); err != nil {
return "", errors.New("Pi runtime package manifest is invalid")
}
const piDependency = "@earendil-works/pi-coding-agent"
if !packageNamePattern.MatchString(piDependency) || strings.TrimSpace(manifest.Dependencies[piDependency]) == "" {
return "", errors.New("Pi runtime package manifest does not declare the Pi dependency")
}
return piDependency, nil
}
// ResolveRequestedVersion validates an explicit version without a lookup, or resolves the latest
// published stable version before the caller starts any lifecycle mutation.
func ResolveRequestedVersion(ctx context.Context, requested string, packageName string, registry RegistryClient) (string, error) {
if strings.TrimSpace(requested) != "" {
version, err := parseSemanticVersion(requested)
if err != nil {
return "", err
}
return version.raw, nil
}
if registry == nil {
return "", errors.New("Pi registry client is unavailable")
}
version, err := registry.LatestStable(ctx, packageName)
if err != nil {
return "", err
}
parsed, err := parseSemanticVersion(version)
if err != nil || parsed.prerelease != "" {
return "", errors.New("Pi registry did not return a stable semantic version")
}
return parsed.raw, nil
}
type semanticVersion struct {
raw string
major string
minor string
patch string
prerelease string
}
var semanticVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
func parseSemanticVersion(raw string) (semanticVersion, error) {
match := semanticVersionPattern.FindStringSubmatch(raw)
if len(match) == 0 {
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
}
if match[4] != "" {
for _, identifier := range strings.Split(match[4], ".") {
if allDigits(identifier) && len(identifier) > 1 && identifier[0] == '0' {
return semanticVersion{}, errors.New("Pi version must be a strict semantic version")
}
}
}
return semanticVersion{raw: raw, major: match[1], minor: match[2], patch: match[3], prerelease: match[4]}, nil
}
func allDigits(value string) bool {
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return value != ""
}
func (left semanticVersion) less(right semanticVersion) bool {
for _, pair := range [][2]string{{left.major, right.major}, {left.minor, right.minor}, {left.patch, right.patch}} {
if len(pair[0]) != len(pair[1]) {
return len(pair[0]) < len(pair[1])
}
if pair[0] != pair[1] {
return pair[0] < pair[1]
}
}
return false
}
+171
View File
@@ -0,0 +1,171 @@
package pi
import (
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type registryFunc func(context.Context, string) (string, error)
func (f registryFunc) LatestStable(ctx context.Context, packageName string) (string, error) {
return f(ctx, packageName)
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func TestNPMRegistryClientSelectsHighestStableVersionAndIgnoresPrereleases(t *testing.T) {
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Scheme != "https" || request.URL.Host != "registry.npmjs.org" {
t.Fatalf("registry request = %s; want authoritative HTTPS registry", request.URL)
}
if request.URL.Path != "/@earendil-works%2Fpi-coding-agent" && request.URL.EscapedPath() != "/@earendil-works%2Fpi-coding-agent" {
t.Fatalf("registry path = %q", request.URL.EscapedPath())
}
return registryResponse(http.StatusOK, `{"versions":{"0.80.3":{},"0.81.0-rc.1":{},"0.81.0":{},"1.0.0-beta.2":{}}}`), nil
})})
got, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
if err != nil {
t.Fatal(err)
}
if got != "0.81.0" {
t.Fatalf("LatestStable() = %q, want 0.81.0", got)
}
}
func TestNPMRegistryClientRejectsInvalidResponses(t *testing.T) {
for _, test := range []struct {
name string
status int
body string
wantText string
}{
{name: "prerelease only", status: http.StatusOK, body: `{"versions":{"1.0.0-rc.1":{}}}`, wantText: "no stable"},
{name: "malformed JSON", status: http.StatusOK, body: `{`, wantText: "invalid"},
{name: "no version data", status: http.StatusOK, body: `{"versions":{}}`, wantText: "no stable"},
{name: "package not found", status: http.StatusNotFound, body: `{"error":"not_found"}`, wantText: "not found"},
} {
t.Run(test.name, func(t *testing.T) {
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return registryResponse(test.status, test.body), nil
})})
_, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
if err == nil || !strings.Contains(strings.ToLower(err.Error()), test.wantText) {
t.Fatalf("LatestStable() error = %v, want %q", err, test.wantText)
}
})
}
}
func TestNPMRegistryClientRejectsAnOversizedResponse(t *testing.T) {
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return registryResponse(http.StatusOK, `{"versions":{"1.2.3":{}}}`+strings.Repeat(" ", registryBodyLimit+1)), nil
})})
_, err := client.LatestStable(context.Background(), "@earendil-works/pi-coding-agent")
if err == nil || !strings.Contains(err.Error(), "too large") {
t.Fatalf("LatestStable() error = %v, want bounded-response error", err)
}
}
func TestNPMRegistryClientHonorsContextTimeout(t *testing.T) {
client := newNPMRegistryClient(&http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
<-request.Context().Done()
return nil, request.Context().Err()
})})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := client.LatestStable(ctx, "@earendil-works/pi-coding-agent")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("LatestStable() error = %v, want deadline exceeded", err)
}
}
func TestResolveRequestedVersionBypassesRegistryForExplicitVersion(t *testing.T) {
called := false
got, err := ResolveRequestedVersion(context.Background(), "1.2.3-rc.1", "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
called = true
return "", errors.New("registry must not be called")
}))
if err != nil {
t.Fatal(err)
}
if got != "1.2.3-rc.1" || called {
t.Fatalf("ResolveRequestedVersion() = %q, called=%t; want explicit prerelease without lookup", got, called)
}
}
func TestResolveRequestedVersionRejectsNonStrictSemver(t *testing.T) {
for _, version := range []string{"1.2", "01.2.3", "v1.2.3", "1.2.3.4", "1.2.3-01"} {
_, err := ResolveRequestedVersion(context.Background(), version, "@earendil-works/pi-coding-agent", nil)
if err == nil || !strings.Contains(err.Error(), "semantic version") {
t.Fatalf("ResolveRequestedVersion(%q) error = %v, want strict semver error", version, err)
}
}
}
func TestUpdateWithResolvedVersionDoesNotMutateWhenDiscoveryFails(t *testing.T) {
for _, test := range []struct {
name string
err error
}{
{name: "timeout", err: context.DeadlineExceeded},
{name: "package missing", err: errors.New("package not found")},
{name: "malformed", err: errors.New("registry response is invalid")},
{name: "no stable version", err: errors.New("registry contains no stable version")},
} {
t.Run(test.name, func(t *testing.T) {
fake := newFakeRunner()
statePath := t.TempDir() + "/state/update.json"
_, err := UpdateWithResolvedVersion(context.Background(), fake, Request{StatePath: statePath, Source: BuildSource, Confirm: true, Drain: true}, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
return "", test.err
}))
if !errors.Is(err, test.err) {
t.Fatalf("UpdateWithResolvedVersion() error = %v, want %v", err, test.err)
}
if len(fake.calls) != 0 {
t.Fatalf("discovery failure invoked Docker or lifecycle commands: %v", fake.calls)
}
if _, statErr := os.Stat(filepath.Dir(statePath)); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("discovery failure created update state directory: %v", statErr)
}
})
}
}
func TestReadRuntimePackageNameReadsThePiDependency(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "docker", "pi-runtime")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dependencies":{"@earendil-works/pi-coding-agent":"0.80.3"}}`), 0o600); err != nil {
t.Fatal(err)
}
got, err := ReadRuntimePackageName(root)
if err != nil {
t.Fatal(err)
}
if got != "@earendil-works/pi-coding-agent" {
t.Fatalf("ReadRuntimePackageName() = %q", got)
}
}
func registryResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
+17 -4
View File
@@ -9,7 +9,6 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
@@ -24,7 +23,6 @@ var (
ErrActiveSessions = errors.New("active sessions must be drained before updating Pi; use --drain only after they are complete") 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") ErrInterruptedUpdate = errors.New("a previous Pi update is incomplete; run pi rollback --yes before starting another update")
ErrInvalidRequest = errors.New("invalid Pi lifecycle request") ErrInvalidRequest = errors.New("invalid Pi lifecycle request")
versionPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][0-9A-Za-z.-]+)?$`)
) )
// Source chooses whether the candidate is built from this checkout or pulled from an immutable image. // Source chooses whether the candidate is built from this checkout or pulled from an immutable image.
@@ -50,6 +48,7 @@ type Request struct {
type Result struct { type Result struct {
Phase Phase Phase Phase
StatePath string StatePath string
Version string
} }
type lifecycleHooks struct { type lifecycleHooks struct {
@@ -69,6 +68,20 @@ func Update(ctx context.Context, runner Runner, request Request) (result Result,
return updateWithHooks(ctx, runner, request, defaultLifecycleHooks) return updateWithHooks(ctx, runner, request, defaultLifecycleHooks)
} }
// UpdateWithResolvedVersion resolves a requested version before Update obtains its lifecycle lock
// or contacts Docker. A registry failure therefore cannot drain sessions, write state, or mutate
// images and configuration.
func UpdateWithResolvedVersion(ctx context.Context, runner Runner, request Request, packageName string, registry RegistryClient) (Result, error) {
version, err := ResolveRequestedVersion(ctx, request.Version, packageName, registry)
if err != nil {
return Result{StatePath: request.StatePath}, err
}
request.Version = version
result, err := Update(ctx, runner, request)
result.Version = version
return result, err
}
func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks lifecycleHooks) (result Result, retErr error) { func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks lifecycleHooks) (result Result, retErr error) {
if request.StatePath == "" { if request.StatePath == "" {
return Result{}, errors.New("update state path is required") return Result{}, errors.New("update state path is required")
@@ -87,8 +100,8 @@ func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks
if !request.Confirm { if !request.Confirm {
return Result{StatePath: request.StatePath}, ErrConfirmationRequired return Result{StatePath: request.StatePath}, ErrConfirmationRequired
} }
if !versionPattern.MatchString(request.Version) { if _, err := parseSemanticVersion(request.Version); err != nil {
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi version must be an explicit pinned version", ErrInvalidRequest) return Result{StatePath: request.StatePath}, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
} }
if request.Source == "" { if request.Source == "" {
return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi update requires an explicit source: build or pull", ErrInvalidRequest) return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi update requires an explicit source: build or pull", ErrInvalidRequest)
+15 -4
View File
@@ -54,21 +54,25 @@ func TestActiveSessionsUsesInstallationScopedInventory(t *testing.T) {
} }
} }
func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) { func TestUpdateBuildsRegistrySelectedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) {
fake := newFakeRunner() fake := newFakeRunner()
dir := t.TempDir() dir := t.TempDir()
result, err := Update(context.Background(), fake, Request{ result, err := UpdateWithResolvedVersion(context.Background(), fake, Request{
StatePath: filepath.Join(dir, ".tht", "update-state.json"), StatePath: filepath.Join(dir, ".tht", "update-state.json"),
Version: "0.81.0",
Source: BuildSource, Source: BuildSource,
Confirm: true, Confirm: true,
}) }, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) {
return "0.81.0", nil
}))
if err != nil { if err != nil {
t.Fatalf("Update() error = %v", err) t.Fatalf("Update() error = %v", err)
} }
if result.Phase != PhaseVerified { if result.Phase != PhaseVerified {
t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified) t.Fatalf("phase = %q, want %q", result.Phase, PhaseVerified)
} }
if result.Version != "0.81.0" {
t.Fatalf("resolved version = %q, want 0.81.0", result.Version)
}
assertCalled(t, fake.calls, "build --pull --build-arg PI_VERSION=0.81.0 core") assertCalled(t, fake.calls, "build --pull --build-arg PI_VERSION=0.81.0 core")
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core") assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
assertNotCalled(t, fake.calls, "frontend") assertNotCalled(t, fake.calls, "frontend")
@@ -81,6 +85,13 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t
if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); selected != fake.buildReference { if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); selected != fake.buildReference {
t.Fatalf("durable selector = %q, want verified candidate %q", selected, fake.buildReference) t.Fatalf("durable selector = %q, want verified candidate %q", selected, fake.buildReference)
} }
state, err := readState(result.StatePath)
if err != nil {
t.Fatal(err)
}
if state.Target.Version != "0.81.0" {
t.Fatalf("durable update state target = %q, want registry-selected version", state.Target.Version)
}
} }
func TestUpdateUsesATransactionScopedComposeOverrideWithoutMutatingTheConfiguredImage(t *testing.T) { func TestUpdateUsesATransactionScopedComposeOverrideWithoutMutatingTheConfiguredImage(t *testing.T) {