diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index 882a3cb7..4086a2c8 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -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 ") + 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 ") } if request.Source != pi.BuildSource && request.Source != pi.PullSource { return pi.Request{}, errors.New("--source requires build or pull") diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index a93a3e89..3e55ea7b 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -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 { diff --git a/tools/tht/internal/pi/latest.go b/tools/tht/internal/pi/latest.go new file mode 100644 index 00000000..df05c0df --- /dev/null +++ b/tools/tht/internal/pi/latest.go @@ -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 +} diff --git a/tools/tht/internal/pi/latest_test.go b/tools/tht/internal/pi/latest_test.go new file mode 100644 index 00000000..fb0994dd --- /dev/null +++ b/tools/tht/internal/pi/latest_test.go @@ -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)), + } +} diff --git a/tools/tht/internal/pi/update.go b/tools/tht/internal/pi/update.go index 9f71d6c8..b9bfab85 100644 --- a/tools/tht/internal/pi/update.go +++ b/tools/tht/internal/pi/update.go @@ -9,7 +9,6 @@ import ( "io" "os" "path/filepath" - "regexp" "runtime" "sort" "strings" @@ -24,7 +23,6 @@ var ( 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") 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. @@ -50,6 +48,7 @@ type Request struct { type Result struct { Phase Phase StatePath string + Version string } type lifecycleHooks struct { @@ -69,6 +68,20 @@ func Update(ctx context.Context, runner Runner, request Request) (result Result, 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) { if request.StatePath == "" { 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 { return Result{StatePath: request.StatePath}, ErrConfirmationRequired } - if !versionPattern.MatchString(request.Version) { - return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi version must be an explicit pinned version", ErrInvalidRequest) + if _, err := parseSemanticVersion(request.Version); err != nil { + return Result{StatePath: request.StatePath}, fmt.Errorf("%w: %v", ErrInvalidRequest, err) } if request.Source == "" { return Result{StatePath: request.StatePath}, fmt.Errorf("%w: Pi update requires an explicit source: build or pull", ErrInvalidRequest) diff --git a/tools/tht/internal/pi/update_test.go b/tools/tht/internal/pi/update_test.go index 475e730c..b823628a 100644 --- a/tools/tht/internal/pi/update_test.go +++ b/tools/tht/internal/pi/update_test.go @@ -54,21 +54,25 @@ func TestActiveSessionsUsesInstallationScopedInventory(t *testing.T) { } } -func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) { +func TestUpdateBuildsRegistrySelectedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *testing.T) { fake := newFakeRunner() 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"), - Version: "0.81.0", Source: BuildSource, Confirm: true, - }) + }, "@earendil-works/pi-coding-agent", registryFunc(func(context.Context, string) (string, error) { + return "0.81.0", nil + })) if err != nil { t.Fatalf("Update() error = %v", err) } if 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, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core") assertNotCalled(t, fake.calls, "frontend") @@ -81,6 +85,13 @@ func TestUpdateBuildsPinnedVersionRecreatesOnlyCoreAndPersistsRecoveryState(t *t if selected := readSelectorReference(t, currentImageOverridePath(result.StatePath)); 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) {