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
+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"
"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)
+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()
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) {