diff --git a/cmd/codeaf-release/version.go b/cmd/codeaf-release/version.go index 2c8ce49504..fbbed31f0b 100644 --- a/cmd/codeaf-release/version.go +++ b/cmd/codeaf-release/version.go @@ -4,129 +4,13 @@ import ( "bufio" "fmt" "io" - "regexp" - "strconv" "strings" -) -var ( - stableTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) - rcTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$`) - devTagPattern = regexp.MustCompile(`^dev-[0-9]{8}-[0-9a-f]{12}$`) - stagingTagPattern = regexp.MustCompile(`^staging-[0-9]{8}-[0-9a-f]{12}$`) - shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{12,40}$`) - datePattern = regexp.MustCompile(`^[0-9]{8}$`) + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) -type version struct { - major int - minor int - patch int -} - -func (v version) String() string { - return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch) -} - -func (v version) compare(other version) int { - switch { - case v.major != other.major: - return compareInt(v.major, other.major) - case v.minor != other.minor: - return compareInt(v.minor, other.minor) - default: - return compareInt(v.patch, other.patch) - } -} - -func compareInt(left, right int) int { - switch { - case left < right: - return -1 - case left > right: - return 1 - default: - return 0 - } -} - -func (v version) bump(component string) (version, error) { - switch component { - case "patch": - v.patch++ - case "minor": - v.minor++ - v.patch = 0 - case "major": - v.major++ - v.minor = 0 - v.patch = 0 - default: - return version{}, usageErr("component %q must be patch, minor, or major", component) - } - return v, nil -} - -type rcTag struct { - base version - counter int -} - -func parseComponent(raw string) (int, bool) { - value, err := strconv.Atoi(raw) - // Every accepted component must leave room for the resolver's next bump. - // A number that this process cannot represent or increment is a junk tag, - // just like a tag that does not match the grammar at all. - if err != nil || value == int(^uint(0)>>1) { - return 0, false - } - return value, true -} - -func parseVersion(match []string) (version, bool) { - major, majorOK := parseComponent(match[1]) - minor, minorOK := parseComponent(match[2]) - patch, patchOK := parseComponent(match[3]) - if !majorOK || !minorOK || !patchOK { - return version{}, false - } - return version{major: major, minor: minor, patch: patch}, true -} - -func parseStable(tag string) (version, bool) { - match := stableTagPattern.FindStringSubmatch(tag) - if match == nil { - return version{}, false - } - return parseVersion(match) -} - -func parseRC(tag string) (rcTag, bool) { - match := rcTagPattern.FindStringSubmatch(tag) - if match == nil { - return rcTag{}, false - } - base, baseOK := parseVersion(match) - counter, counterOK := parseComponent(match[4]) - if !baseOK || !counterOK { - return rcTag{}, false - } - return rcTag{base: base, counter: counter}, true -} - func tagKind(tag string) string { - switch { - case stableTagPattern.MatchString(tag): - return "stable" - case rcTagPattern.MatchString(tag): - return "rc" - case devTagPattern.MatchString(tag): - return "dev" - case stagingTagPattern.MatchString(tag): - return "staging" - default: - return "other" - } + return codeupdate.Kind(tag) } type nextOptions struct { @@ -142,7 +26,10 @@ func runNext(args []string, stdin io.Reader, stdout io.Writer) error { return err } if options.channel == "dev" || options.channel == "staging" { - tag := fmt.Sprintf("%s-%s-%s", options.channel, options.date, strings.ToLower(options.sha[:12])) + tag, err := codeupdate.ChannelTag(options.channel, options.date, options.sha) + if err != nil { + return err + } fmt.Fprintln(stdout, tag) return nil } @@ -186,14 +73,14 @@ func parseNextOptions(args []string) (nextOptions, error) { if options.sha != "" || options.date != "" { return options, usageErr("--sha and --date are only for dev and staging") } - if _, err := (version{}).bump(options.component); err != nil { - return options, err + if _, err := (codeupdate.Version{}).Bump(options.component); err != nil { + return options, usageErr("%s", err) } case "dev", "staging": - if !shaPattern.MatchString(options.sha) { + if !codeupdate.ValidSHA(options.sha) { return options, usageErr("--sha must be 12 to 40 hexadecimal characters") } - if !datePattern.MatchString(options.date) { + if !codeupdate.ValidDate(options.date) { return options, usageErr("--date must be YYYYMMDD") } if options.component != "patch" { @@ -218,58 +105,11 @@ func readTags(reader io.Reader) ([]string, error) { } func nextSemverTag(tags []string, channel, component string) (string, error) { - stableLatest := version{} - for _, tag := range tags { - if candidate, ok := parseStable(tag); ok && candidate.compare(stableLatest) > 0 { - stableLatest = candidate - } - } - - var ( - openLine version - haveOpen bool - maxCounter int - ) - for _, tag := range tags { - candidate, ok := parseRC(tag) - if !ok || candidate.base.compare(stableLatest) <= 0 { - continue - } - switch comparison := candidate.base.compare(openLine); { - case !haveOpen || comparison > 0: - openLine, haveOpen, maxCounter = candidate.base, true, candidate.counter - case comparison == 0 && candidate.counter > maxCounter: - maxCounter = candidate.counter - } + answer, err := codeupdate.NextSemverTag(tags, channel, component) + if err != nil && (strings.Contains(err.Error(), "component") || strings.Contains(err.Error(), "channel")) { + return "", usageErr("--%s", err) } - - var answer string - switch channel { - case "rc": - if haveOpen { - answer = fmt.Sprintf("%s-rc.%d", openLine, maxCounter+1) - } else { - next, err := stableLatest.bump(component) - if err != nil { - return "", err - } - answer = next.String() + "-rc.1" - } - case "stable": - if component == "patch" && haveOpen { - answer = openLine.String() - } else { - next, err := stableLatest.bump(component) - if err != nil { - return "", err - } - answer = next.String() - } - default: - return "", usageErr("--channel must be stable, rc, dev, or staging") - } - - return answer, requireUnused(answer, tags) + return answer, err } // requireUnused is a final collision guard kept separate from version choice. diff --git a/cmd/codeaf-release/version_test.go b/cmd/codeaf-release/version_test.go index 830cc32924..9db4c1d586 100644 --- a/cmd/codeaf-release/version_test.go +++ b/cmd/codeaf-release/version_test.go @@ -115,3 +115,19 @@ func TestUsageErrorsUseExitTwoAndKeepStdoutEmpty(t *testing.T) { } } } + +// TestC12ReleaseCommandsKeepTheirPublicBehaviour proves C12. +func TestC12ReleaseCommandsKeepTheirPublicBehaviour(t *testing.T) { + code, stdout, stderr := invoke([]string{"next", "--channel", "stable"}, "v0.1.0\n") + if code != 0 || stdout != "v0.1.1\n" || stderr != "" { + t.Fatalf("next: code %d stdout %q stderr %q", code, stdout, stderr) + } + code, stdout, stderr = invoke([]string{"kind", "v0.1.1-rc.1"}, "") + if code != 0 || stdout != "rc\n" || stderr != "" { + t.Fatalf("kind: code %d stdout %q stderr %q", code, stdout, stderr) + } + code, stdout, stderr = invoke([]string{"prune", "--channel", "dev"}, "v0.1.0\t2026-09-15T00:00:00Z\n") + if code != 0 || stdout != "" || stderr != "" { + t.Fatalf("prune: code %d stdout %q stderr %q", code, stdout, stderr) + } +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index e2e3c6be2b..7e91f73e4c 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -18,6 +18,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" @@ -30,6 +31,7 @@ import ( "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/trace" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // runChatV3 is the v3 door: one session agent over this directory, and the @@ -68,6 +70,7 @@ func v3OpenRouterConnection(settings config.Config, interactive bool) func(conte } func openChatV3(name string, args []string, pickSession bool) error { + restart := &codeupdate.Plan{} flags := commandFlags(name) model := flags.String("model", "", "model slug for this session; beats the configured default") once := flags.String("once", "", "run one message non-interactively, print the reply, and exit") @@ -184,7 +187,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // the harnesses are all the far machine's, and reading this one's would be // resolving a launch nobody asked for (chatv3_host.go). if dest := strings.TrimSpace(*host); dest != "" { - return openChatV3Host(hostLaunch{ + err := openChatV3Host(hostLaunch{ target: dest, session: strings.TrimSpace(*file), model: strings.TrimSpace(*model), @@ -194,7 +197,9 @@ func openChatV3(name string, args []string, pickSession bool) error { noCompact: *noCompact, yolo: *yolo, budget: chatBudget(*maxHours, *maxCost).Set(), + restart: restart, }) + return finishChatRestart(err, restart, "") } // THE THIRD DOOR, and it forks here for the reason --host does: a machine @@ -203,7 +208,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // a relay tunnel here — which is the whole point of the transport being an // io.ReadWriteCloser and nothing more (chatv3_at.go, docs/REMOTE.md). if name := strings.TrimSpace(*at); name != "" { - return openChatV3At(atLaunch{ + err := openChatV3At(atLaunch{ target: name, session: strings.TrimSpace(*file), model: strings.TrimSpace(*model), @@ -213,7 +218,9 @@ func openChatV3(name string, args []string, pickSession bool) error { noCompact: *noCompact, yolo: *yolo, budget: chatBudget(*maxHours, *maxCost).Set(), + restart: restart, }) + return finishChatRestart(err, restart, "") } // The fourth door, and the only one with no machine in it: this workspace's @@ -255,6 +262,7 @@ func openChatV3(name string, args []string, pickSession bool) error { once: strings.TrimSpace(*once), pick: pickSession, shape: v3LaunchShape(*yolo, *noCompact, *oneModel, *maxHours, *maxCost, strings.TrimSpace(*once) == ""), + restart: restart, }) var taken *hostShapeTaken var unreachable *hostUnreachable @@ -264,7 +272,7 @@ func openChatV3(name string, args []string, pickSession bool) error { case errors.As(err, &unreachable): entryNotice = "this conversation opened in this terminal instead, and ends with it: " + unreachable.reason default: - return err + return finishChatRestart(err, restart, workspace) } } @@ -461,7 +469,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // The byte meter and the logger redirect both belong to the surface rather // than to this door, and [runSurface] (chatv3_surface.go) is where every // door gets them. - return runSurface(ctx, tui3.Options{ + err = runSurface(ctx, tui3.Options{ Agent: agent, Build: buildinfo.String(), // The memory place and the search place read the SAME database the @@ -613,6 +621,7 @@ func openChatV3(name string, args []string, pickSession bool) error { Owned: launch.Place.Owned, SessionFile: transcript, Resumed: resumed, + Restart: restart, // AND THE LAUNCH FACTS A PERSON CAN ACT ON. The unattended boundary and // a replaced codeaf both ride the session-moved line — one dim row at // the top of the conversation — rather than growing surfaces of their @@ -637,6 +646,48 @@ func openChatV3(name string, args []string, pickSession bool) error { SaveBashApproval: bankBashApproval(agent, workspace, settings.ProfileDir, *yolo), ApplyApprovals: applyV3Approvals(agent, workspace, settings.ProfileDir, *yolo), }) + return finishChatRestart(err, restart, "") +} + +// finishChatRestart runs only after the surface door has returned, so every +// connection, session, and terminal cleanup deferred by that door has finished. +func finishChatRestart(err error, restart *codeupdate.Plan, localWorkspace string) error { + if err != nil || restart == nil || restart.Path == "" { + return err + } + // A linked-local engine outlives its surface, so replacing the executable + // does not by itself put the new build behind the conversation. Ask it to + // retire only after the surface has detached; the host itself refuses while + // another view or any work is active, and a refusal never stops that work. + if localWorkspace != "" { + retireUpdateEngine(localWorkspace) + } + return restartUpdatedChat(*restart) +} + +const updateEngineDetachGrace = time.Second +const updateEngineDetachPoll = 25 * time.Millisecond + +var ( + retireChatEngine = enginehost.Retire + restartUpdatedChat = codeupdate.Restart + updateRestartNow = time.Now + updateRestartPause = time.Sleep +) + +// retireUpdateEngine gives the connection close immediately above long enough +// to reach the host before deciding that somebody else really is keeping it. +// It never uses the force door: another window or live work keeps the old host, +// while the ordinary one-window handoff reliably starts the replacement build. +func retireUpdateEngine(workspace string) { + deadline := updateRestartNow().Add(updateEngineDetachGrace) + for { + err := retireChatEngine(workspace, false) + if !errors.Is(err, enginehost.ErrHostBusy) || !updateRestartNow().Before(deadline) { + return + } + updateRestartPause(updateEngineDetachPoll) + } } // ── the shared assembly ───────────────────────────────────────────────────── diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 642a95c316..f15d74a523 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -49,6 +49,7 @@ import ( "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/remote" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // ── the surface: codeaf chat --at ──────────────────────────────────── @@ -78,6 +79,9 @@ type atLaunch struct { // the session is on the far machine. A ceiling accepted here would bound // nothing at all. budget bool + // restart is filled by the surface and read after this door has closed the + // relay tunnel and all conversation connections. + restart *codeupdate.Plan } // check refuses the flags this door cannot honour, in the same words the ssh @@ -223,6 +227,7 @@ func openChatV3At(launch atLaunch) error { // one is resolved here; the only thing that differs between the two doors is // the pipe, so anything that differed in the surface would be a bug. options, _ := hostOptions(fleet, client.Welcome(), launch.pick) + options.Restart = launch.restart return runSurface(context.Background(), options) } diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 6d3fe33c4b..d08ef3c490 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -26,6 +26,7 @@ import ( "github.com/Agent-Field/codeaf/internal/standing" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // ── `codeaf chat --host devbox` ───────────────────────────────────────────── @@ -80,6 +81,9 @@ type hostLaunch struct { // (internal/session's principal.go), and the session is on the far machine. // A ceiling accepted here would bound nothing at all. budget bool + // restart is filled by the surface and read after this door has closed the + // ssh child and all conversation connections. + restart *codeupdate.Plan } // check refuses the flags this door cannot honour. @@ -517,6 +521,7 @@ func openChatV3Host(launch hostLaunch) error { }) defer fleet.closeAll() options, _ := hostOptions(fleet, welcome, launch.pick) + options.Restart = launch.restart // THE SAME WAY THE SURFACE IS RUN AT EVERY OTHER DOOR (chatv3_surface.go): // the byte meter and the logger redirect are the terminal's business rather // than this connection's, and a door does not state them for itself. diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index db3bad765e..443ea71b41 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // localLaunch is one launch that goes through this machine's own session host. @@ -62,6 +63,9 @@ type localLaunch struct { // that conversation's shape back on the welcome and is refused here // ([hostShapeTaken]). shape *remote.LaunchShape + // restart is filled by the surface and read only after this door's + // connection cleanup has run. + restart *codeupdate.Plan } // localLink is the dialer for a conversation on this machine. @@ -272,6 +276,7 @@ func openChatV3Local(launch localLaunch) error { // and the surface lands on home with that row under the cursor // (internal/tui3's takeover.go). options.TakeOver = takeOver + options.Restart = launch.restart // AND WHAT THE DIAL FOUND ON THE WAY IN, on the same line the engine's own // welcome speaks (chatv3_host.go's [hostEntryNotice]). It is joined here // rather than inside that function because it is a fact about THIS ROAD's diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index 2499717050..0d7c620fc7 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -4,8 +4,22 @@ import ( "context" "log" "os" + "time" + "github.com/Agent-Field/codeaf/internal/buildinfo" + internalenv "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +var ( + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + return tui3.Run(ctx, options) + } + surfaceUpdateClient = codeupdate.NewClient + surfaceExecutable = codeupdate.ExecutableTarget + surfaceRevision = buildinfo.Revision + surfaceArguments = func() []string { return append([]string(nil), os.Args[1:]...) } ) // ── THE ONE WAY THE v3 SURFACE IS RUN ─────────────────────────────────────── @@ -29,6 +43,32 @@ import ( // it would lack is everything below, which is why [TestOnlyTheSurfaceHelperRunsTheV3Surface] // reads this package's sources rather than trusting the next door to remember. func runSurface(ctx context.Context, options tui3.Options) error { + revision := surfaceRevision() + client := surfaceUpdateClient(revision, 3*time.Second) + restart := options.Restart + if restart == nil { + restart = &codeupdate.Plan{} + options.Restart = restart + } + options.UpdateRunning = revision + options.UpdateArgs = surfaceArguments() + options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) { + return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{ + Running: revision, ProfileDir: options.ProfileDir, Client: client, + Disabled: internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1", + }) + } + options.ResolveUpdate = client.Select + options.InstallUpdate = func(install context.Context, release codeupdate.Release) (codeupdate.InstallResult, error) { + // Resolution belongs to the off-frame installation command. A launch may + // live on a slow mount, but its first frame never has to wait for that + // mount merely because /update exists. + target, err := surfaceExecutable(os.Executable) + if err != nil { + return codeupdate.InstallResult{}, err + } + return codeupdate.Install(install, codeupdate.InstallOptions{Client: client, Release: release, Target: target}) + } // The byte meter, off unless a developer named a log file (wire.go). It // measures what this surface DRAWS and is therefore as local as the terminal // is, so it is armed here and never at a door: with CODEAF_WIRE_LOG unset @@ -38,7 +78,7 @@ func runSurface(ctx context.Context, options tui3.Options) error { defer closeWire() options.Output = wire return withSurfaceLogger(options.ProfileDir, func() error { - return tui3.Run(ctx, options) + return runSurfaceProgram(ctx, options) }) } diff --git a/cmd/codeaf/chatv3_surface_test.go b/cmd/codeaf/chatv3_surface_test.go index 10e057a1e5..aa7a2ca7d8 100644 --- a/cmd/codeaf/chatv3_surface_test.go +++ b/cmd/codeaf/chatv3_surface_test.go @@ -2,15 +2,162 @@ package main import ( "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" "log" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "sync" "testing" + "time" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/tui3" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) +type surfaceFrameWriter struct { + mu sync.Mutex + bytes.Buffer + ready chan struct{} + once sync.Once +} + +func (writer *surfaceFrameWriter) Write(raw []byte) (int, error) { + writer.mu.Lock() + written, err := writer.Buffer.Write(raw) + if strings.Contains(writer.Buffer.String(), "codeaf") { + writer.once.Do(func() { close(writer.ready) }) + } + writer.mu.Unlock() + return written, err +} + +func (writer *surfaceFrameWriter) String() string { + writer.mu.Lock() + defer writer.mu.Unlock() + return writer.Buffer.String() +} + +// TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit proves +// C5, C7, and the live capture half of C14 at the shared surface door. +func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *testing.T) { + asset := []byte("new executable") + digest := sha256.Sum256(asset) + requestStarted := make(chan struct{}) + releaseResponse := make(chan struct{}) + var requestOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case strings.HasSuffix(request.URL.Path, "/releases/latest"): + requestOnce.Do(func() { close(requestStarted) }) + select { + case <-releaseResponse: + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case <-request.Context().Done(): + } + case strings.HasSuffix(request.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + default: + _, _ = w.Write(asset) + } + })) + defer server.Close() + + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil { + t.Fatal(err) + } + oldRun := runSurfaceProgram + oldClient, oldExecutable := surfaceUpdateClient, surfaceExecutable + oldRevision, oldArguments := surfaceRevision, surfaceArguments + t.Cleanup(func() { + runSurfaceProgram = oldRun + surfaceUpdateClient, surfaceExecutable = oldClient, oldExecutable + surfaceRevision, surfaceArguments = oldRevision, oldArguments + }) + frame := &surfaceFrameWriter{ready: make(chan struct{})} + input, inputWriter := io.Pipe() + defer inputWriter.Close() + optionsSeen := make(chan tui3.Options, 1) + runSurfaceProgram = func(ctx context.Context, options tui3.Options) error { + optionsSeen <- options + options.Input = input + options.Output = frame + options.Width, options.Height = 80, 24 + return tui3.Run(ctx, options) + } + surfaceUpdateClient = func(revision string, timeout time.Duration) *codeupdate.Client { + if revision != "v0.1.1" || timeout != 3*time.Second { + t.Fatalf("client revision %q timeout %s", revision, timeout) + } + return &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL, Revision: revision} + } + resolved := 0 + surfaceExecutable = func(executable func() (string, error)) (string, error) { + resolved++ + return target, nil + } + surfaceRevision = func() string { return "v0.1.1" } + surfaceArguments = func() []string { return []string{"chat", "--model", "x"} } + profile := t.TempDir() + + ctx, cancel := context.WithCancel(context.Background()) + finished := make(chan error, 1) + go func() { + finished <- runSurface(ctx, tui3.Options{ + Agent: &quietAgent{}, Workspace: "/tmp/lab", ProfileDir: profile, SessionFile: "/tmp/this.jsonl", + }) + }() + seen := <-optionsSeen + if seen.UpdateCheck == nil || seen.ResolveUpdate == nil || seen.InstallUpdate == nil { + cancel() + t.Fatal("runSurface did not wire every update door") + } + restartArgs := codeupdate.RestartArgs(seen.UpdateArgs, seen.SessionFile) + if strings.Join(restartArgs, " ") != "chat --model x --session /tmp/this.jsonl" || seen.UpdateRunning != "v0.1.1" { + cancel() + t.Fatalf("running %q restart args %q", seen.UpdateRunning, restartArgs) + } + select { + case <-requestStarted: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("Init never started the launch check") + } + select { + case <-frame.ready: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("the first frame did not render while the release request waited") + } + if resolved != 0 || !strings.Contains(frame.String(), "codeaf") { + cancel() + t.Fatalf("executable resolutions = %d; first frame:\n%s", resolved, frame.String()) + } + close(releaseResponse) + cancel() + if err := <-finished; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("surface exit: %v", err) + } + + result, err := seen.InstallUpdate(context.Background(), codeupdate.Release{Tag: "v0.2.0", Repository: "Agent-Field/codeaf"}) + if err != nil { + t.Fatal(err) + } + installed, err := os.ReadFile(target) + if err != nil || string(installed) != string(asset) || result.Path != target || resolved != 1 { + t.Fatalf("result = %+v resolved = %d installed = %q, %v", result, resolved, installed, err) + } +} + // A SURFACE THAT OWNS THE TERMINAL OWNS THE LOGGER, AND IT OWNS IT FROM ONE // PLACE. // diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 5c59f6512c..3b448b3404 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/router" "github.com/Agent-Field/codeaf/internal/telemetry" "github.com/Agent-Field/codeaf/internal/trace" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) func main() { @@ -150,6 +151,7 @@ func execute() (code int) { } func run() error { + codeupdate.CleanupOldRunning(os.Executable) if len(os.Args) < 2 { // No arguments opens the chat surface, and that surface is v3. The v2 // surface and its --v2 door (flag and environment pin both) were removed @@ -258,6 +260,8 @@ func run() error { // `codeaf --version`, and a person types `-v`. case "version", "--version", "-v": return runVersion() + case "update": + return runUpdate(os.Args[2:]) case "-h", "--help", "help": return usage(os.Args[2:]) default: @@ -381,8 +385,9 @@ Look at what happened — read-only, no key, nothing spent that page printed whole, or the sections that answer a question codeaf version print the build this binary was cut from (--version and -v say the same) - Housekeeping — changes state on disk or on the network + codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] + check for or install a release; stable is the default codeaf cache what the shared build cache holds, and how big it is codeaf cache clean [--yes] @@ -419,8 +424,7 @@ Plan work by hand — a plan you can read, edit and diff [--out plan.json] [--model slug] [--plan-model slug] codeaf plan run [--dir dir] [--parallel 8] [--out done.json] [--yes-spend] [--model slug] [--plan-model slug] - a plan written to a file, then executed exactly as written. It is not what - most people want: nothing learnt mid-flight moves a frozen plan + a plan written to a file, then run exactly as written — it learns nothing Examples: codeaf open the conversation you were having @@ -514,6 +518,9 @@ than fighting your shell. CODEAF_HOME the whole state root — journal, workspace, CAS, craft, profiles, catalog, skills (default ~/.codeaf). Move it to run a disposable store that touches nothing of yours. + CODEAF_NO_UPDATE_CHECK + 1 skips the launch check; /update and codeaf update + still work. CODEAF_PROFILE_DIR where measured behaviour is kept (default CODEAF_HOME) CODEAF_CALL_LOG the model-call log (default /logs/calls.jsonl). "off" writes nothing; any other value is the file to diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go new file mode 100644 index 0000000000..0906efe9a2 --- /dev/null +++ b/cmd/codeaf/update.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +var ( + updateOut io.Writer = os.Stdout + updateErr io.Writer = os.Stderr + updateExecutable = os.Executable + updateClient = codeupdate.NewClient + updateVersionLine = runInstalledVersion + updateRevision = buildinfo.Revision +) + +func runUpdate(args []string) error { + flags := commandFlags("update") + check := flags.Bool("check", false, "check without installing") + stable := flags.Bool("stable", false, "use the newest stable release") + rc := flags.Bool("rc", false, "use the newest release candidate") + dev := flags.Bool("dev", false, "use the newest dev build") + staging := flags.Bool("staging", false, "use the newest staging build") + version := flags.String("version", "", "install one exact release tag") + if err := parseCommandFlags(flags, args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag]") + } + chosen := 0 + for _, set := range []bool{*stable, *rc, *dev, *staging, strings.TrimSpace(*version) != ""} { + if set { + chosen++ + } + } + if chosen > 1 { + return fmt.Errorf("choose one of --stable, --rc, --dev, --staging, or --version") + } + channel := "stable" + switch { + case *rc: + channel = "rc" + case *dev: + channel = "dev" + case *staging: + channel = "staging" + } + choice := codeupdate.Choice{Channel: channel, Version: strings.TrimSpace(*version)} + running := updateRevision() + var target string + if !*check { + var err error + target, err = codeupdate.ExecutableTarget(updateExecutable) + if err != nil { + return updateFailure(err) + } + if codeupdate.Kind(running) == "other" { + shown := strings.TrimSpace(running) + if shown == "" { + shown = "no revision" + } + fmt.Fprintf(updateErr, "this codeaf was built from source (%s) at %s — rebuild with make build, or install a release: %s\n", shown, target, codeupdate.CurlCommand) + return exitStatus(2) + } + } + client := updateClient(running, 3*time.Second) + release, err := client.Select(context.Background(), choice) + if err != nil { + if *check { + fmt.Fprintln(updateErr, "codeaf: could not check for an update:", err) + return exitStatus(1) + } + return updateFailure(fmt.Errorf("could not select a release: %w", err)) + } + if *check { + return sayUpdateCheck(running, release.Tag, choice) + } + if choice.Version == "" { + if comparison, comparable := codeupdate.CompareSemverTags(running, release.Tag); comparable && comparison > 0 { + fmt.Fprintf(updateErr, "this codeaf is %s, ahead of the newest %s %s — pass --version %s to install it anyway\n", running, choice.Channel, release.Tag, release.Tag) + return exitStatus(2) + } + } + + result, err := codeupdate.Install(context.Background(), codeupdate.InstallOptions{ + Client: client, Release: release, Target: target, + }) + if err != nil { + return updateFailure(err) + } + fmt.Fprintf(updateOut, "codeaf: installed %s at %s\n", result.Release.Tag, result.Path) + if err := updateVersionLine(result.Path, updateOut, updateErr); err != nil { + return updateFailure(fmt.Errorf("run the installed codeaf: %w", err)) + } + return nil +} + +func sayUpdateCheck(running, selected string, choice codeupdate.Choice) error { + shown := strings.TrimSpace(running) + if shown == "" { + shown = "an unstamped source build" + } + if choice.Version != "" { + if running == selected { + fmt.Fprintf(updateOut, "this codeaf matches the selected tag, %s\n", selected) + return nil + } + fmt.Fprintf(updateOut, "selected codeaf %s differs · this codeaf is %s\n", selected, shown) + return exitStatus(3) + } + channel := choice.Channel + if channel == "dev" || channel == "staging" { + if running == selected { + fmt.Fprintf(updateOut, "you are on the newest %s codeaf, %s\n", channel, selected) + return nil + } + fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", selected, shown) + return exitStatus(3) + } + comparison, comparable := codeupdate.CompareSemverTags(selected, running) + if comparable && comparison > 0 { + fmt.Fprintf(updateOut, "codeaf %s is available · you have %s\n", selected, shown) + return exitStatus(3) + } + if comparable && comparison == 0 { + if channel == "stable" { + fmt.Fprintf(updateOut, "you are on the newest codeaf, %s\n", selected) + } else { + fmt.Fprintf(updateOut, "you are on the newest %s codeaf, %s\n", channel, selected) + } + return nil + } + fmt.Fprintf(updateOut, "the newest %s codeaf is %s · this codeaf is %s\n", channel, selected, shown) + return nil +} + +func updateFailure(err error) error { + if strings.Contains(err.Error(), codeupdate.CurlCommand) { + return err + } + return fmt.Errorf("%w; install a release with: %s", err, codeupdate.CurlCommand) +} + +func runInstalledVersion(path string, stdout, stderr io.Writer) error { + command := exec.Command(path, "version") + command.Stdout = stdout + command.Stderr = stderr + return command.Run() +} diff --git a/cmd/codeaf/update_test.go b/cmd/codeaf/update_test.go new file mode 100644 index 0000000000..69d3d77464 --- /dev/null +++ b/cmd/codeaf/update_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/enginehost" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +func withUpdateDoor(t *testing.T, revision string, client *codeupdate.Client, target string) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + oldOut, oldErr := updateOut, updateErr + oldExecutable, oldClient := updateExecutable, updateClient + oldVersion, oldRevision := updateVersionLine, updateRevision + updateOut, updateErr = stdout, stderr + updateExecutable = func() (string, error) { return target, nil } + updateClient = func(string, time.Duration) *codeupdate.Client { return client } + updateVersionLine = func(string, io.Writer, io.Writer) error { return nil } + updateRevision = func() string { return revision } + t.Cleanup(func() { + updateOut, updateErr = oldOut, oldErr + updateExecutable, updateClient = oldExecutable, oldClient + updateVersionLine, updateRevision = oldVersion, oldRevision + }) + return stdout, stderr +} + +func updateExit(err error) int { + if err == nil { + return 0 + } + var status exitStatus + if errors.As(err, &status) { + return int(status) + } + return 1 +} + +// TestC9UpdateCheckUsesTheRealDoorAndItsThreeExitCodes proves C9. +func TestC9UpdateCheckUsesTheRealDoorAndItsThreeExitCodes(t *testing.T) { + for _, row := range []struct { + name string + revision string + status int + body string + want int + }{ + {"newer", "v0.1.1", http.StatusOK, `{"tag_name":"v0.2.0"}`, 3}, + {"newest", "v0.2.0", http.StatusOK, `{"tag_name":"v0.2.0"}`, 0}, + {"failure", "v0.1.1", http.StatusInternalServerError, "no", 1}, + } { + t.Run(row.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(row.status) + fmt.Fprint(w, row.body) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, stderr := withUpdateDoor(t, row.revision, client, filepath.Join(t.TempDir(), "codeaf")) + if got := updateExit(runUpdate([]string{"--check"})); got != row.want { + t.Fatalf("exit = %d, want %d; stdout %q stderr %q", got, row.want, stdout.String(), stderr.String()) + } + }) + } +} + +// TestC9bUpdateCheckNamesTheSelectedTagAndUsesItsChannelRules proves C9b. +func TestC9bUpdateCheckNamesTheSelectedTagAndUsesItsChannelRules(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + fmt.Fprint(w, `[ + {"tag_name":"v0.1.1-rc.2","published_at":"2026-09-14T12:00:00Z"}, + {"tag_name":"dev-20260914-bbbbbbbbbbbb","published_at":"2026-09-14T13:00:00Z"}, + {"tag_name":"staging-20260914-cccccccccccc","published_at":"2026-09-14T14:00:00Z"} + ]`) + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + for _, row := range []struct { + name string + args []string + running string + selected string + want int + }{ + {"stable newer", []string{"--check", "--stable"}, "v0.1.0", "v0.2.0", 3}, + {"stable equal", []string{"--check", "--stable"}, "v0.2.0", "v0.2.0", 0}, + {"rc newer", []string{"--check", "--rc"}, "v0.1.1-rc.1", "v0.1.1-rc.2", 3}, + {"rc equal", []string{"--check", "--rc"}, "v0.1.1-rc.2", "v0.1.1-rc.2", 0}, + {"dev different", []string{"--check", "--dev"}, "dev-20260913-aaaaaaaaaaaa", "dev-20260914-bbbbbbbbbbbb", 3}, + {"dev equal", []string{"--check", "--dev"}, "dev-20260914-bbbbbbbbbbbb", "dev-20260914-bbbbbbbbbbbb", 0}, + {"staging different", []string{"--check", "--staging"}, "staging-20260913-dddddddddddd", "staging-20260914-cccccccccccc", 3}, + {"staging equal", []string{"--check", "--staging"}, "staging-20260914-cccccccccccc", "staging-20260914-cccccccccccc", 0}, + {"version different", []string{"--check", "--version", "v0.2.0"}, "v0.3.0", "v0.2.0", 3}, + {"version equal", []string{"--check", "--version", "v0.2.0"}, "v0.2.0", "v0.2.0", 0}, + } { + t.Run(row.name, func(t *testing.T) { + stdout, stderr := withUpdateDoor(t, row.running, client, filepath.Join(t.TempDir(), "codeaf")) + if got := updateExit(runUpdate(row.args)); got != row.want { + t.Fatalf("exit = %d, want %d; stdout %q stderr %q", got, row.want, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), row.selected) { + t.Fatalf("output does not name selected tag %q: %q", row.selected, stdout.String()) + } + }) + } +} + +// TestC9SourceBuildRefusesBeforeTheNetwork proves C9. +func TestC9SourceBuildRefusesBeforeTheNetwork(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("source"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("source install reached the network") + return nil, nil + })}} + _, stderr := withUpdateDoor(t, "deadbeef", client, target) + if code := updateExit(runUpdate(nil)); code != 2 { + t.Fatalf("exit = %d, want 2", code) + } + for _, want := range []string{"this codeaf was built from source (deadbeef) at " + target, "rebuild with make build", codeupdate.CurlCommand} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr does not contain %q: %q", want, stderr.String()) + } + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +// TestC9ExactVersionAndNewestDevSelectionInstallTheChosenTag proves C9. +func TestC9ExactVersionAndNewestDevSelectionInstallTheChosenTag(t *testing.T) { + for _, row := range []struct { + name string + args []string + tag string + }{ + {"exact", []string{"--version", "v0.1.1-rc.1"}, "v0.1.1-rc.1"}, + {"dev", []string{"--dev"}, "dev-20260915-bbbbbbbbbbbb"}, + } { + t.Run(row.name, func(t *testing.T) { + asset := []byte("installed " + row.tag) + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/releases") && !strings.Contains(r.URL.Path, "/download/"): + fmt.Fprint(w, `[{"tag_name":"dev-20260915-aaaaaaaaaaaa","published_at":"2026-09-15T15:00:00Z"},{"tag_name":"dev-20260915-bbbbbbbbbbbb","published_at":"2026-09-15T16:00:00Z"}]`) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/"+row.tag+"/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, _ := withUpdateDoor(t, "v0.1.0", client, target) + if err := runUpdate(row.args); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(target) + if string(got) != string(asset) || !strings.Contains(stdout.String(), "installed "+row.tag) { + t.Fatalf("target %q stdout %q", got, stdout.String()) + } + }) + } +} + +// TestC9AnInstallEndsWithTheNewBinarysVersionLine proves C9 and D10's +// last-line contract through the command door. +func TestC9AnInstallEndsWithTheNewBinarysVersionLine(t *testing.T) { + asset := []byte("installed release") + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/v0.2.0/"): + _, _ = w.Write(asset) + default: + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + } + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + stdout, _ := withUpdateDoor(t, "v0.1.0", client, target) + updateVersionLine = func(path string, output, _ io.Writer) error { + if path != target { + t.Fatalf("version path = %q, want %q", path, target) + } + _, err := fmt.Fprintln(output, "codeaf v0.2.0 built 2026-09-15 12:00") + return err + } + if err := runUpdate(nil); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + if got := lines[len(lines)-1]; got != "codeaf v0.2.0 built 2026-09-15 12:00" { + t.Fatalf("last line = %q; all output:\n%s", got, stdout.String()) + } +} + +// TestC15TerminalUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls proves C15. +func TestC15TerminalUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls(t *testing.T) { + asset := []byte("selected older release") + digest := sha256.Sum256(asset) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + case strings.Contains(r.URL.Path, "/releases/download/v0.2.0/"): + _, _ = w.Write(asset) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + + t.Run("implicit stable", func(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("ahead release"), 0o755); err != nil { + t.Fatal(err) + } + stdout, stderr := withUpdateDoor(t, "v0.3.0", client, target) + if got := updateExit(runUpdate(nil)); got != 2 { + t.Fatalf("exit = %d, want 2; stdout %q stderr %q", got, stdout.String(), stderr.String()) + } + want := "this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — pass --version v0.2.0 to install it anyway\n" + if stderr.String() != want || stdout.Len() != 0 { + t.Fatalf("stdout %q stderr %q, want stderr %q", stdout.String(), stderr.String(), want) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "ahead release" { + t.Fatalf("target = %q, %v", got, err) + } + }) + + t.Run("exact tag", func(t *testing.T) { + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("ahead release"), 0o755); err != nil { + t.Fatal(err) + } + stdout, _ := withUpdateDoor(t, "v0.3.0", client, target) + if err := runUpdate([]string{"--version", "v0.2.0"}); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != string(asset) { + t.Fatalf("target = %q, %v", got, err) + } + if !strings.Contains(stdout.String(), "installed v0.2.0") { + t.Fatalf("stdout = %q", stdout.String()) + } + }) +} + +// TestTerminalDownloadFailureEndsWithTheCurlFallback proves D9's failure line. +func TestTerminalDownloadFailureEndsWithTheCurlFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/releases/latest") { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + return + } + http.NotFound(w, r) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + client := &codeupdate.Client{HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL} + _, _ = withUpdateDoor(t, "v0.1.0", client, target) + err := runUpdate(nil) + if err == nil { + t.Fatal("download failure returned no error") + } + wantEnd := "; install a release with: " + codeupdate.CurlCommand + if !strings.HasSuffix(err.Error(), wantEnd) { + t.Fatalf("failure does not end with the curl fallback: %q", err) + } +} + +// TestC14RestartArgumentsPassTheRealChatFlagParser proves C14. +func TestC14RestartArgumentsPassTheRealChatFlagParser(t *testing.T) { + for _, original := range [][]string{nil, {"chat", "--model", "x"}, {"resume", "--session", "/tmp/this.jsonl"}} { + args := codeupdate.RestartArgs(original, "/tmp/this.jsonl") + name := args[0] + if name != "chat" && name != "resume" { + t.Fatalf("restart command = %q", name) + } + // An invalid reasoning value stops the real door immediately after its + // private flag set has parsed, before it can open a profile or session. + parsed, _, err := watchParses(t, func() error { + withStop := append(append([]string(nil), args[1:]...), "--reasoning", "not-a-level") + return openChatV3(name, withStop, name == "resume") + }) + if err == nil || len(parsed) != 1 { + t.Fatalf("%q reached %d parses and error %v", args, len(parsed), err) + } + if got := parsed[0].values["session"]; got != "/tmp/this.jsonl" { + t.Fatalf("%q parsed --session as %q", args, got) + } + if name == "chat" && len(original) > 1 && original[1] == "--model" && parsed[0].values["model"] != "x" { + t.Fatalf("%q lost --model: %+v", args, parsed[0].values) + } + } +} + +// TestC7TheDoorWaitsOutItsOwnDetachBeforeRestarting proves the local-engine +// half of C7 and D8 without sleeping or replacing the test process. +func TestC7TheDoorWaitsOutItsOwnDetachBeforeRestarting(t *testing.T) { + oldRetire, oldRestart := retireChatEngine, restartUpdatedChat + oldNow, oldPause := updateRestartNow, updateRestartPause + t.Cleanup(func() { + retireChatEngine, restartUpdatedChat = oldRetire, oldRestart + updateRestartNow, updateRestartPause = oldNow, oldPause + }) + now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) + updateRestartNow = func() time.Time { return now } + updateRestartPause = func(wait time.Duration) { now = now.Add(wait) } + var attempts int + retireChatEngine = func(workspace string, anyway bool) error { + attempts++ + if workspace != "/tmp/lab" || anyway { + t.Fatalf("retire(%q, %t)", workspace, anyway) + } + if attempts < 3 { + return enginehost.ErrHostBusy + } + return nil + } + want := codeupdate.Plan{Path: "/tmp/codeaf", Args: []string{"chat", "--session", "/tmp/this.jsonl"}} + restarted := false + restartUpdatedChat = func(got codeupdate.Plan) error { + restarted = true + if got.Path != want.Path || strings.Join(got.Args, "\x00") != strings.Join(want.Args, "\x00") { + t.Fatalf("restart = %+v, want %+v", got, want) + } + return nil + } + if err := finishChatRestart(nil, &want, "/tmp/lab"); err != nil { + t.Fatal(err) + } + if attempts != 3 || !restarted { + t.Fatalf("retire attempts = %d, restarted = %t", attempts, restarted) + } +} diff --git a/docs/changes/unreleased/1060-update-check.md b/docs/changes/unreleased/1060-update-check.md new file mode 100644 index 0000000000..7c7f5321a5 --- /dev/null +++ b/docs/changes/unreleased/1060-update-check.md @@ -0,0 +1,30 @@ +--- +kind: added +title: a launch says when a newer codeaf is out, and /update installs it and restarts +pr: 1060 +surface: [chat, build] +invalidates: + - "The chat manual's install section ended `Nothing self-updates: run it again when you want a newer build.` That sentence is gone and the opposite is now true: `/update` (alias `/upgrade`) downloads the newest stable release, checks its sha256, replaces the running executable and restarts on it with `--session` pointing at the same transcript, and `codeaf update` does the same from a shell. `running-from-the-terminal.md` has a new section, `Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start`, which states all of it." + - "A launch said nothing about newer releases. A build stamped with a stable tag that is behind the newest stable release now draws one dim transcript line naming both versions, `/update`, and the curl line. A release candidate gets it once its stable line is published. A `dev-*` or `staging-*` channel build, a `make build` sha and an unstamped binary make NO request at all and draw nothing, so a developer's build is never told it is behind. The check is asynchronous with a three-second budget, never delays the first frame, and says nothing when it fails; its answer is cached for a day in `update-check.json` beside `config.json`. `CODEAF_NO_UPDATE_CHECK=1` turns the launch check off without touching `/update` or `codeaf update`." + - "The release-tag grammar lived in `cmd/codeaf-release/version.go`. It is now `internal/update`, which the release tool reads; there is one parser. `scripts/install.sh` was accepting any `dev-*` or `staging-*` tag and semver parts with leading zeroes where `internal/update` accepts only the shapes the release workflow publishes — it now uses the same grammar, so the two install roads cannot pick different builds. The publish-time ordering #1056 added is unchanged; only which candidates are eligible." + - "`codeaf update` is a new command, and it sits under housekeeping in `codeaf --help` rather than beside the read-only rows: `--check` prints one line and exits 0 when this release is newest, 3 when a newer one exists and 1 when it could not ask, while a plain run installs stable or whatever `--rc`, `--dev`, `--staging` or `--version ` names. A binary built from source refuses to replace itself in place and names its path; an unwritable executable is left untouched and offered the curl line; sudo is never attempted." + - "`codeaf update --check --rc`, `--dev`, `--staging` and `--version ` were believed to apply the selected channel's rule after selecting its tag. They did not: every answer ran through the stable-only comparison, so a newer rc or disposable channel build could be called newest with exit 0 and the selected tag omitted. Every answer now names that tag; stable and rc compare their semantic versions, dev and staging compare for equality because channel builds are disposable, and an exact tag is equal or different with no invented ordering." + - "A stable build ahead of the newest published stable was believed to get nothing. That was true only of the launch reminder and `--check`: both `codeaf update` and `/update` silently installed the older release. An implicit update now refuses, names both versions, and points at the deliberate override; `--version ` and `/update ` still install exactly what the person named." + - "A deliberate detach in `internal/remote` kept the twenty-second torn-link grace that exists for a terminal killed without warning, so a window that had announced its departure still looked attached for two more call deadlines. It now clears that grace once the last view leaves, which is what lets a freshly installed codeaf take the engine the old process had already let go of. Live turns, tasks, unanswered questions and other views still refuse retirement." +--- + +The owner asked for two things that turn out to be one: tell somebody a newer +codeaf exists, and give them the command. A line that only names a version makes +a person go looking, so this one carries `/update` and the curl line with it, and +`/update` finishes the job rather than printing homework. + +Everything else follows from not wanting to be wrong in front of somebody. The +check is silent on failure because a network complaint at launch is noise about a +thing nobody asked for. A developer's own build is never told it is behind. The +restart waits for the door, after the frame is down and the terminal is back, and +carries the transcript so the conversation the person was having is the one that +comes back. + +The curl line every one of those roads offers is `curl -fsSL https://agentfield.ai/get/codeaf | bash`, +the supported address that serves `scripts/install.sh` from `main`, so the launch +line, the refusals and the README hand out one road. diff --git a/internal/config/settings.go b/internal/config/settings.go index 3ea16e3322..14a549ff3d 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1072,6 +1072,14 @@ var OperatorEnvPins = []string{ // would be promising an override that does nothing, which is worse than // saying nothing at all. "CODEAF_PROFILE_DIR", + // The release check's one-launch opt-out and its two mirror addresses + // (internal/update). They are plumbing rather than settings rows: the first + // is a shell's decision not to make a launch request, while the other two + // redirect GitHub traffic for a mirror or a test. Persisting any of them in + // the profile would make a temporary network posture outlive its reason. + "CODEAF_NO_UPDATE_CHECK", + "CODEAF_GITHUB_API", + "CODEAF_GITHUB_DOWNLOAD", // The two pins on the model-call log (internal/calllog). CODEAF_CALL_LOG // switches it off or moves the file; CODEAF_CALL_LOG_BODIES adds the whole // request and response to every line. Plumbing rather than settings rows, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index d264149dab..9bda2a9af9 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -228,6 +228,8 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/cache` | — | — | how big the shared build cache is, and where | | `/cache` | — | `clean` | asks first, then deletes the cache to free disk — confirm with `/cache clean now` | | `/debug` | — | — | keeps the full record of **this conversation** from here on, and says which folder it goes to | +| `/update` | `/upgrade` | — | installs the newest stable release and restarts this conversation on it | +| `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag, then restarts this conversation on it | | `/copy` | — | — | enters copy mode (also ctrl+b) | | `/select` | — | — | hands the pointer back to the terminal (also ctrl+s) | | `/export` | `/save` | — | writes the whole conversation to a file | diff --git a/internal/manual/chat/opening-files-from-that-machine.md b/internal/manual/chat/opening-files-from-that-machine.md index 296432361a..4d7aea479c 100644 --- a/internal/manual/chat/opening-files-from-that-machine.md +++ b/internal/manual/chat/opening-files-from-that-machine.md @@ -9,7 +9,7 @@ to send one the other way. Everything except that one drag only **reads**. Nothing here edits a file over there, and the copy that lands on this machine is a copy. -## Click a path over --host to open a file on the other machine +## Download a file from my dev box — click a path over --host to open a file on the other machine **A path in a reply is a real link again over a connection.** cmd+click it on a Mac, ctrl+click on Linux — the same gesture that opens a path in a local conversation. codeaf diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 410d247934..ce109bbdba 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1,6 +1,37 @@ # Commands you type in a terminal -## How do I install or update codeaf to the latest version — the curl line, dev, staging, rc and stable +## Is there a newer version — how do I update codeaf — /update — codeaf update — why does it say this every time I start + +At launch, a stable release behind the newest stable gets one dim line naming both +versions and offering `/update`. That command downloads the release, checks its +sha256, replaces this executable and restarts the same conversation; `/upgrade` is +its alias. Finish a running turn or task first. A matching release says `you are on +the newest codeaf, ` and does not restart. + +Release candidates get the launch line once their stable line is published. A +stable build already newest or ahead gets no launch line. That silence does not +authorize a downgrade: if this build is `v0.3.0` and stable is `v0.2.0`, `/update` +refuses with `this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — /update +v0.2.0 installs it anyway`. Naming the tag is the deliberate road and installs it. +Dev, staging, source and unstamped builds make no launch request. Set +`CODEAF_NO_UPDATE_CHECK=1` to skip only this check. Its answer is cached in +`update-check.json` beside `config.json` for 24 hours for the same running build; +a cached newer release is still shown at every launch. + +From a shell, `codeaf update --check` exits 3 for a newer selected stable or rc, +or a different selected dev or staging tag; 0 when a stable or rc build is equal +or ahead, or a channel tag is equal; and 1 when it could not check. `--version +` exits 0 only on that tag and 3 otherwise. Every answer names the selected +tag. `codeaf update` installs stable by default; +`--rc`, `--dev`, and `--staging` select another channel. On `v0.3.0` with stable +at `v0.2.0`, it exits 2 with `this codeaf is v0.3.0, ahead of the newest stable +v0.2.0 — pass --version v0.2.0 to install it anyway`. `--version v0.2.0` installs +what was named. A source build refuses and names its path: rebuild with `make +build`, or install a release with `curl -fsSL https://agentfield.ai/get/codeaf | bash`. +An unwritable target, failed download or bad checksum leaves the original in +place and offers that same line; codeaf never tries sudo. + +## How do I install codeaf — the curl line, agentfield.ai/get/codeaf, dev, staging, rc and stable channels The install line is one curl, the same one the README prints: @@ -40,8 +71,9 @@ wrong thing. Building from source needs nothing published: clone the repository, run `make build`, then run `bin/codeaf` from the checkout. -The installer writes `~/.codeaf/bin/codeaf`; its last line is `codeaf version`. Nothing -self-updates: run the line again when you want a newer build. +The installer writes `~/.codeaf/bin/codeaf`; its last line is `codeaf version`. For a +newer build later, `/update` in the chat or `codeaf update` in a terminal replaces the +binary in place (the section above); running the line again works too. ## Why codeaf do may download rtk — compressed shell output and how to turn it off diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 81eda99ae7..052cc77e11 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2570,6 +2570,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // TestTheChatManualMentionsEveryVerbTheCommandLineAnswersTo. {"can I run this without the chat", "running-from-the-terminal"}, {"how do I update codeaf to the latest version", "running-from-the-terminal"}, + // C13: These are the words a person brings to the update section. + {"is there a newer version", "running-from-the-terminal"}, + {"how do I update codeaf", "running-from-the-terminal"}, + {"update codeaf", "running-from-the-terminal"}, + {"it says a new version is out every time I start", "running-from-the-terminal"}, // The ending #593 added, in the words somebody meets it in: on the // stderr line they have just read, on the word in `--json`, and on the // exit code they are staring at with a perfectly good answer above it. @@ -2657,6 +2662,27 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { } } +// TestC13UpdateQuestionsReachTheNewManualSection proves C13. +func TestC13UpdateQuestionsReachTheNewManualSection(t *testing.T) { + for _, asked := range []string{ + "is there a newer version", + "how do I update codeaf", + "update codeaf", + "it says a new version is out every time I start", + } { + found := false + for _, section := range Chat().Search(asked, DefaultResults) { + if section.Page == "running-from-the-terminal" && strings.Contains(section.Title, "newer version") { + found = true + break + } + } + if !found { + t.Errorf("%q does not reach the update section", asked) + } + } +} + func TestTheServicesPageNamesCustomListingDiscoveryAndDisconnectConfirmation(t *testing.T) { page, ok := Chat().Page("services") if !ok { diff --git a/internal/release/install_test.go b/internal/release/install_test.go index f09e7f6b1e..7d4bb9c736 100644 --- a/internal/release/install_test.go +++ b/internal/release/install_test.go @@ -464,6 +464,38 @@ func TestInstallerSelectsTheNewestBuildOfEachChannel(t *testing.T) { // Contract: the newest published_at wins regardless of list order, created_at // replaces a null published_at, and the same ordering applies to staging. func TestInstallerPicksTheNewestChannelBuildWhateverTheListOrder(t *testing.T) { + t.Run("a newer malformed tag is ignored", func(t *testing.T) { + const ( + valid = "dev-20260915-abcdefabcdef" + malformed = "dev-backfill" + ) + github := newInstallGitHub(t, valid, malformed) + github.timings = map[string]fakeTiming{ + valid: {published: "2026-09-15T15:00:00Z"}, + malformed: {published: "2026-09-15T16:00:00Z"}, + } + run := runInstaller(t, github, []string{"--dev"}, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 0 || !strings.Contains(run.output, "dev "+valid) || strings.Contains(run.output, malformed) { + t.Fatalf("exit %d, want valid tag %s:\n%s", run.code, valid, run.output) + } + }) + + t.Run("a release candidate with leading zeroes is ignored", func(t *testing.T) { + const ( + valid = "v2.1.0-rc.1" + malformed = "v02.1.0-rc.2" + ) + github := newInstallGitHub(t, valid, malformed) + github.timings = map[string]fakeTiming{ + valid: {published: "2026-09-15T15:00:00Z"}, + malformed: {published: "2026-09-15T16:00:00Z"}, + } + run := runInstaller(t, github, []string{"--rc"}, "CODEAF_NO_MODIFY_PATH=1") + if run.code != 0 || !strings.Contains(run.output, "rc "+valid) || strings.Contains(run.output, malformed) { + t.Fatalf("exit %d, want valid tag %s:\n%s", run.code, valid, run.output) + } + }) + t.Run("newest published_at", func(t *testing.T) { const ( oldest = "dev-20260915-e2ae913b7d0c" diff --git a/internal/remote/detach_test.go b/internal/remote/detach_test.go index fc9300d0ab..d29cb042a0 100644 --- a/internal/remote/detach_test.go +++ b/internal/remote/detach_test.go @@ -1,6 +1,9 @@ package remote -import "testing" +import ( + "testing" + "time" +) // viewOn dials a real client at one served conversation over an in-memory pipe. // It is [Loopback] for a session the test built itself, which is the only way to @@ -31,6 +34,9 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { agent := &fakeAgent{} sess := NewSession(engineOn(agent), true) client := viewOn(t, sess) + sess.mu.Lock() + sess.acted = time.Now() + sess.mu.Unlock() if !client.Welcome().Persistent { t.Fatal("a session host did not say its engine outlives the connection") @@ -49,6 +55,9 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { // what the process exiting looks like from the host's side. _ = client.Close() waitFor(t, "the host noticed the surface leave", func() bool { return sess.Attached() == 0 }) + if sess.IdleSince().IsZero() { + t.Fatal("a deliberate detach kept the torn-link watching grace") + } if sess.Ended() { t.Fatal("a dropped connection ended a hosted conversation") } @@ -57,6 +66,22 @@ func TestDetachingFromAHostLeavesTheConversationRunning(t *testing.T) { } } +// A TORN LINK KEEPS THE GRACE. Only MethodDetach is a window saying it left; +// an EOF by itself may be a stalled call or a laptop between redial attempts. +func TestATornHostedConnectionKeepsTheWatchingGrace(t *testing.T) { + sess := NewSession(engineOn(&fakeAgent{}), true) + client := viewOn(t, sess) + sess.mu.Lock() + sess.acted = time.Now() + sess.mu.Unlock() + + _ = client.Close() + waitFor(t, "the host noticed the torn link", func() bool { return sess.Attached() == 0 }) + if !sess.IdleSince().IsZero() { + t.Fatal("a torn link lost the watching grace that protects its redial") + } +} + // AND A ONE-SHOT ENGINE HAS NO LIFE APART FROM THE PIPE, so leaving it is // ending it: the flush is what keeps its session file readable. func TestDetachingFromAOneShotEngineEndsIt(t *testing.T) { diff --git a/internal/remote/server.go b/internal/remote/server.go index c246617a4f..edf8d2cec4 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1029,7 +1029,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // a killed terminal leaves without saying so, and this runs whenever its socket // gets around to reporting the end, which may be after the window that replaced // it has already been welcomed (held.go states the whole rule). -func (sess *Session) detach(s *server) { +func (sess *Session) detach(s *server, deliberate bool) { // THE RAIL'S SUBSCRIPTION GOES WITH THE WINDOW. It is left before anything // else because leaving it is what ends the goroutine pumping frames at a // pipe that is closing (tasklane.go). @@ -1051,6 +1051,13 @@ func (sess *Session) detach(s *server) { } if len(sess.surfaces) == 0 { sess.empty = time.Now() + // A deliberate detach is the window saying it has gone, so its last + // action needs no torn-link grace. Keeping that grace made a cleanly + // closed window look attached for another two call deadlines, which in + // turn kept an in-place update on the old engine it had just left. + if deliberate { + sess.acted = time.Time{} + } } sess.mu.Unlock() if moved { @@ -1696,7 +1703,7 @@ func (s *server) leave() { if sess == nil { return } - sess.detach(s) + sess.detach(s, s.detached.Load()) if s.goodbye.Load() { _ = sess.closeLeaving() return diff --git a/internal/tui3/app.go b/internal/tui3/app.go index e9e6299b74..7664b636b3 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -21,6 +21,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/tui2/tokens" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // frameInterval is the repaint ceiling: at most one frame is BUILT per 33ms, @@ -851,11 +852,18 @@ type app struct { // workspace is the directory this conversation is about, whole; place is // its base name, which is what the status line has room for. The whole path // is what history is keyed by and what the @ completion walks. - workspace string - place string - file string - build string - resumed bool + workspace string + place string + file string + build string + resumed bool + updateCheck func(context.Context) (codeupdate.Available, bool) + resolveUpdate func(context.Context, codeupdate.Choice) (codeupdate.Release, error) + installUpdate func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) + updateRunning string + updateArgs []string + updateActive bool + restart *codeupdate.Plan // errandHome is the person's own home directory, resolved ONCE at `open` and // held: the `~` project an item that belongs to no repository runs in // ([app.errandPlace], [app.readBareBands], and homeexchange.go's @@ -2669,6 +2677,12 @@ func newApp(ctx context.Context, opts Options) *app { file: opts.SessionFile, build: strings.TrimSpace(opts.Build), resumed: opts.Resumed, + updateCheck: opts.UpdateCheck, + resolveUpdate: opts.ResolveUpdate, + installUpdate: opts.InstallUpdate, + updateRunning: strings.TrimSpace(opts.UpdateRunning), + updateArgs: append([]string(nil), opts.UpdateArgs...), + restart: opts.Restart, models: opts.Models, modelsForService: opts.ModelsForService, sources: opts.Sources, @@ -3103,7 +3117,7 @@ func (a *app) Init() tea.Cmd { // AND THE SETUP SCREEN'S EXAMPLE PANEL, when the setup is the first frame // and the controls screen is its first step. It answers nil in every other // case, which is most launches (onboarding.go). - a.setupDemoCmd(), titleSend(a.titleSent), + a.setupDemoCmd(), a.checkForUpdate(), titleSend(a.titleSent), // AND THE TWO DOORS INTO THE LOOP FROM ELSEWHERE, each with its one // command parked on it (doorbell.go). a.news.waitRing(), a.leaving.waitRing()} @@ -4646,6 +4660,18 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // law's own clock, and the only line this surface draws about a wait. return a, a.remoteOpenSlow(msg) + case updateCheckMsg: + return a, a.tookUpdateCheck(msg) + + case updateResolveMsg: + return a, a.tookUpdateResolve(msg) + + case updateInstallMsg: + return a, a.tookUpdateInstall(msg) + + case updateRestartMsg: + return a, a.quit() + case frameMsg: return a, a.paint() } @@ -6653,6 +6679,9 @@ func (a *app) slash(line string) tea.Cmd { // The unknown-command hint below says back what was typed and not what it // resolved to, so the name as written is kept. switch canonicalCommand(name) { + case "update": + return a.runUpdateCommand(rest) + case "autonomy": if rest != "" { return a.changeAutonomy(rest) diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 0b5c86f657..e7283bc5ed 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -438,6 +438,8 @@ var commands = []command{ // twice in a day, and a row inserted higher would push a daily command // behind a scroll. {name: "debug", desc: "keep the full record of this conversation · says where it goes"}, + {name: "update", desc: "install the newest codeaf and restart on it", alias: []string{"upgrade"}}, + {name: "update", args: "", desc: "…that channel or exact tag, then restart"}, {name: "help", desc: "this list", alias: []string{"?"}}, {name: "quit", desc: "close this conversation", alias: []string{"exit", "q"}}, } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 18b5d2749f..344574cd12 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -3471,6 +3471,9 @@ func (a *app) homeRowGone(row session.SessionRow) bool { // the new conversation owes itself, so the submit below is talking to the new // agent and not to the one that just closed. func (a *app) homeStart(text string) tea.Cmd { + if (strings.TrimSpace(text) != "" || len(a.chips) > 0) && a.updateStopsTurn() { + return nil + } if !a.canStart() { a.home.say(newUnavailableWord, "") return nil diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index bff41c69a3..ac409cb5e8 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -972,6 +972,9 @@ func (a *app) askHereWith(text string, orders ErrandOrders) tea.Cmd { if text == "" { return nil } + if a.updateStopsTurn() { + return nil + } h := &a.home if a.errand == nil { h.say(homeAskUnavailableWord, "") diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 1f1561738a..ffde02952a 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -253,7 +253,7 @@ func homeFate(word, rest string) string { return fatePlace } return fateAnswers - case "help", "manual", "status", "cost", "budget", "cache", "debug", + case "help", "manual", "status", "cost", "budget", "cache", "debug", "update", "stop", "remember", "forget": return fateAnswers } diff --git a/internal/tui3/input.go b/internal/tui3/input.go index d35cbb5eb4..c0973dfefa 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1375,6 +1375,9 @@ func (a *app) enterLine(marked bool) tea.Cmd { // empty message — "what is this?" is often the picture itself — so the two // tests below both ask about the tray as well as about the words. held := len(a.chips) > 0 + if !strings.HasPrefix(line, "/") && (line != "" || held) && a.updateStopsTurn() { + return nil + } // An empty draft with a call selected is a reader, not a typist: enter // opens what ↑/↓ picked out. A draft of any length is a sentence, and a // sentence wins. diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 68ec63570a..77f227a44b 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -55,6 +55,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/standing" "github.com/Agent-Field/codeaf/internal/subharness" + codeupdate "github.com/Agent-Field/codeaf/internal/update" ) // Agent is the slice of *session.Agent this surface uses. It is an interface @@ -416,6 +417,22 @@ type Options struct { // machines, where this process's own build would be the wrong answer. Build string + // UpdateCheck is the silent launch look at the newest stable release. It is + // a command rather than an opening read so the first frame never waits for + // the network. Nil leaves the capability absent. + UpdateCheck func(context.Context) (codeupdate.Available, bool) + // ResolveUpdate and InstallUpdate are the two off-frame halves of /update. + // Keeping selection separate lets the surface name the tag before the + // download begins. Nil leaves the command with an honest refusal. + ResolveUpdate func(context.Context, codeupdate.Choice) (codeupdate.Release, error) + InstallUpdate func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) + // UpdateRunning is the exact revision of this process, without build-time + // decoration. UpdateArgs are its original arguments. Restart is the slot the + // surface fills before quitting and the door reads after the terminal is back. + UpdateRunning string + UpdateArgs []string + Restart *codeupdate.Plan + // Memory is the durable memory store behind the memory place. Nil means the // place is unavailable; the live door passes the same store it gave the // session, wrapped so that the two READING methods are spelled the way this diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go new file mode 100644 index 0000000000..f6cb1c84be --- /dev/null +++ b/internal/tui3/updatecmd.go @@ -0,0 +1,160 @@ +package tui3 + +import ( + "context" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +type updateCheckMsg struct { + available codeupdate.Available + show bool +} + +type updateResolveMsg struct { + release codeupdate.Release + choice codeupdate.Choice + err error +} + +type updateInstallMsg struct { + result codeupdate.InstallResult + err error +} + +type updateRestartMsg struct{} + +const updateTurnRefusal = "finish the update before starting a turn" + +// updateStopsTurn keeps every turn-starting door behind the update that owns +// the next process start. Home also receives the same words on its visible +// message line because its full-page frame hides conversation notes. +func (a *app) updateStopsTurn() bool { + if !a.updateActive { + return false + } + a.note(updateTurnRefusal) + if a.at(pageHome) { + a.home.say(updateTurnRefusal, "") + } + return true +} + +// checkForUpdate starts after the app is ready to draw, so a slow or absent +// network can never become a slow first frame. +func (a *app) checkForUpdate() tea.Cmd { + if a.updateCheck == nil { + return nil + } + return func() tea.Msg { + available, show := a.updateCheck(a.ctx) + return updateCheckMsg{available: available, show: show} + } +} + +func (a *app) tookUpdateCheck(message updateCheckMsg) tea.Cmd { + if message.show { + a.note(message.available.Notice()) + } + return nil +} + +func (a *app) runUpdateCommand(argument string) tea.Cmd { + if a.state == stateWorking || len(a.runningNodes()) != 0 { + a.note("finish the running turn or task before updating codeaf") + return nil + } + if a.updateActive { + a.note("an update is already running") + return nil + } + if a.resolveUpdate == nil || a.installUpdate == nil || a.restart == nil { + a.note("this window cannot update codeaf here · install a release with: " + codeupdate.CurlCommand) + return nil + } + if codeupdate.Kind(a.updateRunning) == "other" { + a.note("this codeaf was built from source · rebuild with make build, or install a release: " + codeupdate.CurlCommand) + return nil + } + choice := updateChoice(argument) + resolve := a.resolveUpdate + // The mark is set before the command leaves the loop. A second /update or a + // new turn can therefore never enter while release selection is off-frame. + a.updateActive = true + return func() tea.Msg { + release, err := resolve(context.Background(), choice) + return updateResolveMsg{release: release, choice: choice, err: err} + } +} + +func updateChoice(argument string) codeupdate.Choice { + argument = strings.TrimSpace(argument) + switch argument { + case "", "stable": + return codeupdate.Choice{Channel: "stable"} + case "rc", "dev", "staging": + return codeupdate.Choice{Channel: argument} + default: + return codeupdate.Choice{Version: argument} + } +} + +func (a *app) tookUpdateResolve(message updateResolveMsg) tea.Cmd { + if message.err != nil { + a.updateFailed(message.err) + return nil + } + if message.release.Tag == a.updateRunning { + a.updateActive = false + a.note("you are on the newest codeaf, " + message.release.Tag) + return nil + } + if message.choice.Version == "" { + if comparison, comparable := codeupdate.CompareSemverTags(a.updateRunning, message.release.Tag); comparable && comparison > 0 { + channel := message.choice.Channel + if channel == "" { + channel = "stable" + } + a.updateActive = false + a.note("this codeaf is " + a.updateRunning + ", ahead of the newest " + channel + " " + message.release.Tag + " — /update " + message.release.Tag + " installs it anyway") + return nil + } + } + a.note("downloading codeaf " + message.release.Tag + " for " + runtimePlatform() + "…") + install := a.installUpdate + return func() tea.Msg { + result, err := install(context.Background(), message.release) + return updateInstallMsg{result: result, err: err} + } +} + +func runtimePlatform() string { + return runtimeGOOS + "/" + runtimeGOARCH +} + +func (a *app) tookUpdateInstall(message updateInstallMsg) tea.Cmd { + if message.err != nil { + a.updateFailed(message.err) + return nil + } + a.note("checksum matched · installed at " + message.result.Path) + a.note("restarting on " + message.result.Release.Tag + "…") + *a.restart = codeupdate.Plan{ + Path: message.result.Path, + Args: codeupdate.RestartArgs(a.updateArgs, a.file), + } + // The final two notes get one frame of their own before the program quits. + // Quitting in this same update would restore the terminal before Bubble Tea + // had painted the checksum and restart lines at all. + return surfaceTick(frameInterval, func(time.Time) tea.Msg { return updateRestartMsg{} }) +} + +func (a *app) updateFailed(err error) { + a.updateActive = false + a.note("could not update codeaf: " + err.Error()) + a.note("install a release with: " + codeupdate.CurlCommand) +} diff --git a/internal/tui3/updatecmd_test.go b/internal/tui3/updatecmd_test.go new file mode 100644 index 0000000000..a6594c0e25 --- /dev/null +++ b/internal/tui3/updatecmd_test.go @@ -0,0 +1,281 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +func updateNotes(a *app) string { + var lines []string + for _, entry := range a.entries { + if entry.kind == entryNote { + lines = append(lines, entry.text) + } + } + return strings.Join(lines, "\n") +} + +// TestC1LaunchAvailabilityBecomesOneTranscriptNote proves C1. +func TestC1LaunchAvailabilityBecomesOneTranscriptNote(t *testing.T) { + a := newTestApp(&fakeAgent{model: "test/model"}) + available := codeupdate.Available{Latest: "v0.2.0", Running: "v0.1.1"} + drive(t, a, updateCheckMsg{available: available, show: true}) + line := "codeaf v0.2.0 is out · you have v0.1.1 · /update installs it and restarts · or: " + codeupdate.CurlCommand + if got := strings.Count(updateNotes(a), line); got != 1 { + t.Fatalf("launch note count = %d, want 1:\n%s", got, updateNotes(a)) + } +} + +// TestOneUpdateOwnsTheSurfaceUntilItFinishes proves that a second installer +// and a home-page turn cannot begin while release selection is off-frame. +func TestOneUpdateOwnsTheSurfaceUntilItFinishes(t *testing.T) { + agent := &fakeAgent{model: "test/model"} + a := newApp(context.Background(), Options{ + Agent: agent, Workspace: "/tmp/lab", UpdateRunning: "v0.1.1", Restart: &codeupdate.Plan{}, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + }, + }) + first := a.slash("/update") + if first == nil || !a.updateActive { + t.Fatalf("first command = %v, active = %t", first, a.updateActive) + } + if second := a.slash("/upgrade"); second != nil { + t.Fatal("the overlapping update returned a command") + } + started := 0 + a.start = func(string) (Conversation, error) { + started++ + return Conversation{Agent: &fakeAgent{model: "test/model"}, SessionFile: "/tmp/next/transcript.jsonl"}, nil + } + a.openHome() + a.home.box.setText("start another turn") + a.home.build() + drive(t, a, key("enter")) + if started != 0 { + t.Fatalf("home enter started %d conversations while the update was running", started) + } + if len(agent.sent) != 0 || a.home.box.String() != "start another turn" { + t.Fatalf("sent = %q home draft = %q", agent.sent, a.home.box.String()) + } + for _, want := range []string{"an update is already running", "finish the update before starting a turn"} { + if strings.Count(updateNotes(a), want) != 1 { + t.Fatalf("notes do not contain %q once:\n%s", want, updateNotes(a)) + } + } +} + +// TestAnUpdateOwnsHomesAskHereRoad proves that the home-page chord cannot +// start an errand beneath the restart that an update is preparing. +func TestAnUpdateOwnsHomesAskHereRoad(t *testing.T) { + called := 0 + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateActive = true + a.standingRoot = t.TempDir() + a.errand = func(ErrandOrders) (Agent, error) { + called++ + return &errandAgent{fakeAgent: fakeAgent{model: "test/model"}}, nil + } + a.openHome() + a.home.box.setText("ask from home") + a.home.build() + drive(t, a, key("alt+enter")) + if !a.composerShowing() { + t.Fatal("the first ask-here chord did not open its composer") + } + drive(t, a, key("alt+enter")) + + if called != 0 || len(a.exchanges) != 0 { + t.Fatalf("errand calls = %d exchanges = %d", called, len(a.exchanges)) + } + if got := strings.Count(updateNotes(a), "finish the update before starting a turn"); got != 1 { + t.Fatalf("update refusal count = %d, want 1:\n%s", got, updateNotes(a)) + } +} + +// TestUpdateCheckCommandDefersTheReleaseDoor proves the command itself does no +// work until Bubble Tea runs it. The shared door test proves the first frame. +func TestUpdateCheckCommandDefersTheReleaseDoor(t *testing.T) { + called := false + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateCheck = func(context.Context) (codeupdate.Available, bool) { + called = true + return codeupdate.Available{}, false + } + command := a.checkForUpdate() + if called || command == nil { + t.Fatalf("building the first frame called = %t, command nil = %t", called, command == nil) + } + _ = command() + if !called { + t.Fatal("the command did not make the deferred check") + } +} + +// TestC6UpdateOnTheNewestBuildDoesNotInstallOrQuit proves C6. +func TestC6UpdateOnTheNewestBuildDoesNotInstallOrQuit(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "v0.2.0", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + if installed || a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), "you are on the newest codeaf, v0.2.0") { + t.Fatalf("installed = %t active = %t restart = %+v notes:\n%s", installed, a.updateActive, restart, updateNotes(a)) + } +} + +// TestC15ChatUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls proves C15. +func TestC15ChatUpdateRefusesAnImplicitDowngradeAndAnExactTagInstalls(t *testing.T) { + t.Run("implicit stable", func(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", + UpdateRunning: "v0.3.0", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + want := "this codeaf is v0.3.0, ahead of the newest stable v0.2.0 — /update v0.2.0 installs it anyway" + if installed || a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), want) { + t.Fatalf("installed = %t active = %t restart = %+v notes:\n%s", installed, a.updateActive, restart, updateNotes(a)) + } + }) + + t.Run("exact tag", func(t *testing.T) { + installed := false + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", SessionFile: "/tmp/this.jsonl", + UpdateRunning: "v0.3.0", Restart: restart, + ResolveUpdate: func(_ context.Context, choice codeupdate.Choice) (codeupdate.Release, error) { + if choice.Version != "v0.2.0" { + t.Fatalf("choice = %+v", choice) + } + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + installed = true + return codeupdate.InstallResult{Release: codeupdate.Release{Tag: "v0.2.0"}, Path: "/tmp/codeaf"}, nil + }, + }) + command := a.slash("/update v0.2.0") + drive(t, a, command()) + if !installed || restart.Path != "/tmp/codeaf" { + t.Fatalf("installed = %t restart = %+v notes:\n%s", installed, restart, updateNotes(a)) + } + }) +} + +// TestUpdateCommandListNamesItsOptionalArgument proves the list agrees with the manual. +func TestUpdateCommandListNamesItsOptionalArgument(t *testing.T) { + bare, withArgument := false, false + for _, row := range commands { + if row.name != "update" { + continue + } + bare = bare || row.args == "" + withArgument = withArgument || row.args == "" + } + if !bare || !withArgument { + t.Fatalf("update rows: bare = %t argument = %t", bare, withArgument) + } +} + +// TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript proves C7. +func TestC7UpdateInstallReturnsTheRestartPlanForThisTranscript(t *testing.T) { + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", SessionFile: "/tmp/this.jsonl", + UpdateRunning: "v0.1.1", UpdateArgs: []string{"chat", "--model", "x"}, Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{Release: codeupdate.Release{Tag: "v0.2.0"}, Path: "/tmp/codeaf"}, nil + }, + }) + command := a.slash("/update") + drive(t, a, command()) + if restart.Path != "/tmp/codeaf" || strings.Join(restart.Args, " ") != "chat --model x --session /tmp/this.jsonl" { + t.Fatalf("restart = %+v", restart) + } + for _, want := range []string{"downloading codeaf v0.2.0", "checksum matched · installed at /tmp/codeaf", "restarting on v0.2.0"} { + if !strings.Contains(updateNotes(a), want) { + t.Fatalf("notes do not contain %q:\n%s", want, updateNotes(a)) + } + } +} + +// TestC7UpdateFailureNamesTheCauseAndLeavesNoRestart proves C7. +func TestC7UpdateFailureNamesTheCauseAndLeavesNoRestart(t *testing.T) { + restart := &codeupdate.Plan{} + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", UpdateRunning: "v0.1.1", Restart: restart, + ResolveUpdate: func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.2.0"}, nil + }, + InstallUpdate: func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, errors.New("the checksum did not match") + }, + }) + drive(t, a, a.slash("/update")()) + if a.updateActive || restart.Path != "" || !strings.Contains(updateNotes(a), "the checksum did not match") || !strings.Contains(updateNotes(a), codeupdate.CurlCommand) { + t.Fatalf("active = %t restart = %+v notes:\n%s", a.updateActive, restart, updateNotes(a)) + } +} + +// TestC8UpdateRefusesWhileATurnOrTaskIsRunning proves C8. +func TestC8UpdateRefusesWhileATurnOrTaskIsRunning(t *testing.T) { + for _, kind := range []string{"turn", "task"} { + t.Run(kind, func(t *testing.T) { + resolved := false + a := newTestApp(&fakeAgent{model: "test/model"}) + a.updateRunning = "v0.1.1" + a.restart = &codeupdate.Plan{} + a.resolveUpdate = func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + resolved = true + return codeupdate.Release{}, nil + } + a.installUpdate = func(context.Context, codeupdate.Release) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{}, nil + } + if kind == "turn" { + a.state = stateWorking + } else { + a.tasks = map[uint64]*taskNode{1: {id: 1, state: session.TaskRunning}} + a.taskOrder = []uint64{1} + } + if command := a.slash("/update"); command != nil { + t.Fatal("the refusal returned a command") + } + if resolved || strings.Count(updateNotes(a), "finish the running turn or task before updating codeaf") != 1 { + t.Fatalf("resolved = %t notes:\n%s", resolved, updateNotes(a)) + } + }) + } +} diff --git a/internal/tui3/updateplatform.go b/internal/tui3/updateplatform.go new file mode 100644 index 0000000000..e8bc8347d8 --- /dev/null +++ b/internal/tui3/updateplatform.go @@ -0,0 +1,8 @@ +package tui3 + +import "runtime" + +var ( + runtimeGOOS = runtime.GOOS + runtimeGOARCH = runtime.GOARCH +) diff --git a/internal/update/check.go b/internal/update/check.go new file mode 100644 index 0000000000..f67cda79ac --- /dev/null +++ b/internal/update/check.go @@ -0,0 +1,145 @@ +package update + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + internalenv "github.com/Agent-Field/codeaf/internal/env" +) + +const cacheLifetime = 24 * time.Hour + +// Available is the stable release comparison shown at launch or by --check. +type Available struct { + Latest string + Running string +} + +// Newer reports whether the selected stable release supersedes this build. +func (a Available) Newer() bool { + latest, latestOK := ParseStable(a.Latest) + switch Kind(a.Running) { + case "stable": + running, ok := ParseStable(a.Running) + return latestOK && ok && latest.Compare(running) > 0 + case "rc": + running, ok := ParseRC(a.Running) + return latestOK && ok && latest.Compare(running.Base) >= 0 + default: + return false + } +} + +// Notice is the one launch line carrying both update roads. +func (a Available) Notice() string { + if !a.Newer() { + return "" + } + return "codeaf " + a.Latest + " is out · you have " + a.Running + " · /update installs it and restarts · or: " + CurlCommand +} + +type checkCache struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` + Running string `json:"running"` +} + +// CheckOptions supplies the launch policy's disk, clock, and release client. +type CheckOptions struct { + Running string + ProfileDir string + Client *Client + Now func() time.Time + Disabled bool +} + +// CheckLaunch silently checks the newest stable release when this build is a +// stable or release-candidate build. Every failure is absence at this surface. +func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { + running := strings.TrimSpace(options.Running) + if options.Disabled || internalenv.Get(NoUpdateCheckEnv) == "1" || (Kind(running) != "stable" && Kind(running) != "rc") { + return Available{}, false + } + now := time.Now + if options.Now != nil { + now = options.Now + } + path := config.ProfilePath(options.ProfileDir, "update-check.json") + if cached, ok := loadCheckCache(path); ok && cached.Running == running { + age := now().Sub(cached.CheckedAt) + if age >= 0 && age < cacheLifetime { + answer := Available{Latest: cached.Latest, Running: running} + return answer, answer.Newer() + } + } + if options.Client == nil { + return Available{}, false + } + release, err := options.Client.Select(ctx, Choice{Channel: "stable"}) + if err != nil { + return Available{}, false + } + answer := Available{Latest: release.Tag, Running: running} + _ = saveCheckCache(path, checkCache{CheckedAt: now(), Latest: release.Tag, Running: running}) + return answer, answer.Newer() +} + +func loadCheckCache(path string) (checkCache, bool) { + if strings.TrimSpace(path) == "" { + return checkCache{}, false + } + raw, err := os.ReadFile(path) + if err != nil { + return checkCache{}, false + } + var cached checkCache + if json.Unmarshal(raw, &cached) != nil || cached.CheckedAt.IsZero() || cached.Latest == "" || cached.Running == "" { + return checkCache{}, false + } + return cached, true +} + +func saveCheckCache(path string, cached checkCache) error { + if strings.TrimSpace(path) == "" { + return nil + } + raw, err := json.Marshal(cached) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".update-check-*.json") + if err != nil { + return err + } + temporaryPath := temporary.Name() + keep := false + defer func() { + if !keep { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(append(raw, '\n')); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + keep = true + return nil +} diff --git a/internal/update/cleanup_running_unix.go b/internal/update/cleanup_running_unix.go new file mode 100644 index 0000000000..b3a000027d --- /dev/null +++ b/internal/update/cleanup_running_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package update + +// CleanupOldRunning does nothing where replacing a running executable leaves +// no backup. In particular, it does not resolve the executable during launch. +func CleanupOldRunning(func() (string, error)) {} diff --git a/internal/update/cleanup_running_unix_test.go b/internal/update/cleanup_running_unix_test.go new file mode 100644 index 0000000000..c5956ef537 --- /dev/null +++ b/internal/update/cleanup_running_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package update + +import "testing" + +func TestUnixLaunchDoesNotResolveAnExecutableOnlyToCleanAWindowsBackup(t *testing.T) { + called := false + CleanupOldRunning(func() (string, error) { + called = true + return "", nil + }) + if called { + t.Fatal("the Unix launch resolved its executable for Windows cleanup") + } +} diff --git a/internal/update/cleanup_running_windows.go b/internal/update/cleanup_running_windows.go new file mode 100644 index 0000000000..4e411aee14 --- /dev/null +++ b/internal/update/cleanup_running_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package update + +// CleanupOldRunning removes the backup left by the previous Windows update. +// Resolution is platform-specific so other launches never pay for this read. +func CleanupOldRunning(executable func() (string, error)) { + target, err := ExecutableTarget(executable) + if err == nil { + CleanupOld(target) + } +} diff --git a/internal/update/client.go b/internal/update/client.go new file mode 100644 index 0000000000..1607932925 --- /dev/null +++ b/internal/update/client.go @@ -0,0 +1,263 @@ +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + internalenv "github.com/Agent-Field/codeaf/internal/env" +) + +const ( + // CurlCommand is the independent installation road shown at launch beside + // /update and after a failed or unavailable in-place update. IT IS THE + // SUPPORTED LINE, NOT THE SCRIPT UNDER IT: agentfield.ai/get/codeaf serves + // scripts/install.sh from main, and the address is the one a person can + // remember and the one every other place codeaf documents spells, so a + // launch line and the README can never hand out two different roads. + CurlCommand = "curl -fsSL https://agentfield.ai/get/codeaf | bash" + + NoUpdateCheckEnv = "CODEAF_NO_UPDATE_CHECK" + GitHubAPIEnv = "CODEAF_GITHUB_API" + GitHubDownloadEnv = "CODEAF_GITHUB_DOWNLOAD" +) + +const primaryRepository = "Agent-Field/codeaf" +const legacyRepository = "Agent-Field/aforge-v2" // legacy-name + +// Client talks to the release API and release asset host. +type Client struct { + HTTP *http.Client + APIBase string + DownloadBase string + Token string + Revision string +} + +// NewClient builds the release client from the same mirror and token inputs as +// the shell installer. The timeout covers the whole request, including bodies. +func NewClient(revision string, timeout time.Duration) *Client { + apiBase := strings.TrimRight(internalenv.Get(GitHubAPIEnv), "/") + if apiBase == "" { + apiBase = "https://api.github.com" + } + downloadBase := strings.TrimRight(internalenv.Get(GitHubDownloadEnv), "/") + if downloadBase == "" { + downloadBase = "https://github.com" + } + token := internalenv.Value("GITHUB_TOKEN") + if token == "" { + token = internalenv.Value("GH_TOKEN") + } + return &Client{ + HTTP: &http.Client{Timeout: timeout}, + APIBase: apiBase, + DownloadBase: downloadBase, + Token: token, + Revision: revision, + } +} + +func (c *Client) httpClient() *http.Client { + if c != nil && c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 3 * time.Second} +} + +// requestClient keeps the caller's redirect decisions while enforcing the +// credential boundary the release API promises. Go considers another port on +// the same hostname safe for sensitive headers; the token here belongs to the +// exact API host, including its port, so that default is too broad. +func (c *Client) requestClient() *http.Client { + base := c.httpClient() + client := *base + checkRedirect := base.CheckRedirect + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if checkRedirect != nil { + if err := checkRedirect(request, via); err != nil { + return err + } + } else if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if len(via) > 0 && !strings.EqualFold(request.URL.Host, via[0].URL.Host) { + request.Header.Del("Authorization") + } + return nil + } + return &client +} + +func (c *Client) userAgent() string { + revision := strings.TrimSpace(c.Revision) + if revision == "" { + revision = "source" + } + return "codeaf/" + revision +} + +type statusError struct { + code int + resource string +} + +func (e *statusError) Error() string { + return fmt.Sprintf("%s answered HTTP %d", e.resource, e.code) +} + +type requestError struct{ cause error } + +func (e *requestError) Error() string { return "release request failed: " + e.cause.Error() } +func (e *requestError) Unwrap() error { return e.cause } + +func isStatus(err error, code int) bool { + var status *statusError + return errors.As(err, &status) && status.code == code +} + +func nameReleaseResource(err error, resource string) error { + var status *statusError + if errors.As(err, &status) { + return &statusError{code: status.code, resource: resource} + } + var request *requestError + if errors.As(err, &request) { + return fmt.Errorf("%s request failed: %w", resource, request.cause) + } + return err +} + +func (c *Client) get(ctx context.Context, rawURL, accept string, api bool) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", accept) + req.Header.Set("User-Agent", c.userAgent()) + // The token belongs only on API requests. Release downloads may redirect to + // another host, and no credential follows them there. + if api && strings.TrimSpace(c.Token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.Token)) + } + resp, err := c.requestClient().Do(req) + if err != nil { + var requestURL *url.Error + if errors.As(err, &requestURL) { + err = requestURL.Err + } + return nil, &requestError{cause: err} + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil, &statusError{code: resp.StatusCode, resource: "release API"} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read the release response: %w", err) + } + return body, nil +} + +// Choice selects one release channel or one exact tag. +type Choice struct { + Channel string + Version string +} + +// Release is one selected GitHub release and the repository that answered. +type Release struct { + Tag string + Repository string +} + +type apiRelease struct { + TagName string `json:"tag_name"` + PublishedAt time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (r apiRelease) stamp() time.Time { + if !r.PublishedAt.IsZero() { + return r.PublishedAt + } + return r.CreatedAt +} + +// Select resolves a channel with the same timestamp law as the shell installer. +func (c *Client) Select(ctx context.Context, choice Choice) (Release, error) { + if version := strings.TrimSpace(choice.Version); version != "" { + if Kind(version) == "other" { + return Release{}, fmt.Errorf("%q is not a codeaf release tag", version) + } + return Release{Tag: version, Repository: primaryRepository}, nil + } + channel := strings.TrimSpace(choice.Channel) + if channel == "" { + channel = "stable" + } + if channel != "stable" && channel != "rc" && channel != "dev" && channel != "staging" { + return Release{}, fmt.Errorf("channel must be stable, rc, dev, or staging") + } + for index, repository := range []string{primaryRepository, legacyRepository} { // legacy-name + release, err := c.selectRepository(ctx, repository, channel) + if err == nil { + return release, nil + } + if !isStatus(err, http.StatusNotFound) || index == 1 { + return Release{}, err + } + } + return Release{}, errors.New("no release repository answered") +} + +func (c *Client) selectRepository(ctx context.Context, repository, channel string) (Release, error) { + suffix := "releases/latest" + if channel != "stable" { + suffix = "releases?per_page=100" + } + rawURL := strings.TrimRight(c.APIBase, "/") + "/repos/" + repository + "/" + suffix + body, err := c.get(ctx, rawURL, "application/vnd.github+json", true) + if err != nil { + return Release{}, err + } + if channel == "stable" { + var row apiRelease + if err := json.Unmarshal(body, &row); err != nil { + return Release{}, fmt.Errorf("read the latest release: %w", err) + } + if Kind(row.TagName) != "stable" { + return Release{}, fmt.Errorf("the latest release did not name a stable codeaf tag") + } + return Release{Tag: row.TagName, Repository: repository}, nil + } + var rows []apiRelease + if err := json.Unmarshal(body, &rows); err != nil { + return Release{}, fmt.Errorf("read the release list: %w", err) + } + var newest apiRelease + for _, row := range rows { + if Kind(row.TagName) != channel { + continue + } + if newest.TagName == "" || row.stamp().After(newest.stamp()) { + newest = row + } + } + if newest.TagName == "" { + return Release{}, fmt.Errorf("no %s build has been published yet", channel) + } + return Release{Tag: newest.TagName, Repository: repository}, nil +} + +func (c *Client) assetURL(release Release, name string) string { + base := strings.TrimRight(c.DownloadBase, "/") + return base + "/" + release.Repository + "/releases/download/" + url.PathEscape(release.Tag) + "/" + url.PathEscape(name) +} diff --git a/internal/update/install.go b/internal/update/install.go new file mode 100644 index 0000000000..320caaa644 --- /dev/null +++ b/internal/update/install.go @@ -0,0 +1,171 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// InstallOptions describes one in-place replacement. +type InstallOptions struct { + Client *Client + Release Release + Target string + GOOS string + GOARCH string +} + +// InstallResult is the release and executable path that are ready to restart. +type InstallResult struct { + Release Release + Path string +} + +// Install downloads, checks, and atomically replaces one executable. +func Install(ctx context.Context, options InstallOptions) (InstallResult, error) { + if options.Client == nil { + return InstallResult{}, errors.New("no release client is available") + } + target := strings.TrimSpace(options.Target) + if target == "" { + return InstallResult{}, errors.New("the running executable path is empty") + } + goos, goarch := options.GOOS, options.GOARCH + if goos == "" { + goos = runtime.GOOS + } + if goarch == "" { + goarch = runtime.GOARCH + } + if (goos != "linux" && goos != "darwin" && goos != "windows") || (goarch != "amd64" && goarch != "arm64") { + return InstallResult{}, fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + extension := "" + if goos == "windows" { + extension = ".exe" + } + asset := "codeaf-" + goos + "-" + goarch + extension + + release := options.Release + if release.Tag == "" { + return InstallResult{}, errors.New("the release tag is empty") + } + if release.Repository == "" { + release.Repository = primaryRepository + } + var ( + body []byte + checksums []byte + err error + ) + for index, repository := range installRepositories(release.Repository) { + release.Repository = repository + body, asset, checksums, err = options.Client.downloadRelease(ctx, release, asset, goos, goarch, extension) + if err == nil { + break + } + if !isStatus(err, http.StatusNotFound) || index == 1 { + return InstallResult{}, err + } + } + if err != nil { + return InstallResult{}, err + } + expected, ok := checksumFor(checksums, asset) + if !ok { + return InstallResult{}, fmt.Errorf("checksums.txt has no checksum for %s", asset) + } + actualBytes := sha256.Sum256(body) + actual := hex.EncodeToString(actualBytes[:]) + if !strings.EqualFold(expected, actual) { + return InstallResult{}, fmt.Errorf("the checksum for %s did not match", asset) + } + + temporaryFile, err := os.CreateTemp(filepath.Dir(target), fmt.Sprintf(".codeaf.tmp.%d.", os.Getpid())) + if err != nil { + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + } + temporary := temporaryFile.Name() + defer os.Remove(temporary) + _, writeErr := temporaryFile.Write(body) + closeErr := temporaryFile.Close() + if writeErr != nil { + return InstallResult{}, fmt.Errorf("write %s: %w", temporary, writeErr) + } + if closeErr != nil { + return InstallResult{}, fmt.Errorf("close %s: %w", temporary, closeErr) + } + if err := os.Chmod(temporary, 0o755); err != nil { + return InstallResult{}, fmt.Errorf("make %s executable: %w", temporary, err) + } + if err := replaceExecutable(temporary, target); err != nil { + return InstallResult{}, fmt.Errorf("cannot replace %s: %w; install a release with: %s", target, err, CurlCommand) + } + return InstallResult{Release: release, Path: target}, nil +} + +func installRepositories(first string) []string { + if first == legacyRepository { + return []string{legacyRepository} + } + return []string{primaryRepository, legacyRepository} // legacy-name +} + +func (c *Client) downloadRelease(ctx context.Context, release Release, asset, goos, goarch, extension string) ([]byte, string, []byte, error) { + shownAsset := asset + body, err := c.get(ctx, c.assetURL(release, asset), "application/octet-stream", false) + if isStatus(err, http.StatusNotFound) { + legacyAsset := "aforge-" + goos + "-" + goarch + extension // legacy-name + body, err = c.get(ctx, c.assetURL(release, legacyAsset), "application/octet-stream", false) + if err == nil { + asset = legacyAsset + } + } + if err != nil { + return nil, asset, nil, nameReleaseResource(err, shownAsset) + } + checksums, err := c.get(ctx, c.assetURL(release, "checksums.txt"), "application/octet-stream", false) + if err != nil { + return nil, asset, nil, nameReleaseResource(err, "checksums.txt") + } + return body, asset, checksums, nil +} + +func checksumFor(raw []byte, name string) (string, bool) { + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + candidate := strings.TrimPrefix(fields[1], "*") + if candidate == name { + if decoded, err := hex.DecodeString(fields[0]); err == nil && len(decoded) == sha256.Size { + return strings.ToLower(fields[0]), true + } + } + } + return "", false +} + +// ExecutableTarget resolves the running binary before an installer replaces it. +func ExecutableTarget(executable func() (string, error)) (string, error) { + if executable == nil { + executable = os.Executable + } + path, err := executable() + if err != nil { + return "", fmt.Errorf("find the running codeaf: %w", err) + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", path, err) + } + return path, nil +} diff --git a/internal/update/replace_contract_test.go b/internal/update/replace_contract_test.go new file mode 100644 index 0000000000..d4bc117290 --- /dev/null +++ b/internal/update/replace_contract_test.go @@ -0,0 +1,54 @@ +package update + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestWindowsReplacementFailureLeavesTheOriginalAtItsTarget proves the +// Windows half of D9 without asking this host to run a Windows executable. +func TestWindowsReplacementFailureLeavesTheOriginalAtItsTarget(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "codeaf.exe") + temporary := filepath.Join(directory, ".codeaf.tmp") + if err := os.WriteFile(target, []byte("original"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(temporary, []byte("replacement"), 0o755); err != nil { + t.Fatal(err) + } + want := windowsUnableToMoveReplacement2 + err := replaceWindowsExecutable(temporary, target, func(gotTarget, gotReplacement, gotBackup string) error { + if gotTarget != target || gotReplacement != temporary || gotBackup != target+".old" { + t.Fatalf("replace(%q, %q, %q)", gotTarget, gotReplacement, gotBackup) + } + // Windows has already moved the original to the backup when it reports + // ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, while the replacement stays put. + if moveErr := os.Rename(gotTarget, gotBackup); moveErr != nil { + t.Fatal(moveErr) + } + return want + }) + if !errors.Is(err, want) { + t.Fatalf("replacement error = %v, want %v", err, want) + } + raw, readErr := os.ReadFile(target) + if readErr != nil || string(raw) != "original" { + t.Fatalf("original target = %q, %v", raw, readErr) + } +} + +// TestTheWindowsBuildUsesTheReplacementRecoveryContract keeps the platform +// door on the operation that repairs ReplaceFileW's partial failure. +func TestTheWindowsBuildUsesTheReplacementRecoveryContract(t *testing.T) { + raw, err := os.ReadFile("replace_windows.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "return replaceWindowsExecutable(temporary, target, replaceFile)") { + t.Fatal("the Windows replacement door bypasses the atomic replacement contract") + } +} diff --git a/internal/update/replace_unix.go b/internal/update/replace_unix.go new file mode 100644 index 0000000000..c8ba53916a --- /dev/null +++ b/internal/update/replace_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package update + +import "os" + +func replaceExecutable(temporary, target string) error { + return os.Rename(temporary, target) +} + +// CleanupOld removes nothing on systems where the running executable can be +// replaced directly. +func CleanupOld(string) {} diff --git a/internal/update/replace_windows.go b/internal/update/replace_windows.go new file mode 100644 index 0000000000..c04f4d1056 --- /dev/null +++ b/internal/update/replace_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package update + +import ( + "os" + "syscall" + "unsafe" +) + +var replaceFileW = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW") + +func replaceExecutable(temporary, target string) error { + return replaceWindowsExecutable(temporary, target, replaceFile) +} + +func replaceFile(target, replacement, backup string) error { + targetPointer, err := syscall.UTF16PtrFromString(target) + if err != nil { + return err + } + replacementPointer, err := syscall.UTF16PtrFromString(replacement) + if err != nil { + return err + } + backupPointer, err := syscall.UTF16PtrFromString(backup) + if err != nil { + return err + } + result, _, callErr := replaceFileW.Call( + uintptr(unsafe.Pointer(targetPointer)), + uintptr(unsafe.Pointer(replacementPointer)), + uintptr(unsafe.Pointer(backupPointer)), + 0, + 0, + 0, + ) + if result != 0 { + return nil + } + if callErr != syscall.Errno(0) { + return callErr + } + return syscall.EINVAL +} + +// CleanupOld removes the previous Windows executable when the next process can. +func CleanupOld(target string) { _ = os.Remove(target + ".old") } diff --git a/internal/update/replace_windows_contract.go b/internal/update/replace_windows_contract.go new file mode 100644 index 0000000000..cda7005ca6 --- /dev/null +++ b/internal/update/replace_windows_contract.go @@ -0,0 +1,35 @@ +package update + +import ( + "errors" + "fmt" + "os" + "syscall" +) + +type windowsFileReplacer func(target, replacement, backup string) error + +// Windows reports error 1177 after moving the original to the backup but +// before moving the checked replacement onto the target. +const windowsUnableToMoveReplacement2 syscall.Errno = 1177 + +// replaceWindowsExecutable gives ReplaceFileW the target, checked replacement, +// and backup path. On its documented partial failure, recovery first restores +// the backup and then tries to finish the replacement if that new move fails. +func replaceWindowsExecutable(temporary, target string, replace windowsFileReplacer) error { + backup := target + ".old" + if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + err := replace(target, temporary, backup) + if !errors.Is(err, windowsUnableToMoveReplacement2) { + return err + } + if restoreErr := os.Rename(backup, target); restoreErr == nil { + return err + } else if installErr := os.Rename(temporary, target); installErr == nil { + return nil + } else { + return fmt.Errorf("replace target: %w; restore original: %v; move replacement: %v", err, restoreErr, installErr) + } +} diff --git a/internal/update/restart.go b/internal/update/restart.go new file mode 100644 index 0000000000..cf40019f66 --- /dev/null +++ b/internal/update/restart.go @@ -0,0 +1,35 @@ +package update + +import "strings" + +// Plan is the executable and arguments a restored terminal restarts with. +type Plan struct { + Path string + Args []string +} + +// RestartArgs keeps the original launch shape and points it at this transcript. +func RestartArgs(original []string, transcript string) []string { + args := append([]string(nil), original...) + transcript = strings.TrimSpace(transcript) + if transcript == "" { + return args + } + if len(args) == 0 { + return []string{"chat", "--session", transcript} + } + for index, arg := range args { + if arg == "--session" { + if index+1 < len(args) { + args[index+1] = transcript + return args + } + return append(args, transcript) + } + if strings.HasPrefix(arg, "--session=") { + args[index] = "--session=" + transcript + return args + } + } + return append(args, "--session", transcript) +} diff --git a/internal/update/restart_unix.go b/internal/update/restart_unix.go new file mode 100644 index 0000000000..9f8aa311c6 --- /dev/null +++ b/internal/update/restart_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package update + +import ( + "os" + "syscall" +) + +// Restart replaces this process after the terminal has been restored. +func Restart(plan Plan) error { + argv := append([]string{plan.Path}, plan.Args...) + return syscall.Exec(plan.Path, argv, os.Environ()) +} diff --git a/internal/update/restart_windows.go b/internal/update/restart_windows.go new file mode 100644 index 0000000000..0077fc99cc --- /dev/null +++ b/internal/update/restart_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package update + +import ( + "os" + "os/exec" +) + +// Restart starts the replacement process with this process's terminal streams. +func Restart(plan Plan) error { + command := exec.Command(plan.Path, plan.Args...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Start() +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000000..1351e1ac70 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,380 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +func releaseClient(server *httptest.Server, revision string) *Client { + return &Client{ + HTTP: server.Client(), APIBase: server.URL, DownloadBase: server.URL, Revision: revision, + } +} + +// TestC1StableLaunchComparisonAndNotice proves C1. +func TestC1StableLaunchComparisonAndNotice(t *testing.T) { + for _, row := range []struct { + running string + show bool + }{{"v0.1.1", true}, {"v0.2.0", false}, {"v0.3.0", false}} { + t.Run(row.running, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer server.Close() + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: row.running, ProfileDir: t.TempDir(), Client: releaseClient(server, row.running), + }) + if show != row.show { + t.Fatalf("show = %t, want %t", show, row.show) + } + if row.show { + line := answer.Notice() + for _, want := range []string{"v0.2.0", "v0.1.1", "/update", CurlCommand} { + if strings.Count(line, want) != 1 { + t.Fatalf("notice %q does not contain %q exactly once", line, want) + } + } + } + }) + } +} + +// TestC2ReleaseCandidateIsPromptedOnlyWhenItsLineIsStable proves C2. +func TestC2ReleaseCandidateIsPromptedOnlyWhenItsLineIsStable(t *testing.T) { + for _, row := range []struct { + latest string + show bool + }{{"v0.2.0", true}, {"v0.1.9", false}} { + answer := Available{Latest: row.latest, Running: "v0.2.0-rc.3"} + if got := answer.Newer(); got != row.show { + t.Fatalf("latest %s: newer = %t, want %t", row.latest, got, row.show) + } + } +} + +// TestC3SourceAndChannelLaunchesNeverReachTheNetwork proves C3. +func TestC3SourceAndChannelLaunchesNeverReachTheNetwork(t *testing.T) { + var hits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) })) + defer server.Close() + for _, running := range []string{"dev-20260915-abcdefabcdef", "staging-20260915-abcdefabcdef", "deadbeef", ""} { + answer, show := CheckLaunch(context.Background(), CheckOptions{ + Running: running, ProfileDir: t.TempDir(), Client: releaseClient(server, running), + }) + if show || answer != (Available{}) { + t.Fatalf("%q returned %+v, %t", running, answer, show) + } + } + if hits.Load() != 0 { + t.Fatalf("server saw %d requests", hits.Load()) + } +} + +// TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh proves C4. +func TestC4OptOutAndFreshCacheAvoidRequestsWhileStaleFactsRefresh(t *testing.T) { + base := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) + var hits atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer server.Close() + client := releaseClient(server, "v0.1.1") + + t.Setenv(NoUpdateCheckEnv, "1") + CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}) + if hits.Load() != 0 { + t.Fatal("the opt-out reached the server") + } + t.Setenv(NoUpdateCheckEnv, "") + + fresh := t.TempDir() + path := filepath.Join(fresh, "update-check.json") + if err := saveCheckCache(path, checkCache{CheckedAt: base.Add(-23 * time.Hour), Latest: "v0.2.0", Running: "v0.1.1"}); err != nil { + t.Fatal(err) + } + answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: fresh, Client: client, Now: func() time.Time { return base }}) + if !show || answer.Latest != "v0.2.0" || hits.Load() != 0 { + t.Fatalf("fresh cache = %+v, %t, hits %d", answer, show, hits.Load()) + } + + for _, cached := range []checkCache{ + {CheckedAt: base.Add(-25 * time.Hour), Latest: "v0.2.0", Running: "v0.1.1"}, + {CheckedAt: base.Add(-time.Hour), Latest: "v0.2.0", Running: "v0.1.0"}, + } { + dir := t.TempDir() + if err := saveCheckCache(filepath.Join(dir, "update-check.json"), cached); err != nil { + t.Fatal(err) + } + CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: dir, Client: client, Now: func() time.Time { return base }}) + } + if hits.Load() != 2 { + t.Fatalf("stale facts made %d requests, want 2", hits.Load()) + } +} + +// TestC5LaunchFailuresAreSilent proves C5. +func TestC5LaunchFailuresAreSilent(t *testing.T) { + tests := map[string]http.Handler{ + "403": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusForbidden) }), + "404": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.NotFound(w, nil) }), + "500": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusInternalServerError) }), + "malformed": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "{") }), + "timeout": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(50 * time.Millisecond) }), + } + for name, handler := range tests { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(handler) + defer server.Close() + client := releaseClient(server, "v0.1.1") + if name == "timeout" { + client.HTTP.Timeout = time.Millisecond + } + answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}) + if show || answer != (Available{}) { + t.Fatalf("failure drew %+v, %t", answer, show) + } + }) + } + client := &Client{HTTP: &http.Client{Timeout: 10 * time.Millisecond}, APIBase: "http://127.0.0.1:1", Revision: "v0.1.1"} + if answer, show := CheckLaunch(context.Background(), CheckOptions{Running: "v0.1.1", ProfileDir: t.TempDir(), Client: client}); show || answer != (Available{}) { + t.Fatalf("unreachable server drew %+v, %t", answer, show) + } +} + +func servedRelease(t *testing.T, asset []byte, checksum string) (*httptest.Server, *atomic.Int64) { + t.Helper() + requests := &atomic.Int64{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch { + case strings.HasSuffix(r.URL.Path, "/codeaf-"+runtime.GOOS+"-"+runtime.GOARCH): + _, _ = w.Write(asset) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%s codeaf-%s-%s\n", checksum, runtime.GOOS, runtime.GOARCH) + default: + http.NotFound(w, r) + } + })) + return server, requests +} + +// TestC7CheckedInstallReplacesAtomicallyAndMismatchPreservesTheOriginal proves C7. +func TestC7CheckedInstallReplacesAtomicallyAndMismatchPreservesTheOriginal(t *testing.T) { + asset := []byte("new codeaf") + digest := sha256.Sum256(asset) + for _, row := range []struct { + name string + checksum string + ok bool + }{{"matching", hex.EncodeToString(digest[:]), true}, {"wrong", strings.Repeat("0", 64), false}} { + t.Run(row.name, func(t *testing.T) { + server, _ := servedRelease(t, asset, row.checksum) + defer server.Close() + dir := t.TempDir() + target := filepath.Join(dir, "codeaf") + if err := os.WriteFile(target, []byte("old codeaf"), 0o700); err != nil { + t.Fatal(err) + } + result, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.1.1"), Release: Release{Tag: "v0.2.0", Repository: primaryRepository}, Target: target, + }) + if (err == nil) != row.ok { + t.Fatalf("error = %v, want success %t", err, row.ok) + } + got, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatal(readErr) + } + want := []byte("old codeaf") + if row.ok { + want = asset + info, _ := os.Stat(target) + if info.Mode().Perm() != 0o755 || result.Path != target || result.Release.Tag != "v0.2.0" { + t.Fatalf("result = %+v mode %o", result, info.Mode().Perm()) + } + } else if !strings.Contains(err.Error(), "checksum") { + t.Fatalf("mismatch error = %v", err) + } + if string(got) != string(want) { + t.Fatalf("target = %q, want %q", got, want) + } + matches, _ := filepath.Glob(filepath.Join(dir, ".codeaf.tmp.*")) + if len(matches) != 0 { + t.Fatalf("temporary files remain: %v", matches) + } + }) + } +} + +// TestC10TokenReachesOnlyTheAPIHost proves C10. +func TestC10TokenReachesOnlyTheAPIHost(t *testing.T) { + var apiAuthorization, apiAccept, apiAgent string + var downloadAuthorization, downloadAccept, downloadAgent string + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiAuthorization = r.Header.Get("Authorization") + apiAccept = r.Header.Get("Accept") + apiAgent = r.Header.Get("User-Agent") + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer api.Close() + asset := []byte("release") + digest := sha256.Sum256(asset) + download := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downloadAuthorization = r.Header.Get("Authorization") + downloadAccept = r.Header.Get("Accept") + downloadAgent = r.Header.Get("User-Agent") + if strings.HasSuffix(r.URL.Path, "checksums.txt") { + fmt.Fprintf(w, "%x codeaf-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) + return + } + _, _ = w.Write(asset) + })) + defer download.Close() + client := &Client{HTTP: api.Client(), APIBase: api.URL, DownloadBase: download.URL, Token: "secret", Revision: "v0.1.1"} + release, err := client.Select(context.Background(), Choice{Channel: "stable"}) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), InstallOptions{Client: client, Release: release, Target: target}); err != nil { + t.Fatal(err) + } + if apiAuthorization != "Bearer secret" || downloadAuthorization != "" { + t.Fatalf("authorization: api %q download %q", apiAuthorization, downloadAuthorization) + } + if apiAccept != "application/vnd.github+json" || downloadAccept != "application/octet-stream" { + t.Fatalf("accept: api %q download %q", apiAccept, downloadAccept) + } + if apiAgent != "codeaf/v0.1.1" || downloadAgent != "codeaf/v0.1.1" { + t.Fatalf("user agent: api %q download %q", apiAgent, downloadAgent) + } +} + +// TestC10RedirectsNeverCarryTheAPITokenToAnotherHost proves the redirect case +// with two real servers that share a hostname and differ only by port. +func TestC10RedirectsNeverCarryTheAPITokenToAnotherHost(t *testing.T) { + var redirectedAuthorization string + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + redirectedAuthorization = request.Header.Get("Authorization") + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + })) + defer destination.Close() + var apiAuthorization string + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + apiAuthorization = request.Header.Get("Authorization") + http.Redirect(w, request, destination.URL+request.URL.Path, http.StatusFound) + })) + defer api.Close() + client := &Client{HTTP: api.Client(), APIBase: api.URL, Token: "secret", Revision: "v0.1.1"} + if _, err := client.Select(context.Background(), Choice{Channel: "stable"}); err != nil { + t.Fatal(err) + } + if apiAuthorization != "Bearer secret" || redirectedAuthorization != "" { + t.Fatalf("authorization: original host %q redirected host %q", apiAuthorization, redirectedAuthorization) + } +} + +// TestC11RepositoryAndAssetFallbacksKeepOldReleasesInstallable proves C11. +func TestC11RepositoryAndAssetFallbacksKeepOldReleasesInstallable(t *testing.T) { + asset := []byte("old named release") + digest := sha256.Sum256(asset) + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch { + case strings.Contains(r.URL.Path, "/repos/"+primaryRepository+"/"): + http.NotFound(w, r) + case strings.Contains(r.URL.Path, "/repos/"+legacyRepository+"/"): + fmt.Fprint(w, `{"tag_name":"v0.2.0"}`) + case strings.HasSuffix(r.URL.Path, "/aforge-"+runtime.GOOS+"-"+runtime.GOARCH): // legacy-name + _, _ = w.Write(asset) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%x *aforge-%s-%s\n", digest, runtime.GOOS, runtime.GOARCH) // legacy-name + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := releaseClient(server, "v0.1.1") + release, err := client.Select(context.Background(), Choice{Channel: "stable"}) + if err != nil || release.Repository != legacyRepository { + t.Fatalf("release = %+v, error %v", release, err) + } + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Install(context.Background(), InstallOptions{Client: client, Release: release, Target: target}); err != nil { + t.Fatal(err) + } + joined := strings.Join(paths, "\n") + for _, want := range []string{"/repos/" + primaryRepository, "/repos/" + legacyRepository, "/codeaf-", "/aforge-"} { // legacy-name + if !strings.Contains(joined, want) { + t.Fatalf("requests do not contain %q:\n%s", want, joined) + } + } +} + +// TestAssetFailureNamesTheCurrentAssetAndStatusWithoutExposingFallbacks proves D9. +func TestAssetFailureNamesTheCurrentAssetAndStatusWithoutExposingFallbacks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.1.0"), + Release: Release{Tag: "v0.2.0", Repository: primaryRepository}, + Target: target, + }) + if err == nil { + t.Fatal("missing asset returned no error") + } + want := fmt.Sprintf("codeaf-%s-%s answered HTTP 404", runtime.GOOS, runtime.GOARCH) + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } + if strings.Contains(err.Error(), server.URL) || strings.Contains(err.Error(), "aforge") { // legacy-name + t.Fatalf("person-facing error exposes a fallback: %q", err) + } +} + +// TestC14RestartArgumentsReopenTheSameConversation proves C14. +func TestC14RestartArgumentsReopenTheSameConversation(t *testing.T) { + file := "/tmp/conversation.jsonl" + for _, row := range []struct { + name string + args []string + want []string + }{ + {"bare", nil, []string{"chat", "--session", file}}, + {"chat", []string{"chat", "--model", "x"}, []string{"chat", "--model", "x", "--session", file}}, + {"resume", []string{"resume", "--session", file}, []string{"resume", "--session", file}}, + } { + t.Run(row.name, func(t *testing.T) { + got := RestartArgs(row.args, file) + if strings.Join(got, "\x00") != strings.Join(row.want, "\x00") { + t.Fatalf("args = %q, want %q", got, row.want) + } + }) + } +} diff --git a/internal/update/version.go b/internal/update/version.go new file mode 100644 index 0000000000..5bb1a954ae --- /dev/null +++ b/internal/update/version.go @@ -0,0 +1,247 @@ +// Package update finds, installs, and restarts onto codeaf releases. +package update + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var ( + stableTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + rcTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$`) + devTagPattern = regexp.MustCompile(`^dev-[0-9]{8}-[0-9a-f]{12}$`) + stagingTagPattern = regexp.MustCompile(`^staging-[0-9]{8}-[0-9a-f]{12}$`) + shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{12,40}$`) + datePattern = regexp.MustCompile(`^[0-9]{8}$`) +) + +// Version is the stable semantic part of a release tag. +type Version struct { + Major int + Minor int + Patch int +} + +func (v Version) String() string { + return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// Compare orders stable semantic versions. +func (v Version) Compare(other Version) int { + switch { + case v.Major != other.Major: + return compareInt(v.Major, other.Major) + case v.Minor != other.Minor: + return compareInt(v.Minor, other.Minor) + default: + return compareInt(v.Patch, other.Patch) + } +} + +func compareInt(left, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + +// Bump advances one semantic component. +func (v Version) Bump(component string) (Version, error) { + switch component { + case "patch": + v.Patch++ + case "minor": + v.Minor++ + v.Patch = 0 + case "major": + v.Major++ + v.Minor = 0 + v.Patch = 0 + default: + return Version{}, fmt.Errorf("component %q must be patch, minor, or major", component) + } + return v, nil +} + +// RCTag is a release candidate and the stable line it belongs to. +type RCTag struct { + Base Version + Counter int +} + +// CompareSemverTags orders stable and release-candidate tags. A stable tag +// follows every release candidate on its own version line. +func CompareSemverTags(left, right string) (int, bool) { + leftVersion, leftStable := ParseStable(left) + leftRC, leftCandidate := ParseRC(left) + rightVersion, rightStable := ParseStable(right) + rightRC, rightCandidate := ParseRC(right) + if (!leftStable && !leftCandidate) || (!rightStable && !rightCandidate) { + return 0, false + } + if leftCandidate { + leftVersion = leftRC.Base + } + if rightCandidate { + rightVersion = rightRC.Base + } + if comparison := leftVersion.Compare(rightVersion); comparison != 0 { + return comparison, true + } + switch { + case leftStable && rightCandidate: + return 1, true + case leftCandidate && rightStable: + return -1, true + case leftCandidate && rightCandidate: + return compareInt(leftRC.Counter, rightRC.Counter), true + default: + return 0, true + } +} + +func parseComponent(raw string) (int, bool) { + value, err := strconv.Atoi(raw) + // Every accepted component must leave room for the resolver's next bump. + // A number this process cannot represent or increment is a junk tag, just + // like a tag that does not match the grammar at all. + if err != nil || value == int(^uint(0)>>1) { + return 0, false + } + return value, true +} + +func parseVersion(match []string) (Version, bool) { + major, majorOK := parseComponent(match[1]) + minor, minorOK := parseComponent(match[2]) + patch, patchOK := parseComponent(match[3]) + if !majorOK || !minorOK || !patchOK { + return Version{}, false + } + return Version{Major: major, Minor: minor, Patch: patch}, true +} + +// ParseStable parses a stable release tag. +func ParseStable(tag string) (Version, bool) { + match := stableTagPattern.FindStringSubmatch(tag) + if match == nil { + return Version{}, false + } + return parseVersion(match) +} + +// ParseRC parses a release-candidate tag. +func ParseRC(tag string) (RCTag, bool) { + match := rcTagPattern.FindStringSubmatch(tag) + if match == nil { + return RCTag{}, false + } + base, baseOK := parseVersion(match) + counter, counterOK := parseComponent(match[4]) + if !baseOK || !counterOK { + return RCTag{}, false + } + return RCTag{Base: base, Counter: counter}, true +} + +// Kind names the release channel encoded by a tag. +func Kind(tag string) string { + switch { + case stableTagPattern.MatchString(tag): + return "stable" + case rcTagPattern.MatchString(tag): + return "rc" + case devTagPattern.MatchString(tag): + return "dev" + case stagingTagPattern.MatchString(tag): + return "staging" + default: + return "other" + } +} + +// ValidSHA reports whether a revision can form a channel tag. +func ValidSHA(value string) bool { return shaPattern.MatchString(value) } + +// ValidDate reports whether a date has the channel tag's YYYYMMDD shape. +func ValidDate(value string) bool { return datePattern.MatchString(value) } + +// ChannelTag forms a dev or staging tag from a date and revision. +func ChannelTag(channel, date, sha string) (string, error) { + if channel != "dev" && channel != "staging" { + return "", fmt.Errorf("channel must be dev or staging") + } + if !ValidDate(date) { + return "", fmt.Errorf("date must be YYYYMMDD") + } + if !ValidSHA(sha) { + return "", fmt.Errorf("sha must be 12 to 40 hexadecimal characters") + } + return fmt.Sprintf("%s-%s-%s", channel, date, strings.ToLower(sha[:12])), nil +} + +// NextSemverTag returns the next stable or release-candidate tag. +func NextSemverTag(tags []string, channel, component string) (string, error) { + stableLatest := Version{} + for _, tag := range tags { + if candidate, ok := ParseStable(tag); ok && candidate.Compare(stableLatest) > 0 { + stableLatest = candidate + } + } + + var ( + openLine Version + haveOpen bool + maxCounter int + ) + for _, tag := range tags { + candidate, ok := ParseRC(tag) + if !ok || candidate.Base.Compare(stableLatest) <= 0 { + continue + } + switch comparison := candidate.Base.Compare(openLine); { + case !haveOpen || comparison > 0: + openLine, haveOpen, maxCounter = candidate.Base, true, candidate.Counter + case comparison == 0 && candidate.Counter > maxCounter: + maxCounter = candidate.Counter + } + } + + var answer string + switch channel { + case "rc": + if haveOpen { + answer = fmt.Sprintf("%s-rc.%d", openLine, maxCounter+1) + } else { + next, err := stableLatest.Bump(component) + if err != nil { + return "", err + } + answer = next.String() + "-rc.1" + } + case "stable": + if component == "patch" && haveOpen { + answer = openLine.String() + } else { + next, err := stableLatest.Bump(component) + if err != nil { + return "", err + } + answer = next.String() + } + default: + return "", fmt.Errorf("channel must be stable or rc") + } + for _, tag := range tags { + if tag == answer { + return "", fmt.Errorf("release tag %s already exists", answer) + } + } + return answer, nil +} diff --git a/scripts/install.sh b/scripts/install.sh index 72203e338c..ce18d35bf9 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -227,6 +227,19 @@ extract_tags() { sed -E 's/^"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)"$/\1/' } +# Channel selection accepts exactly the grammar used by internal/update/version.go. +tag_matches_channel() { + local channel="$1" + local tag="$2" + case "$channel" in + stable) [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ;; + rc) [[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.([1-9][0-9]*)$ ]] ;; + dev) [[ "$tag" =~ ^dev-[0-9]{8}-[0-9a-f]{12}$ ]] ;; + staging) [[ "$tag" =~ ^staging-[0-9]{8}-[0-9a-f]{12}$ ]] ;; + *) return 1 ;; + esac +} + # GitHub's release-list order is not publish order, so the LAW carries each tag's own timestamp into channel selection. extract_dated_tags() { awk ' @@ -308,9 +321,7 @@ else TAG_STAMP="" # GitHub's list order is not publish order, so the LAW chooses a matching candidate by its own release timestamp. while IFS=' ' read -r stamp candidate; do - if [[ "$CHANNEL" == "rc" && "$candidate" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] || - [[ "$CHANNEL" == "dev" && "$candidate" == dev-* ]] || - [[ "$CHANNEL" == "staging" && "$candidate" == staging-* ]]; then + if tag_matches_channel "$CHANNEL" "$candidate"; then if [[ -z "$TAG" || "$stamp" > "$TAG_STAMP" ]]; then TAG="$candidate" TAG_STAMP="$stamp" @@ -328,13 +339,17 @@ if [[ -z "${TAG:-}" ]]; then api_problem fi -if [[ "$TAG" =~ ^dev-[0-9]{8}-[0-9a-f]{12}$ ]]; then +if [[ -z "$VERSION" ]] && ! tag_matches_channel "$CHANNEL" "$TAG"; then + fail "no $CHANNEL build has been published yet" +fi + +if tag_matches_channel "dev" "$TAG"; then DISPLAY_CHANNEL="dev" -elif [[ "$TAG" =~ ^staging-[0-9]{8}-[0-9a-f]{12}$ ]]; then +elif tag_matches_channel "staging" "$TAG"; then DISPLAY_CHANNEL="staging" -elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]]; then +elif tag_matches_channel "rc" "$TAG"; then DISPLAY_CHANNEL="rc" -elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then +elif tag_matches_channel "stable" "$TAG"; then DISPLAY_CHANNEL="stable" else DISPLAY_CHANNEL=""