diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 9085169514..529b2334eb 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -126,11 +126,14 @@ func openChatV3(name string, args []string, pickSession bool) error { // second id, or under none, is a record nothing can be joined to. The id is // minted whether or not the switch is on: /debug can turn the record on // halfway through a conversation, and it needs a run to turn it on for. + // + // THE RECORD ITSELF OPENS AFTER THE LAUNCH'S OWN UPDATE below. That road + // can hand the terminal to another build, and a record opened first would + // be one that run never writes to. Nothing between here and there makes a + // model call. if *debug { trace.Enable() } - ctx := openDebugRecord(name, *model, "") - defer trace.Announce(ctx, os.Stderr) if flags.NArg() != 0 { // Resume's usage names no --once and no --session: it opens a list of // the sessions there ARE, so naming one on the command line is the other @@ -206,6 +209,17 @@ func openChatV3(name string, args []string, pickSession bool) error { if !ok { return fmt.Errorf("--reasoning %q: use auto, low, medium, high, xhigh or max (off also clears the override)", *reasoning) } + // THE LAUNCH'S OWN UPDATE, after every refusal above and before any door + // forks: a released codeaf installs the newest stable and restarts onto it + // here, before a frame is drawn, and every other launch is told what is out + // on one terminal line (prelaunch.go). It is the same for all four doors + // because the file being replaced is this machine's whichever one opens. + // --once is a script, not a person, and is never restarted under it. + if strings.TrimSpace(*once) == "" { + updateBeforeLaunch() + } + ctx := openDebugRecord(name, *model, "") + defer trace.Announce(ctx, os.Stderr) // THE OTHER DOOR, and it forks BEFORE any of this machine's own resolution // below: over --host the models, the keys, the session files, the gate and diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index bc2ef7d242..f66c3b645e 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -80,7 +80,19 @@ func runSurface(ctx context.Context, options tui3.Options) error { // A BREW, DISTRO OR NIX FILE, OR ONE IN A FOLDER THIS ACCOUNT CANNOT // WRITE, is not ours to replace in place. Refusal: func() string { return codeupdate.InstallRefusal(executable, curl) }, + // THE BUILD'S HALF OF THE AUTOMATIC ROAD, decided by the check off the + // frame on the resolved file (codeupdate.AutomaticFor), exactly as the + // launch's own road decides it (prelaunch.go): only a stable codeaf + // moving to a newer stable counts down. devaf, stageaf, a dev build, a + // release candidate and every other name hear the quiet line instead. + Automatic: func(available codeupdate.Available) bool { return available.Automatic }, + // The release a person already stopped with ctrl+c before this window + // opened. Counting down to it again would be asking twice. + Deferred: launchUpdate.deferred, } + // A BUILD THE LAUNCH JUST INSTALLED SAYS SO once it is on screen, because + // the line the old build printed is under the chat now. + options.UpdatedFrom = launchUpdate.updatedFrom options.UpdateCheck = func(check context.Context) (codeupdate.Available, bool) { return codeupdate.CheckLaunch(check, codeupdate.CheckOptions{ Running: revision, ProfileDir: options.ProfileDir, Client: client, diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index c15f419fcd..39c0af6d1c 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -551,7 +551,8 @@ Housekeeping — changes state on disk or on the network codeaf disconnect forget a provider and the key or sign-in behind it codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] - check or install a release; this build's own channel is the default + check or install a release; this build's own channel is the default; + --version pins that release until a plain codeaf update moves it codeaf cache what the shared build cache holds, and how big it is codeaf cache clean [--yes] @@ -689,8 +690,9 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row. 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. + 1 skips the launch check and the update a released + codeaf installs as it opens; /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/prelaunch.go b/cmd/codeaf/prelaunch.go new file mode 100644 index 0000000000..0a2b8299a2 --- /dev/null +++ b/cmd/codeaf/prelaunch.go @@ -0,0 +1,275 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/config" + internalenv "github.com/Agent-Field/codeaf/internal/env" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +// ── THE UPDATE BEFORE THE CHAT OPENS ──────────────────────────────────────── +// +// A person who typed `codeaf` on a released build gets the newest stable +// release BEFORE anything is drawn: the launch looks once, installs, and hands +// the terminal to the new build with the same arguments. Everything here is +// bounded so that a launch is never held hostage by the network: +// +// - The look is free on most launches — the answer is cached for a day — and +// otherwise waits at most [codeupdate.PrelaunchLookup]. A look that got no +// answer is not waited on again for an hour. +// - The install waits at most [prelaunchInstallWait], and ctrl+c ends it at +// once. Either way the build already on disk opens, and the chat's own +// background road (internal/tui3's updateoffer.go) is still there. +// - Anything that goes wrong is one line on stderr, and the chat opens. +// +// WHO IS INSTALLED UNASKED is [codeupdate.AutomaticFor]'s question, and it is +// asked here with the person's half beside it: the `update.auto` row, a pin, a +// release they skipped. A launch that will not install still SAYS what is out, +// on one line in the terminal, before the chat says it again inside. +// +// IT RUNS ONLY WHERE A PERSON IS WAITING ON IT: standard input and output are a +// terminal, `CI` is unset, and the launch is not `--once`. A source build, a +// `CODEAF_NO_UPDATE_CHECK=1` shell or a launch the last one started does not +// look at all. + +// updatedFromEnv is how a launch tells the build it just installed what it +// replaced. The new build reads it, clears it so no child inherits it, and +// does not look again — which is also what makes a restart loop impossible. +const updatedFromEnv = "CODEAF_UPDATED_FROM" + +// prelaunchInstallWait bounds the download a person watches before the chat +// opens. It is long enough for a release on an ordinary connection and short +// enough that a slow one opens the build on disk rather than holding the +// terminal; the chat's own road finishes the job in the background. +const prelaunchInstallWait = 20 * time.Second + +var ( + prelaunchRevision = buildinfo.Revision + prelaunchExecutable = os.Executable + prelaunchClient = codeupdate.NewClient + prelaunchInstall = codeupdate.Install + prelaunchRelaunch = codeupdate.Relaunch + prelaunchArguments = func() []string { return append([]string(nil), os.Args[1:]...) } + prelaunchOut io.Writer = os.Stderr + prelaunchNow = time.Now + prelaunchLookupWait = codeupdate.PrelaunchLookup + prelaunchInstallFor = prelaunchInstallWait + // prelaunchAttended is whether a person is at this terminal: both ends of + // it are a terminal and no CI runner says otherwise. A pipe, a script or a + // test harness is nobody to restart in front of. + prelaunchAttended = func() bool { + return stdinIsTerminal(os.Stdin) && stdinIsTerminal(os.Stdout) && strings.TrimSpace(internalenv.Value("CI")) == "" + } + // prelaunchMeterLive is whether the moving download line has a terminal to + // move on: the writer it draws to is standard error. + prelaunchMeterLive = func() bool { return stdinIsTerminal(os.Stderr) } + // prelaunchInterrupt turns ctrl+c into "open what is on disk now" for as + // long as the install runs, and gives the signal back afterwards. + prelaunchInterrupt = func(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, os.Interrupt) + } +) + +// launchUpdate is what this launch's own road leaves for the surface it opens. +// It is package state because four doors open the surface and all of them go +// through [runSurface], which is the one place that reads it. +var launchUpdate launchUpdateFacts + +type launchUpdateFacts struct { + // updatedFrom is the build the previous process replaced, or "". + updatedFrom string + // deferred is the release a person stopped with ctrl+c at this launch. The + // chat names it on its quiet line instead of counting down to it again. + deferred string +} + +// updateBeforeLaunch is the launch's own road. It returns only to let the chat +// open; a successful install never returns, because the new build has taken +// the terminal. +func updateBeforeLaunch() { + launchUpdate = launchUpdateFacts{} + // THE BUILD A LAUNCH JUST STARTED DOES NOT LOOK AGAIN. The variable is + // cleared before anything else, so an engine or a worker this process + // starts never inherits a fact about a launch it was not part of. + if from := strings.TrimSpace(internalenv.Get(updatedFromEnv)); from != "" { + // BOTH SPELLINGS GO, because the one door that read it answers from + // either: a shell exporting the legacy spelling would otherwise switch + // this road off for good and claim an update on every launch. + _ = os.Unsetenv(updatedFromEnv) + _ = os.Unsetenv(internalenv.Legacy(updatedFromEnv)) + launchUpdate.updatedFrom = from + return + } + running := strings.TrimSpace(prelaunchRevision()) + if codeupdate.Kind(running) == "other" || internalenv.Get(codeupdate.NoUpdateCheckEnv) == "1" || !prelaunchAttended() { + return + } + executable, err := prelaunchExecutable() + if err != nil { + return + } + profileDir := config.ProfileDir() + client := prelaunchClient(running, codeupdate.CheckTimeout) + look, cancel := context.WithTimeout(context.Background(), prelaunchLookupWait) + available, newer := codeupdate.CheckLaunch(look, codeupdate.CheckOptions{ + Running: running, ProfileDir: profileDir, Client: client, + Executable: executable, Prelaunch: true, Now: prelaunchNow, + }) + cancel() + if !newer { + return + } + tag := strings.TrimSpace(available.Latest) + command := codeupdate.InstallName(executable) + " update" + curl := codeupdate.CurlLine(executable, codeupdate.FollowedChannel(running)) + headline := "codeaf " + tag + " is out · you have " + running + // THE ORDER IS THE CHAT'S ORDER (internal/tui3's tookUpdateCheck), so the + // terminal and the chat never give one launch two different answers. + switch { + case codeupdate.InstallRefusal(executable, curl) != "": + fmt.Fprintln(prelaunchOut, headline+" · "+codeupdate.InstallRefusal(executable, curl)) + return + case strings.TrimSpace(available.Pinned) != "": + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is out · you have "+running+", pinned · "+command+" installs it and unpins") + return + case !config.UpdateAutoAt(profileDir) || !available.Automatic: + // THE BUILD'S HALF IS THE CHECK'S ANSWER (codeupdate.AutomaticFor on + // the resolved file), so this road and the chat cannot disagree about a + // symlinked install. + fmt.Fprintln(prelaunchOut, headline+" · "+command+" installs it") + return + } + state := codeupdate.LoadAutoState(profileDir) + if !codeupdate.ShouldOffer(state, tag, prelaunchNow()) || state.SlowRecently(tag, prelaunchNow()) { + // A RELEASE SOMEBODY SKIPPED, OR ONE STILL BACKING OFF AFTER A FAILED + // INSTALL, is not raised here — exactly as the chat does not raise it. + // ONE THAT DID NOT ARRIVE IN TIME AT A RECENT LAUNCH is left to the + // chat's background road too, which has no clock a person waits on: + // paying the same twenty seconds again would teach nothing new. + return + } + installBeforeLaunch(client, available, executable, curl, profileDir) +} + +// installBeforeLaunch downloads and installs the release a launch found, then +// hands the terminal to it. Every way it can stop short says one line and +// returns, so the build already on disk opens. +func installBeforeLaunch(client *codeupdate.Client, available codeupdate.Available, executable, curl, profileDir string) { + running, tag := strings.TrimSpace(available.Running), strings.TrimSpace(available.Latest) + target, err := codeupdate.ExecutableTarget(func() (string, error) { return executable, nil }) + if err != nil { + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is out · this codeaf could not be found on disk · opening "+running) + return + } + fmt.Fprintln(prelaunchOut, "updating codeaf "+running+" to "+tag+" · ctrl+c opens "+running+" now") + interrupted, stopInterrupt := prelaunchInterrupt(context.Background()) + install, cancel := context.WithTimeout(interrupted, prelaunchInstallFor) + meter := newDownloadMeter(prelaunchOut, prelaunchMeterLive()) + result, err := prelaunchInstall(install, codeupdate.InstallOptions{ + Client: client, Target: target, Curl: curl, Automatic: true, + // THE RELEASE THE LOOK NAMED, with the moment it carried, and never a + // fresh channel resolve: what was announced is what is installed. + Release: codeupdate.Release{Tag: tag, PublishedAt: available.LatestPublished}, + Progress: meter.observe, + }) + timedOut := errors.Is(install.Err(), context.DeadlineExceeded) + stopped := interrupted.Err() != nil + cancel() + stopInterrupt() + meter.clear() + if err != nil { + var refusal *codeupdate.RefusalError + switch { + case stopped: + launchUpdate.deferred = tag + fmt.Fprintln(prelaunchOut, "update stopped · opening "+running) + case timedOut: + // WRITTEN DOWN, so the next launch does not start the same download + // against the same clock ([codeupdate.AutoState.SlowRecently]). + codeupdate.AutoSlow(profileDir, tag) + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is still downloading · opening "+running+" now · the chat installs it in the background") + case errors.As(err, &refusal): + fmt.Fprintln(prelaunchOut, refusal.Reason) + default: + // A REAL FAILURE COUNTS, on the same record the chat keeps, so a + // release that cannot be installed backs off instead of costing + // every launch the same wait. + codeupdate.AutoFailure(profileDir, tag, err) + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" could not be installed · "+err.Error()+" · opening "+running) + } + return + } + codeupdate.AutoSuccess(profileDir) + if result.Already { + // THE FILE ALREADY HELD THE RELEASE — another window installed it after + // this process started — so nothing was installed here, and the line + // says so. The file on disk is still newer than this process, so the + // terminal is still handed to it. + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" is already on disk · opening it") + } else { + fmt.Fprintln(prelaunchOut, "codeaf "+tag+" installed · opening it") + } + _ = os.Setenv(updatedFromEnv, running) + if err := prelaunchRelaunch(codeupdate.Plan{Path: result.Path, Args: prelaunchArguments()}); err != nil { + // The file on disk is the new build; this process is still the old one, + // and it is better to open it than to leave the person at a prompt. + _ = os.Unsetenv(updatedFromEnv) + fmt.Fprintln(prelaunchOut, "could not start codeaf "+tag+": "+err.Error()+" · opening "+running+" · the next launch opens "+tag) + } +} + +// downloadMeter draws the one line that moves while a release arrives. It +// redraws at most ten times a second and only when the shown number changes, +// because a 32 KB read is far finer than anybody can see. +// +// IT DRAWS ONLY ON A TERMINAL. The launch runs with stdin and stdout on one, +// but `codeaf 2>log` sends this writer to a file, where a carriage return and +// an erase-line sequence are litter rather than a moving number. +type downloadMeter struct { + out io.Writer + live bool + shown string + last time.Time + drew bool +} + +func newDownloadMeter(out io.Writer, live bool) *downloadMeter { + return &downloadMeter{out: out, live: live} +} + +func (m *downloadMeter) observe(received, total int64) { + if !m.live { + return + } + line := " " + megabytes(received) + if total > 0 { + line += " of " + megabytes(total) + } + line += " MB" + now := time.Now() + if line == m.shown || (m.drew && now.Sub(m.last) < 100*time.Millisecond && received != total) { + return + } + m.shown, m.last, m.drew = line, now, true + fmt.Fprint(m.out, "\r\x1b[K"+line) +} + +// clear takes the moving line away so whatever is said next starts clean. +func (m *downloadMeter) clear() { + if m.drew { + fmt.Fprint(m.out, "\r\x1b[K") + } +} + +func megabytes(bytes int64) string { + return fmt.Sprintf("%d", (bytes+500_000)/1_000_000) +} diff --git a/cmd/codeaf/prelaunch_test.go b/cmd/codeaf/prelaunch_test.go new file mode 100644 index 0000000000..0ca23d2703 --- /dev/null +++ b/cmd/codeaf/prelaunch_test.go @@ -0,0 +1,424 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + internalenv "github.com/Agent-Field/codeaf/internal/env" + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +// launchRelease is a release host that answers the stable channel with one tag +// and serves its asset, counting every request it is asked. +type launchRelease struct { + server *httptest.Server + requests atomic.Int64 + asset []byte + // hold, when set, is how long every request waits before it answers. + hold time.Duration + // stall, when set, is closed by the test to release a held asset. + stall chan struct{} + // checksum overrides the asset's real digest, to make a bad install. + checksum string +} + +func newLaunchRelease(t *testing.T, latest string) *launchRelease { + t.Helper() + release := &launchRelease{asset: []byte("codeaf " + latest + " release bytes")} + release.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + release.requests.Add(1) + if release.hold > 0 { + select { + case <-time.After(release.hold): + case <-r.Context().Done(): + return + } + } + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprintf(w, `{"tag_name":%q}`, latest) + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + sum := sha256.Sum256(release.asset) + checksum := hex.EncodeToString(sum[:]) + if release.checksum != "" { + checksum = release.checksum + } + fmt.Fprintf(w, "%s codeaf-%s-%s\n", checksum, runtime.GOOS, runtime.GOARCH) + case strings.HasSuffix(r.URL.Path, "/codeaf-"+runtime.GOOS+"-"+runtime.GOARCH): + if release.stall != nil { + select { + case <-release.stall: + case <-r.Context().Done(): + return + } + } + _, _ = w.Write(release.asset) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(release.server.Close) + return release +} + +// launchLab is one launch's whole world: the running build, the file it runs +// from, a profile of its own and what it printed and relaunched. +type launchLab struct { + out *bytes.Buffer + target string + profile string + relaunched []codeupdate.Plan + updatedEnv string +} + +func newLaunchLab(t *testing.T, running string, release *launchRelease, name string) *launchLab { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + lab := &launchLab{out: &bytes.Buffer{}, target: filepath.Join(dir, name), profile: t.TempDir()} + if err := os.WriteFile(lab.target, []byte("codeaf "+running+" bytes"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(config.ProfileDirEnv, lab.profile) + t.Setenv(updatedFromEnv, "") + t.Setenv(codeupdate.NoUpdateCheckEnv, "") + saved := struct { + revision func() string + executable func() (string, error) + client func(string, time.Duration) *codeupdate.Client + relaunch func(codeupdate.Plan) error + arguments func() []string + attended func() bool + lookup time.Duration + installFor time.Duration + interrupt func(context.Context) (context.Context, context.CancelFunc) + launchFacts launchUpdateFacts + }{prelaunchRevision, prelaunchExecutable, prelaunchClient, prelaunchRelaunch, prelaunchArguments, prelaunchAttended, prelaunchLookupWait, prelaunchInstallFor, prelaunchInterrupt, launchUpdate} + savedOut, savedInstall, savedMeter := prelaunchOut, prelaunchInstall, prelaunchMeterLive + prelaunchMeterLive = func() bool { return false } + prelaunchRevision = func() string { return running } + prelaunchExecutable = func() (string, error) { return lab.target, nil } + if release != nil { + prelaunchClient = func(revision string, _ time.Duration) *codeupdate.Client { + return &codeupdate.Client{HTTP: release.server.Client(), APIBase: release.server.URL, DownloadBase: release.server.URL, Revision: revision} + } + } + prelaunchRelaunch = func(plan codeupdate.Plan) error { + lab.relaunched = append(lab.relaunched, plan) + lab.updatedEnv = internalenv.Get(updatedFromEnv) + return nil + } + prelaunchArguments = func() []string { return []string{"chat", "--model", "test/model"} } + prelaunchAttended = func() bool { return true } + prelaunchOut = lab.out + t.Cleanup(func() { + prelaunchRevision, prelaunchExecutable, prelaunchClient = saved.revision, saved.executable, saved.client + prelaunchRelaunch, prelaunchArguments, prelaunchAttended = saved.relaunch, saved.arguments, saved.attended + prelaunchLookupWait, prelaunchInstallFor, prelaunchInterrupt = saved.lookup, saved.installFor, saved.interrupt + prelaunchOut, prelaunchInstall, prelaunchMeterLive = savedOut, savedInstall, savedMeter + launchUpdate = saved.launchFacts + }) + return lab +} + +func (lab *launchLab) onDisk(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(lab.target) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// TestAReleasedCodeafUpdatesAndRestartsBeforeTheChatOpens is the whole road: a +// stable codeaf behind the newest stable installs it, says so, and hands the +// terminal to the new file with the same arguments and the fact of what it +// replaced. +func TestAReleasedCodeafUpdatesAndRestartsBeforeTheChatOpens(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + updateBeforeLaunch() + if got := lab.onDisk(t); got != string(release.asset) { + t.Fatalf("the file on disk is %q, want the release", got) + } + if len(lab.relaunched) != 1 { + t.Fatalf("relaunched %d times, want once:\n%s", len(lab.relaunched), lab.out) + } + plan := lab.relaunched[0] + if plan.Path != lab.target || strings.Join(plan.Args, " ") != "chat --model test/model" { + t.Fatalf("relaunch plan = %+v, want %s with the original arguments", plan, lab.target) + } + if lab.updatedEnv != "v0.7.0" { + t.Fatalf("the new build was told it replaced %q, want v0.7.0", lab.updatedEnv) + } + for _, want := range []string{ + "updating codeaf v0.7.0 to v0.7.1 · ctrl+c opens v0.7.0 now", + "codeaf v0.7.1 installed · opening it", + } { + if !strings.Contains(lab.out.String(), want) { + t.Fatalf("the terminal did not say %q:\n%s", want, lab.out) + } + } + if state := codeupdate.LoadAutoState(lab.profile); state.Failures != 0 { + t.Fatalf("a successful launch install left failures behind: %+v", state) + } +} + +// TestTheBuildALaunchStartedDoesNotLookAgain proves the restart cannot loop: +// the new build reads what it replaced, clears it so no child inherits it, and +// makes no request at all. +func TestTheBuildALaunchStartedDoesNotLookAgain(t *testing.T) { + release := newLaunchRelease(t, "v0.7.2") + newLaunchLab(t, "v0.7.1", release, "codeaf") + t.Setenv(updatedFromEnv, "v0.7.0") + updateBeforeLaunch() + if release.requests.Load() != 0 { + t.Fatalf("the restarted build looked again: %d requests", release.requests.Load()) + } + if launchUpdate.updatedFrom != "v0.7.0" { + t.Fatalf("updatedFrom = %q, want v0.7.0", launchUpdate.updatedFrom) + } + if value, present := os.LookupEnv(updatedFromEnv); present && value != "" { + t.Fatalf("%s is still %q for every child to inherit", updatedFromEnv, value) + } +} + +// TestAFileAlreadyHoldingTheReleaseIsOpenedWithoutClaimingAnInstall proves the +// launch does not say it installed what another window already put there, and +// still hands the terminal to that newer file. +func TestAFileAlreadyHoldingTheReleaseIsOpenedWithoutClaimingAnInstall(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + prelaunchInstall = func(_ context.Context, options codeupdate.InstallOptions) (codeupdate.InstallResult, error) { + return codeupdate.InstallResult{Release: options.Release, Path: options.Target, Already: true}, nil + } + updateBeforeLaunch() + if !strings.Contains(lab.out.String(), "codeaf v0.7.1 is already on disk · opening it") || strings.Contains(lab.out.String(), "installed · opening it") { + t.Fatalf("the launch said:\n%s", lab.out) + } + if len(lab.relaunched) != 1 { + t.Fatalf("relaunched %d times, want once onto the newer file", len(lab.relaunched)) + } +} + +// TestTheLegacySpellingOfTheRestartMarkerIsClearedToo proves a shell exporting +// the old spelling cannot switch the launch road off for good: both spellings +// are cleared the first time it is read. +func TestTheLegacySpellingOfTheRestartMarkerIsClearedToo(t *testing.T) { + newLaunchLab(t, "v0.7.0", newLaunchRelease(t, "v0.7.1"), "codeaf") + legacy := internalenv.Legacy(updatedFromEnv) + t.Setenv(legacy, "v0.6.0") + updateBeforeLaunch() + if launchUpdate.updatedFrom != "v0.6.0" { + t.Fatalf("updatedFrom = %q, want the legacy spelling's value", launchUpdate.updatedFrom) + } + if value, present := os.LookupEnv(legacy); present && value != "" { + t.Fatalf("%s is still %q, so every later launch would skip its update", legacy, value) + } +} + +// TestTheDownloadMeterDrawsOnlyOnATerminal proves `codeaf 2>log` gets no +// carriage returns or erase sequences in its log. +func TestTheDownloadMeterDrawsOnlyOnATerminal(t *testing.T) { + var file, terminal bytes.Buffer + newDownloadMeter(&file, false).observe(10_000_000, 66_000_000) + live := newDownloadMeter(&terminal, true) + live.observe(10_000_000, 66_000_000) + if file.Len() != 0 { + t.Fatalf("a meter with no terminal wrote %q", file.String()) + } + if !strings.Contains(terminal.String(), "10 of 66 MB") { + t.Fatalf("a live meter wrote %q", terminal.String()) + } +} + +// TestEveryLaunchThatWillNotInstallSaysWhatIsOutInTheTerminal proves the other +// half of the road: auto update off, a file not named codeaf and a pinned file +// each print one line naming the release and the road, and replace nothing. +func TestEveryLaunchThatWillNotInstallSaysWhatIsOutInTheTerminal(t *testing.T) { + for _, row := range []struct { + name string + file string + setup func(t *testing.T, lab *launchLab, release *launchRelease) + want string + }{ + { + name: "auto update off", file: "codeaf", + setup: func(t *testing.T, lab *launchLab, _ *launchRelease) { + if err := config.SaveUpdateAuto(lab.profile, false); err != nil { + t.Fatal(err) + } + }, + want: "codeaf v0.7.1 is out · you have v0.7.0 · codeaf update installs it", + }, + { + name: "a file under another name", file: "oldaf", + want: "codeaf v0.7.1 is out · you have v0.7.0 · oldaf update installs it", + }, + { + name: "a pinned file", file: "codeaf", + setup: func(t *testing.T, lab *launchLab, release *launchRelease) { + _, err := codeupdate.Install(context.Background(), codeupdate.InstallOptions{ + Client: prelaunchClient("v0.7.0", time.Second), Target: lab.target, + Release: codeupdate.Release{Tag: "v0.7.0"}, AllowDowngrade: true, + }) + if err != nil { + t.Fatal(err) + } + }, + want: "codeaf v0.7.1 is out · you have v0.7.0, pinned · codeaf update installs it and unpins", + }, + } { + t.Run(row.name, func(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + lab := newLaunchLab(t, "v0.7.0", release, row.file) + if row.setup != nil { + row.setup(t, lab, release) + } + before := lab.onDisk(t) + updateBeforeLaunch() + if !strings.Contains(lab.out.String(), row.want) { + t.Fatalf("the terminal said:\n%s\nwant %q", lab.out, row.want) + } + if lab.onDisk(t) != before || len(lab.relaunched) != 0 { + t.Fatalf("a launch that only announces replaced or restarted: relaunched %d", len(lab.relaunched)) + } + }) + } +} + +// TestNoPersonNoLookAndNoReleaseNoLook proves the launch road never runs where +// nobody is waiting on it — a pipe, a script, CI — or for a build from source, +// or in a shell that asked for no request. +func TestNoPersonNoLookAndNoReleaseNoLook(t *testing.T) { + for _, row := range []struct { + name string + running string + setup func(t *testing.T) + }{ + {name: "nobody at the terminal", running: "v0.7.0", setup: func(*testing.T) { prelaunchAttended = func() bool { return false } }}, + {name: "a source build", running: "a65a9eed"}, + {name: "CODEAF_NO_UPDATE_CHECK", running: "v0.7.0", setup: func(t *testing.T) { t.Setenv(codeupdate.NoUpdateCheckEnv, "1") }}, + } { + t.Run(row.name, func(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + lab := newLaunchLab(t, row.running, release, "codeaf") + if row.setup != nil { + row.setup(t) + } + updateBeforeLaunch() + if release.requests.Load() != 0 || lab.out.Len() != 0 || len(lab.relaunched) != 0 { + t.Fatalf("requests %d, said %q, relaunched %d", release.requests.Load(), lab.out, len(lab.relaunched)) + } + }) + } +} + +// TestASilentNetworkCostsOneShortWaitAndThenNone proves the look gives up on +// its own clock, says nothing, and does not make the next launch wait again. +func TestASilentNetworkCostsOneShortWaitAndThenNone(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + release.hold = 10 * time.Second + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + prelaunchLookupWait = 50 * time.Millisecond + started := time.Now() + updateBeforeLaunch() + if waited := time.Since(started); waited > 2*time.Second { + t.Fatalf("the launch waited %s on a silent network", waited) + } + if lab.out.Len() != 0 || len(lab.relaunched) != 0 { + t.Fatalf("a silent network said %q and relaunched %d", lab.out, len(lab.relaunched)) + } + asked := release.requests.Load() + updateBeforeLaunch() + if release.requests.Load() != asked { + t.Fatal("the next launch waited on the same silent network again") + } +} + +// TestAnInstallThatCannotFinishOpensTheBuildOnDisk proves every way the install +// can stop short says one line, replaces nothing, restarts nothing — and that +// only a real failure counts against the release, while ctrl+c is remembered +// as the person's own "not now" for the chat that opens next. +func TestAnInstallThatCannotFinishOpensTheBuildOnDisk(t *testing.T) { + t.Run("a bad checksum", func(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + release.checksum = strings.Repeat("0", 64) + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + before := lab.onDisk(t) + updateBeforeLaunch() + if lab.onDisk(t) != before || len(lab.relaunched) != 0 { + t.Fatal("a failed install replaced or restarted") + } + if !strings.Contains(lab.out.String(), "codeaf v0.7.1 could not be installed") || !strings.Contains(lab.out.String(), "opening v0.7.0") { + t.Fatalf("the failure was not said:\n%s", lab.out) + } + if state := codeupdate.LoadAutoState(lab.profile); state.FailedRelease != "v0.7.1" || state.Failures != 1 { + t.Fatalf("the failure was not counted: %+v", state) + } + // AND IT BACKS OFF: the next launch inside the backoff does not try. + lab.out.Reset() + updateBeforeLaunch() + if strings.Contains(lab.out.String(), "updating codeaf") { + t.Fatalf("a launch inside the backoff tried again:\n%s", lab.out) + } + }) + t.Run("the install clock", func(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + release.stall = make(chan struct{}) + defer close(release.stall) + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + prelaunchInstallFor = 100 * time.Millisecond + updateBeforeLaunch() + if !strings.Contains(lab.out.String(), "codeaf v0.7.1 is still downloading · opening v0.7.0 now · the chat installs it in the background") { + t.Fatalf("the clock was not said:\n%s", lab.out) + } + if state := codeupdate.LoadAutoState(lab.profile); state.Failures != 0 || len(lab.relaunched) != 0 || launchUpdate.deferred != "" { + t.Fatalf("a slow download counted as a failure or a stop: %+v, relaunched %d, deferred %q", state, len(lab.relaunched), launchUpdate.deferred) + } + // AND THE NEXT LAUNCH DOES NOT PAY THE SAME CLOCK AGAIN: the release + // is left to the chat's background road, and nothing is downloaded. + asked := release.requests.Load() + lab.out.Reset() + updateBeforeLaunch() + if strings.Contains(lab.out.String(), "updating codeaf") || release.requests.Load() != asked { + t.Fatalf("the next launch started the same slow download again (%d more requests):\n%s", release.requests.Load()-asked, lab.out) + } + }) + t.Run("ctrl+c", func(t *testing.T) { + release := newLaunchRelease(t, "v0.7.1") + release.stall = make(chan struct{}) + defer close(release.stall) + lab := newLaunchLab(t, "v0.7.0", release, "codeaf") + prelaunchInterrupt = func(parent context.Context) (context.Context, context.CancelFunc) { + stopped, cancel := context.WithCancel(parent) + time.AfterFunc(50*time.Millisecond, cancel) + return stopped, cancel + } + updateBeforeLaunch() + if !strings.Contains(lab.out.String(), "update stopped · opening v0.7.0") { + t.Fatalf("the stop was not said:\n%s", lab.out) + } + if launchUpdate.deferred != "v0.7.1" { + t.Fatalf("deferred = %q, want the release the person stopped", launchUpdate.deferred) + } + if state := codeupdate.LoadAutoState(lab.profile); state.Failures != 0 || len(lab.relaunched) != 0 { + t.Fatalf("ctrl+c counted as a failure: %+v, relaunched %d", state, len(lab.relaunched)) + } + }) +} diff --git a/cmd/codeaf/update.go b/cmd/codeaf/update.go index ec03d5d500..6e0dd44bb6 100644 --- a/cmd/codeaf/update.go +++ b/cmd/codeaf/update.go @@ -29,7 +29,7 @@ func runUpdate(args []string) error { 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") + version := flags.String("version", "", "install one exact release tag and pin it there until a plain codeaf update") if err := parseCommandFlags(flags, args); err != nil { return err } @@ -126,15 +126,25 @@ func runUpdate(args []string) error { } if result.Already { fmt.Fprintf(updateOut, "codeaf: %s is already installed at %s\n", result.Release.Tag, result.Path) + sayPinned(result) return nil } fmt.Fprintf(updateOut, "codeaf: installed %s at %s\n", result.Release.Tag, result.Path) + sayPinned(result) if err := updateVersionLine(result.Path, updateOut, updateErr); err != nil { return updateFailure(fmt.Errorf("run the installed codeaf: %w", err), curl) } return nil } +// sayPinned names the pin a named tag just set, so a person who rolled back to +// chase a bug is not surprised by the next launch leaving it there. +func sayPinned(result codeupdate.InstallResult) { + if result.Pinned { + fmt.Fprintf(updateOut, "codeaf: %s is pinned · it is not updated on its own until `codeaf update` runs without --version\n", result.Release.Tag) + } +} + func sayUpdateCheck(running string, release codeupdate.Release, choice codeupdate.Choice) error { selected := release.Tag shown := strings.TrimSpace(running) diff --git a/docs/changes/unreleased/1826-auto-update-on-launch.md b/docs/changes/unreleased/1826-auto-update-on-launch.md new file mode 100644 index 0000000000..b96a4dd64f --- /dev/null +++ b/docs/changes/unreleased/1826-auto-update-on-launch.md @@ -0,0 +1,21 @@ +--- +kind: changed +title: a released codeaf installs the newest stable and restarts onto it before the chat opens +pr: 1826 +surface: [chat, docs] +invalidates: + - "In v0.8.0 an update never happened at launch: the chat opened first, counted down ten seconds in the keys line and installed in the background for the NEXT launch. Now a stable build named codeaf with `automatic updates` on installs a newer stable release before the chat opens and restarts onto it with the same arguments; the chat's countdown is only the fallback when that could not finish." + - "In v0.8.0 the countdown and background install ran for every channel, so devaf, stageaf, dev builds installed as codeaf and release candidates replaced themselves unasked. Now only a stable release in a file named codeaf is ever installed without being asked (`update.AutomaticFor`); every other build is told on one line and installs only on `/update` or `codeaf update`." + - "With `automatic updates` off, a newer release was named only inside the chat. It is now also printed in the terminal before the chat opens, `codeaf is out · you have · codeaf update installs it`, using the file's own name for devaf, stageaf and other installs." + - "There was no way to stay on an older release: the next automatic update moved the file forward again. `codeaf update --version ` and `/update ` now pin the file at that release, the automatic road refuses to move a pinned file, and a plain `codeaf update` or `/update` unpins it." + - "A finished background install said `codeaf installed · this session keeps running · new windows open the updated app; work already running keeps its current engine until all its windows and work close`, and the keys line said `codeaf installed · this session keeps running`. Both now say `codeaf installed · restart to use the new version`." + - "`CODEAF_NO_UPDATE_CHECK=1` used to mean only that no launch check was made. It now also means no update is installed as codeaf opens, because that installation depends on the same launch check." +--- + +The launch's own road lives in `cmd/codeaf/prelaunch.go` and is bounded so that +it never holds a terminal hostage. It runs only where a person is waiting: a +terminal on both ends, `CI` unset, and not `--once`. The check answers from a +day-long cache when it can. Otherwise it waits at most 1.5 seconds, and a check +that got no answer is not waited on again for an hour. The install waits at most +twenty seconds. A slow download, a failure or ctrl+c opens the build already on +disk and says so in one line. diff --git a/internal/config/settings.go b/internal/config/settings.go index 6140da4c1d..18821c9784 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -273,10 +273,14 @@ const ( // because a person who has silenced the tips has said they know the surface, // and being told about features is the same conversation. KeyHints = "ui.hints" - // KeyUpdateAuto is whether codeaf INSTALLS a new release in the background on - // its own. ON is the default: the check is one small request after the first - // frame, and a person who does not want it has a row, an environment variable - // and one chord on the offer itself. OFF means nothing is installed until the + // KeyUpdateAuto is whether codeaf INSTALLS a new release on its own. ON is + // the default: a released codeaf installs a newer stable release as it opens + // and starts on it (cmd/codeaf's prelaunch.go), and the chat's countdown + // installs in the background when that could not finish. Only a stable build + // named codeaf is ever moved this way; dev, staging, rc and source builds are + // told, never replaced ([codeupdate.AutomaticFor]). A person who does not want + // it has a row, an environment variable and one chord on the offer itself. + // OFF means nothing is installed until the // person asks — `/update` still installs a release on request, because a // person who typed it has said what they want — but the LAUNCH CHECK STILL // RUNS and still names the release on one dim line, because knowing is not @@ -1077,6 +1081,11 @@ var OperatorEnvPins = []string{ "CODEAF_NO_UPDATE_CHECK", "CODEAF_GITHUB_API", "CODEAF_GITHUB_DOWNLOAD", + // What a launch that installed a release tells the build it hands the + // terminal to (cmd/codeaf's prelaunch.go): the build it replaced. codeaf + // sets it for exactly one exec and the new process clears it on arrival, so + // it is plumbing between two processes and never a person's choice. + "CODEAF_UPDATED_FROM", // CODEAF_NO_AUTOMATIONS keeps a window from starting the clock that runs // automations (internal/automation's Startable). The demo home sets it so a // look at the fixture spends nothing on its invented automations; it is one @@ -2492,8 +2501,9 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyUpdateAuto, Category: CategoryPractice, Kind: SettingBool, Label: "auto update", - Hint: "keeps this codeaf installation current in the background. " + - "The offer waits in the keys line for about ten seconds; it never blocks a keystroke. " + + Hint: "keeps a released codeaf current: a newer stable release installs as codeaf opens, then codeaf starts on it. " + + "When that cannot finish quickly, the offer waits in the keys line for about ten seconds and installs in the background. " + + "Dev, staging, rc and source builds are never updated on their own. " + "Off, installation is manual — nothing is installed until you run /update — and codeaf " + "still checks once and names a new release (CODEAF_NO_UPDATE_CHECK=1 makes no request at all).", read: func() string { return formatBool(UpdateAutoAt(dir)) }, diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index f18d51d60e..71a458fd1c 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -869,7 +869,8 @@ func TestTheAutoUpdateRowIsFiledWithThePracticeRows(t *testing.T) { // THE FIRST SENTENCE SAYS WHAT THE ROW DOES, and the rest says what off // means: manual installation, while the one launch check still runs. for _, want := range []string{ - "keeps this codeaf installation current in the background.", + "keeps a released codeaf current: a newer stable release installs as codeaf opens", + "Dev, staging, rc and source builds are never updated on their own.", "Off, installation is manual", "still checks once", } { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 073c6325ec..5fedb602e5 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -340,10 +340,10 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/cache` | — | `clean` | asks first, then deletes the cache to free disk — confirm with `/cache clean now` | | `/cache` | — | `clean now` | deletes the shared build cache 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 in the background for the next launch | -| `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag for the next launch | +| `/update` | `/upgrade` | — | installs the newest stable release in the background for the next launch, and unpins | +| `/update` | `/upgrade` | `` | installs that channel's newest release or one exact tag for the next launch; a tag pins the file there | | `/update skip` | `/upgrade skip` | — | answers an open update offer not now, for that release only | -| `/update never` | `/upgrade never` | — | turns `auto update` off; the setting row does the same | +| `/update never` | `/upgrade never` | — | turns `automatic updates` off, so no release installs itself as codeaf opens or in the background; the setting row does the same | | `/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/keys.md b/internal/manual/chat/keys.md index c4db413e9a..32bc560330 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1234,7 +1234,7 @@ remembered as well. With history not wired up (`--no-history`), `up` takes nothing and keeps its other meanings. -## The thinking chip above the message box — `alt+e`, `/effort`, and making this chat think harder +## The thinking chip above the message box — `alt+e`, `/effort`, and how to make this one chat think harder The line above the message box — the legend — names how hard the model will think about your next turn, immediately after the model that will be doing the thinking: diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index c565c718e0..8dfe73d696 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -2,30 +2,83 @@ ## Is there a newer version — update a dev build — keep a dev build up to date — latest dev — /update — why does it say this every time I start -At launch, a stable, dev or staging build behind the newest release of its own -channel is told about it, and with `automatic updates` on (the default) the install -begins on its own after a brief pause: the sentence `codeaf is out · -installs in 10s · skip · /update` waits in the keys line, counted -down from about ten seconds. It never blocks a keystroke. `/update` while it -is up installs immediately — that is the release the offer is about, not a -rollback; the skip chord, or `/update skip`, answers that one release, it is -never offered again, and a later release is a new question; `/update never` turns -`automatic updates` off, and so does the `automatic updates` row on `/settings`' **General** tab. Turning it off does not -cancel an install already downloading, and the launch check still makes its one -request and names what is out. A release whose automatic install failed -three times stops being tried on its own. - -A release candidate gets the notice when its stable line is published. An equal -or ahead build gets nothing. Source and unstamped builds make no launch request, -and their `/update` answers `this codeaf was built from source · rebuild with -make build, or install a release: curl -fsSL https://agentfield.ai/get/codeaf | -bash`. A codeaf owned by Homebrew, Nix or the system package manager, or one in a -folder this account cannot write, is never replaced in place: with `automatic updates` -on or off the launch names the manager and the curl line, not `/update`. - -With `automatic updates` off, the launch check still runs and still says `codeaf -is out · you have · /update installs it for the next launch`, but -nothing is downloaded until you ask. +Every launch of a release build checks the newest release of the channel it +follows, and what happens next depends on the build: + +- A released codeaf — a stable tag, in a file named `codeaf` — with `automatic + updates` on (the default) installs the newest stable release BEFORE the chat + opens and starts on it. The terminal says `updating codeaf to + · ctrl+c opens now`, then `codeaf installed · + opening it`, and the chat's first line says `codeaf updated from to + as it opened · automatic updates in /settings turns this off`. +- Every other build is told and never replaced unasked: dev and staging builds, + release candidates, `devaf`, `stageaf`, any other file name, a pinned file, + and any codeaf with `automatic updates` off. Before the chat opens the + terminal prints `codeaf is out · you have · codeaf update + installs it` (with the file's own name: `devaf update`), and the chat says + `codeaf is out · you have · /update installs it for the + next launch`. Nothing is downloaded until you ask. + +A release candidate is told when its stable line is published. An equal or +ahead build gets nothing. Source and unstamped builds — `make build`, `go run` +— make no launch request at all, and their `/update` answers `this codeaf was +built from source · rebuild with make build, or install a release: curl -fsSL +https://agentfield.ai/get/codeaf | bash`. A codeaf owned by Homebrew, Nix or the +system package manager, or one in a folder this account cannot write, is never +replaced in place: the launch names the manager and the curl line instead. + +## Does it update itself automatically — automatic updates — auto update on launch — restart on the new version — stop updating itself + +Only a released codeaf moving to a newer stable release is updated without +being asked, and only where a person is waiting: standard input and output are +a terminal, `CI` is unset, and the launch is not `--once`. The look is cached +for 24 hours and otherwise waits at most 1.5 seconds; a look that got no answer +is not waited on again for an hour. The install waits at most 20 seconds and +then hands the terminal to the new build with the same arguments. + +Every way it stops short opens the build already on disk. ctrl+c says `update +stopped · opening `, and that window does not count down to the same +release again. A slow download says `codeaf is still downloading · +opening now · the chat installs it in the background`, and for a day +that release is left to the chat rather than waited on at launch. A failure says +`codeaf could not be installed · · opening ` and +counts: a release backs off for hours after a failure and stops being tried on +its own after three. A release you skipped, or one backing off, prints nothing +at launch. + +When the launch could not finish, the chat's offer takes over: `codeaf +is out · installs in 10s · skip · /update` waits in the keys line, +counted down from about ten seconds, then installs in the background and says +`codeaf installed · restart to use the new version`. It never blocks a +keystroke or restarts the conversation. `/update` while it is up +installs at once; the skip chord, or `/update skip`, answers that one release +and it is never offered again. + +To stop all of it, turn off `automatic updates` on `/settings`' **General** +tab or type `/update never`; launches still say what is out. +`CODEAF_NO_UPDATE_CHECK=1` makes no request at all. Turning the row off does not +cancel an install already downloading. + +## Pin a version — stay on an older version — roll back to an earlier release + +`codeaf update --version ` or `/update ` installs that exact release +and PINS it: ``codeaf: is pinned · it is not updated on its own until +`codeaf update` runs without --version`` (the chat says `… until /update runs +without a version`). Naming the release already installed pins it in place +without a download. Naming a tag is also the only way to roll back. + +A pinned file is never updated on its own. Its launches say `codeaf is +out · you have , pinned · codeaf update installs it and unpins`, and an +automatic install that races a pin is refused with `codeaf is pinned on +this machine · /update or codeaf update installs and unpins it`. + +A plain `codeaf update`, `codeaf update --stable`, or `/update` with no tag +installs the newest release and unpins. The pin belongs to the file, not to a +profile: it is kept beside it in `.install.lock`, every profile running +that file sees it, and a file reinstalled with the curl line is no longer +pinned. + +## /update in the chat — install in the background — skip — never — channel — rollback `/update` downloads the release, checks its sha256 and replaces this executable IN THE BACKGROUND; `/upgrade` is its alias. It never quits, never restarts the @@ -45,7 +98,10 @@ two terminals, or two profiles sharing one codeaf, cannot replace it at once, an a window that resolves a release another terminal has already superseded installs nothing. -Set `CODEAF_NO_UPDATE_CHECK=1` to skip only the launch check. Dev and staging +## Skip the update check — CODEAF_NO_UPDATE_CHECK — update-check.json — which curl line reinstalls this file + +Set `CODEAF_NO_UPDATE_CHECK=1` to skip the launch check and the update before +the chat opens. Dev and staging answers are cached beside `config.json` for one hour in `update-check.dev.json` and `update-check.staging.json`; stable and rc use `update-check.json` for 24 hours. A cached newer release is still shown. The curl line reinstalls this file @@ -61,7 +117,8 @@ stable release its launch line just named, and a file under any other name gets `codeaf update` and `codeaf update --check` default to dev on a `dev-*` build, staging on a `staging-*` build, and stable on stable or rc. `--stable`, `--rc`, -`--dev`, `--staging`, or `--version ` overrides that choice. +`--dev`, `--staging`, or `--version ` overrides that choice. `--version +` also pins the file at that release, and any install without it unpins. `--check` exits 3 when the selected release is newer, and also whenever a dev or staging tag is selected from a build of another channel, because a channel tag @@ -137,7 +194,8 @@ terminal, when `CI` is set, or when the install runs in your home folder or `/` neither does codeaf: what the anonymous usage counts carry is written in `docs/TELEMETRY.md` in the repository, and the `telemetry` switch in `/settings` turns them off. `/update` in the chat or `codeaf update` in a terminal replaces it in place; -running the install line again works too. +running the install line again works too. With `automatic updates` on, a codeaf +installed this way also installs each new stable release itself as it opens. ## What is devaf — dev build beside codeaf — side by side — two versions — install under a different file name — --name @@ -161,7 +219,9 @@ may contain ASCII letters, digits, `.`, `_`, and `-`, and must begin with a letter or digit. codeaf and devaf share `~/.codeaf`, including keys and conversations, and one engine per workspace; opening a workspace with the other build retires an idle host or joins a busy compatible one. A devaf launch checks -the dev channel hourly, and bare `/update` keeps following dev. +the dev channel hourly, and bare `/update` keeps following dev. A devaf is never +updated on its own: a launch behind the newest dev build says `codeaf +is out · you have · devaf update installs it` and opens as it is. ## What is stageaf — staging build beside codeaf — try the next release early — install staging — how often does staging update @@ -177,6 +237,8 @@ channel and name lines. It installs `~/.codeaf/bin/stageaf` (`stageaf.exe` on Windows) beside an untouched codeaf. `stageaf version` still starts with `codeaf`. It shares `~/.codeaf` with codeaf and devaf, including keys and conversations. A stageaf launch checks the staging channel hourly; bare `/update` follows staging. +Like devaf it is never updated on its own; `stageaf update` installs the newest +staging build when you ask. The promotion runs once a week and moves staging to the newest dev commit at the Friday 17:00 Toronto-time cutoff when the full check passes. When the check diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d6911e1de3..589b47465e 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -3126,6 +3126,15 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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 launch that installs a release and starts on it before the chat + // opens, in the words of somebody who just watched it happen — and of + // somebody who wants it to stop, or to stay on the version they have. + {"does codeaf update itself automatically", "running-from-the-terminal"}, + {"why did codeaf restart on a new version when I opened it", "running-from-the-terminal"}, + {"how do I stop codeaf from updating itself", "running-from-the-terminal"}, + {"pin codeaf to an older version", "running-from-the-terminal"}, + {"how do I stay on an old version of codeaf", "running-from-the-terminal"}, + {"will my devaf update itself", "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. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f193ee31d4..f188cbece5 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3228,6 +3228,9 @@ func newApp(ctx context.Context, opts Options) *app { if notice := strings.TrimSpace(opts.Notice); notice != "" { a.note(notice) } + if from := strings.TrimSpace(opts.UpdatedFrom); from != "" { + a.note(updatedFromNote(from, a.updateRunning)) + } if a.resumed && a.file != "" { a.note(a.resumedNote()) } diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 6cd9c62a53..173af01c89 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -473,6 +473,10 @@ type Options struct { // retry loop, and the cross-terminal install lock. Nil leaves the launch // notice as a one-line note with no offer behind it. UpdateAuto *UpdateCoordinator + // UpdatedFrom is the build the launch replaced before this window opened + // (cmd/codeaf's prelaunch.go), or "". The window's first lines say so, + // because what the old build printed is under the chat by now. + UpdatedFrom string // 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 diff --git a/internal/tui3/updatecmd.go b/internal/tui3/updatecmd.go index 2ecb0bb110..385b2483e1 100644 --- a/internal/tui3/updatecmd.go +++ b/internal/tui3/updatecmd.go @@ -82,7 +82,16 @@ func (a *app) tookUpdateCheck(message updateCheckMsg) tea.Cmd { a.note(refusal) return nil } - if !a.updateAuto.Enabled { + // A PINNED FILE IS TOLD, NOT MOVED. A person put this release here by name + // and the countdown would undo it; the line says how to let it go. + if strings.TrimSpace(message.available.Pinned) != "" { + a.note(pinnedUpdateNotice(message.available)) + return nil + } + // OFF, OR A BUILD THE AUTOMATIC ROAD IS NOT FOR — a dev or staging build, + // a release candidate, a file not named codeaf — or a release the person + // already stopped as this launch opened: one quiet line, nothing counted. + if !a.updateAuto.Enabled || !a.updateAuto.automatic(message.available) { a.note(quietUpdateNotice(message.available)) return nil } @@ -265,6 +274,9 @@ func (a *app) tookUpdateResolve(message updateResolveMsg) tea.Cmd { return func() tea.Msg { result, err := install(context.Background(), codeupdate.InstallOptions{ Release: release, AllowDowngrade: allowDowngrade, + // The countdown's own install is nobody asking; it never moves a + // pinned file and never pins or unpins one. + Automatic: message.auto, }) return updateInstallMsg{result: result, err: err, auto: message.auto, tag: tag} } @@ -296,6 +308,9 @@ func (a *app) tookUpdateInstall(message updateInstallMsg) tea.Cmd { // one this window was about to write, so there is nothing to alarm. a.offer.clear() a.note("codeaf " + tag + " is already the build on disk · this session is untouched") + if message.result.Pinned { + a.note(pinnedNote(tag)) + } return nil } a.offer.ready(a.now()) @@ -311,6 +326,9 @@ func (a *app) tookUpdateInstall(message updateInstallMsg) tea.Cmd { } a.note("checksum matched · installed at " + message.result.Path) a.note(a.updateOfferNote()) + if message.result.Pinned { + a.note(pinnedNote(tag)) + } return a.offerSettleTick() } diff --git a/internal/tui3/updatelaunch_test.go b/internal/tui3/updatelaunch_test.go new file mode 100644 index 0000000000..521290cd0f --- /dev/null +++ b/internal/tui3/updatelaunch_test.go @@ -0,0 +1,150 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + codeupdate "github.com/Agent-Field/codeaf/internal/update" +) + +// TestABuildTheAutomaticRoadIsNotForGetsTheQuietLine proves the countdown is +// only for the builds the rule names: a devaf, a dev build, a release +// candidate or any other file hears the one quiet line and nothing counts down. +func TestABuildTheAutomaticRoadIsNotForGetsTheQuietLine(t *testing.T) { + state := codeupdate.AutoState{} + coordinator := offerAuto(&state) + asked := codeupdate.Available{} + coordinator.Automatic = func(available codeupdate.Available) bool { + asked = available + return false + } + lab := newOfferLab(t, coordinator) + drive(t, lab.app, offerCheck("v0.9.3")) + if lab.app.offer.offering() || lab.installed != 0 { + t.Fatalf("a build the road is not for was counted down: %+v, installed %d", lab.app.offer, lab.installed) + } + if asked.Latest != "v0.9.3" || asked.Running != "v0.9.2" { + t.Fatalf("the rule was asked about %+v", asked) + } + if !strings.Contains(updateNotes(lab.app), "codeaf v0.9.3 is out · you have v0.9.2 · /update installs it for the next launch") { + t.Fatalf("the quiet line was not said:\n%s", updateNotes(lab.app)) + } +} + +// TestAPinnedFileIsToldAndNeverCountedDown proves a person's pin outranks the +// countdown, and the line says the road that lets it go. +func TestAPinnedFileIsToldAndNeverCountedDown(t *testing.T) { + state := codeupdate.AutoState{} + lab := newOfferLab(t, offerAuto(&state)) + check := offerCheck("v0.9.3") + check.available.Pinned = "v0.9.2" + drive(t, lab.app, check) + if lab.app.offer.offering() || lab.installed != 0 { + t.Fatalf("a pinned file was counted down: %+v", lab.app.offer) + } + if !strings.Contains(updateNotes(lab.app), "codeaf v0.9.3 is out · you have v0.9.2, pinned · /update installs it and unpins") { + t.Fatalf("the pinned line was not said:\n%s", updateNotes(lab.app)) + } +} + +// TestAReleaseStoppedAtLaunchIsNotCountedDownAgain proves ctrl+c before the +// chat opened is the person's answer for THAT release only. +func TestAReleaseStoppedAtLaunchIsNotCountedDownAgain(t *testing.T) { + state := codeupdate.AutoState{} + coordinator := offerAuto(&state) + coordinator.Deferred = "v0.9.3" + lab := newOfferLab(t, coordinator) + drive(t, lab.app, offerCheck("v0.9.3")) + if lab.app.offer.offering() { + t.Fatal("the release the person stopped was counted down again") + } + if !strings.Contains(updateNotes(lab.app), "/update installs it for the next launch") { + t.Fatalf("the stopped release was not named:\n%s", updateNotes(lab.app)) + } + later := newOfferLab(t, coordinator) + drive(t, later.app, offerCheck("v0.9.4")) + if !later.app.offer.offering() { + t.Fatal("a later release was treated as the one the person stopped") + } +} + +// TestOnlyTheCountdownInstallsAsNobody proves the installer hears who asked: +// the countdown's own install is automatic, and `/update` typed by a person is +// not — which is what lets a person's install pin and unpin while the +// countdown never can. +func TestOnlyTheCountdownInstallsAsNobody(t *testing.T) { + state := codeupdate.AutoState{} + lab := newOfferLab(t, offerAuto(&state)) + drive(t, lab.app, offerCheck("v0.9.3")) + drive(t, lab.app, updateGraceMsg{}) + if lab.installed != 1 || !lab.automatic { + t.Fatalf("the countdown installed %d times, automatic %t", lab.installed, lab.automatic) + } + typed := newOfferLab(t, offerAuto(&codeupdate.AutoState{})) + command := typed.app.runUpdateCommand("") + if command == nil { + t.Fatal("a typed /update was refused") + } + drive(t, typed.app, command()) + if typed.installed != 1 || typed.automatic { + t.Fatalf("a typed /update installed %d times, automatic %t", typed.installed, typed.automatic) + } +} + +// TestANamedInstallSaysItPinned proves the pin is said the moment it is set, +// whether the release had to be downloaded or was already on disk. +func TestANamedInstallSaysItPinned(t *testing.T) { + for _, already := range []bool{false, true} { + lab := newOfferLab(t, offerAuto(&codeupdate.AutoState{})) + lab.result = codeupdate.InstallResult{Release: codeupdate.Release{Tag: "v0.9.0"}, Path: "/tmp/codeaf", Pinned: true, Already: already} + lab.app.resolveUpdate = func(context.Context, codeupdate.Choice) (codeupdate.Release, error) { + return codeupdate.Release{Tag: "v0.9.0"}, nil + } + command := lab.app.runUpdateCommand("v0.9.0") + if command == nil { + t.Fatal("a named /update was refused") + } + drive(t, lab.app, command()) + if !strings.Contains(updateNotes(lab.app), "codeaf v0.9.0 is pinned · it is not updated on its own until /update runs without a version") { + t.Fatalf("already=%t: the pin was not said:\n%s", already, updateNotes(lab.app)) + } + } +} + +// TestAFinishedInstallSaysOneShortLine proves the completion is the release and +// the one thing to do about it — in the transcript and in the keys line alike — +// and that it fits an eighty-column window. +func TestAFinishedInstallSaysOneShortLine(t *testing.T) { + lab := newOfferLab(t, offerAuto(&codeupdate.AutoState{})) + drive(t, lab.app, offerCheck("v0.9.3")) + drive(t, lab.app, updateGraceMsg{}) + want := "codeaf v0.9.3 installed · restart to use the new version" + if !strings.Contains(updateNotes(lab.app), want) { + t.Fatalf("the transcript did not say %q:\n%s", want, updateNotes(lab.app)) + } + if strings.Contains(updateNotes(lab.app), "new windows open the updated app") { + t.Fatalf("the long completion came back:\n%s", updateNotes(lab.app)) + } + if got := lab.app.updateOfferHint(80); got != want { + t.Fatalf("the keys line says %q, want %q", got, want) + } +} + +// TestAWindowALaunchUpdatedSaysSo proves the line the old build printed is not +// the only record: the chat's own first lines name both builds and the switch. +func TestAWindowALaunchUpdatedSaysSo(t *testing.T) { + a := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", ProfileDir: t.TempDir(), + UpdateRunning: "v0.9.3", UpdatedFrom: "v0.9.2", + }) + if !strings.Contains(updateNotes(a), "codeaf updated from v0.9.2 to v0.9.3 as it opened · automatic updates in /settings turns this off") { + t.Fatalf("the window did not say it was updated:\n%s", updateNotes(a)) + } + quiet := newApp(context.Background(), Options{ + Agent: &fakeAgent{model: "test/model"}, Workspace: "/tmp/lab", ProfileDir: t.TempDir(), UpdateRunning: "v0.9.3", + }) + if strings.Contains(updateNotes(quiet), "codeaf updated from") { + t.Fatalf("an ordinary launch claimed an update:\n%s", updateNotes(quiet)) + } +} diff --git a/internal/tui3/updateoffer.go b/internal/tui3/updateoffer.go index 48604d111e..c50cc71416 100644 --- a/internal/tui3/updateoffer.go +++ b/internal/tui3/updateoffer.go @@ -46,6 +46,27 @@ type UpdateCoordinator struct { Disable func() error // Refusal names why this executable cannot be replaced in place, or "". Refusal func() string + // Automatic is the BUILD's half of the automatic road + // ([codeupdate.AutomaticFor]): whether this file may move to this release + // without anybody asking. Only a stable codeaf moving to a newer stable + // does; every other build gets the quiet line. Nil leaves the road open, so + // a window wired before the rule existed behaves as it always did. + Automatic func(codeupdate.Available) bool + // Deferred is the release a person stopped with ctrl+c while the launch + // was installing it, before this window opened. It gets the quiet line + // rather than a second countdown. + Deferred string +} + +// automatic reports whether this window may count down to a release at all. +func (c *UpdateCoordinator) automatic(available codeupdate.Available) bool { + if c == nil { + return false + } + if deferred := strings.TrimSpace(c.Deferred); deferred != "" && deferred == strings.TrimSpace(available.Latest) { + return false + } + return c.Automatic == nil || c.Automatic(available) } // updateSkipKey is the offer's one action: "not now", said about one exact @@ -215,13 +236,21 @@ func (a *app) updateOfferHint(width int) string { case updateDownloading: return "Updating codeaf in the background · keep working" case updateReady: - return "codeaf " + o.tag + " installed · this session keeps running" + return updateReadyLine(o.tag) case updateFailed: return "codeaf " + o.tag + " could not be installed · /update retries" } return "" } +// updateReadyLine is what a finished install says, in the keys line and in +// the transcript alike: what landed and the one thing to do about it. It is +// short on purpose — the engine and window details it used to spell out are +// the manual's to explain, not every install's to recite. +func updateReadyLine(tag string) string { + return "codeaf " + strings.TrimSpace(tag) + " installed · restart to use the new version" +} + // spellGrace prints a short count for the keys line. func spellGrace(left time.Duration) string { seconds := int(left / time.Second) @@ -268,7 +297,7 @@ func (a *app) updateOfferNote() string { case updateDownloading: return "updating codeaf in the background · this session keeps running" case updateReady: - return "codeaf " + o.tag + " installed · this session keeps running · new windows open the updated app; work already running keeps its current engine until all its windows and work close" + return updateReadyLine(o.tag) case updateFailed: return "codeaf " + o.tag + " could not be installed · this version keeps running · /update retries" } @@ -290,6 +319,28 @@ func refusedUpdateNotice(available codeupdate.Available) string { return updateAheadNotice(available) } +// pinnedUpdateNotice names the release for a file a person pinned by naming a +// tag. `/update` with no tag is the road that moves it forward and unpins it. +func pinnedUpdateNotice(available codeupdate.Available) string { + return updateAheadNotice(available) + ", pinned · /update installs it and unpins" +} + +// pinnedNote is the line a person's own install adds when it left the file +// pinned, so the pin is never a surprise found later. +func pinnedNote(tag string) string { + return "codeaf " + strings.TrimSpace(tag) + " is pinned · it is not updated on its own until /update runs without a version" +} + +// updatedFromNote is the first line of a window a launch has just updated +// onto: the old build's own line is under the chat now. +func updatedFromNote(from, now string) string { + line := "codeaf updated from " + strings.TrimSpace(from) + if now = strings.TrimSpace(now); now != "" { + line += " to " + now + } + return line + " as it opened · automatic updates in /settings turns this off" +} + // updateAheadNotice is the head both launch notices open with: what is out and // what this build is. The two roads differ only in the clause that follows. func updateAheadNotice(available codeupdate.Available) string { diff --git a/internal/tui3/updateoffer_test.go b/internal/tui3/updateoffer_test.go index 8090078df1..e09f04ad4e 100644 --- a/internal/tui3/updateoffer_test.go +++ b/internal/tui3/updateoffer_test.go @@ -35,6 +35,7 @@ type offerLab struct { result codeupdate.InstallResult failWith error allow *bool + automatic bool } func newOfferLab(t *testing.T, coordinator *UpdateCoordinator) *offerLab { @@ -52,6 +53,7 @@ func newOfferLab(t *testing.T, coordinator *UpdateCoordinator) *offerLab { }, InstallUpdate: func(_ context.Context, options codeupdate.InstallOptions) (codeupdate.InstallResult, error) { *lab.allow = options.AllowDowngrade + lab.automatic = options.Automatic if lab.failWith != nil { return codeupdate.InstallResult{}, lab.failWith } diff --git a/internal/update/auto.go b/internal/update/auto.go index 7d5bc820a3..f2ec234d96 100644 --- a/internal/update/auto.go +++ b/internal/update/auto.go @@ -30,6 +30,11 @@ const ( maxAutoFailures = 3 autoRetryBase = 6 * time.Hour autoRetryCap = 72 * time.Hour + // slowInstallQuiet is how long a release that did not arrive inside the + // launch's own clock is left to the chat's background road. Without it a + // slow connection would pay that whole clock on every launch for the same + // release, and the chat installs it regardless. + slowInstallQuiet = 24 * time.Hour autoStateName = "update-state.json" ) @@ -44,6 +49,24 @@ type AutoState struct { Failures int `json:"failures,omitempty"` LastAttemptAt time.Time `json:"last_attempt_at,omitempty"` LastError string `json:"last_error,omitempty"` + // SlowRelease and SlowAt name the release a launch could not download + // inside its own clock, and when. It is not a failure — nothing went wrong + // but the connection's speed — so it never counts toward + // [maxAutoFailures]; it only keeps the next launches from waiting on the + // same download again ([AutoState.SlowRecently]). + SlowRelease string `json:"slow_release,omitempty"` + SlowAt time.Time `json:"slow_at,omitempty"` +} + +// SlowRecently reports whether a launch ran out of time downloading this exact +// release within [slowInstallQuiet]. A later release is a new question. +func (s AutoState) SlowRecently(tag string, now time.Time) bool { + tag = strings.TrimSpace(tag) + if tag == "" || tag != strings.TrimSpace(s.SlowRelease) || s.SlowAt.IsZero() { + return false + } + age := now.Sub(s.SlowAt) + return age >= 0 && age < slowInstallQuiet } // Dismissed reports whether this exact release was answered "not now". @@ -305,6 +328,20 @@ func AutoFailure(profileDir, tag string, err error) AutoState { return state } +// AutoSlow records that a launch ran out of its own clock downloading a release. +// Like [AutoFailure] it never claims a write it did not make: a write that did +// not land costs at most one more wait on the next launch. +func AutoSlow(profileDir, tag string) { + tag = strings.TrimSpace(tag) + if tag == "" { + return + } + _, _ = mutateAutoState(profileDir, func(state *AutoState) { + state.SlowRelease = tag + state.SlowAt = time.Now() + }) +} + // AutoSuccess clears the failure belonging to a release now installed. // // IT DOES NOT CLEAR A DISMISSAL. The person's "not now" for another tag is a @@ -318,6 +355,8 @@ func AutoSuccess(profileDir string) AutoState { state.Failures = 0 state.LastAttemptAt = time.Time{} state.LastError = "" + state.SlowRelease = "" + state.SlowAt = time.Time{} }) if saveErr != nil { return LoadAutoState(profileDir) @@ -361,6 +400,13 @@ type installRecord struct { SHA256 string `json:"sha256,omitempty"` PublishedAt time.Time `json:"published_at,omitempty"` InstalledAt time.Time `json:"installed_at,omitempty"` + // Pinned says a PERSON put this exact release here by name — `/update + // ` or `codeaf update --version ` — so the automatic road leaves + // the file alone until a person moves it again. It lives in the record and + // not in a profile because it is a fact about this FILE: two profiles share + // one executable, and a file replaced by curl or a package manager no longer + // hashes to the record, which drops the pin with it. + Pinned bool `json:"pinned,omitempty"` } // canonicalTarget resolves a target to the ABSOLUTE, LINK-FREE path whose file @@ -464,13 +510,24 @@ func (l *InstallLock) Record() (installRecord, bool) { // RecordInstalled writes what this target now holds. The caller holds the lock // and has just written those bytes. func (l *InstallLock) RecordInstalled(tag, digest string, published time.Time) { + l.recordInstalled(tag, digest, published, false) +} + +// recordInstalled is [InstallLock.RecordInstalled] with the pin said out loud: +// the installer pins exactly the release a person named and unpins on every +// other install a person asked for. +func (l *InstallLock) recordInstalled(tag, digest string, published time.Time, pinned bool) { + l.writeRecord(installRecord{ + Tag: strings.TrimSpace(tag), SHA256: strings.ToLower(strings.TrimSpace(digest)), + PublishedAt: published.UTC(), InstalledAt: time.Now().UTC(), Pinned: pinned, + }) +} + +// writeRecord replaces the record through the held handle. +func (l *InstallLock) writeRecord(record installRecord) { if l == nil || l.file == nil { return } - record := installRecord{ - Tag: strings.TrimSpace(tag), SHA256: strings.ToLower(strings.TrimSpace(digest)), - PublishedAt: published.UTC(), InstalledAt: time.Now().UTC(), - } raw, err := json.Marshal(record) if err != nil { return @@ -487,6 +544,42 @@ func (l *InstallLock) RecordInstalled(tag, digest string, published time.Time) { _ = l.file.Sync() } +// PinnedRelease names the release a person pinned this executable at, or "" +// when nothing is pinned. A pin is believed exactly as the rest of the record +// is — only while the file still hashes to it — so a codeaf replaced by curl or +// a package manager has no pin left to honour. +// +// IT READS WITHOUT TAKING THE LOCK, because a launch asks it on every start and +// must neither create the lock file beside an executable that was never +// updated nor wait on an install another window is running. A record that +// cannot be read reads as no pin; the installer asks again UNDER the lock +// before the automatic road writes anything ([InstallOptions.Automatic]), so a +// pin written a moment later is still honoured. +func PinnedRelease(target string) string { + target = canonicalTarget(target) + if target == "" { + return "" + } + file, err := os.Open(installLockPath(target)) + if err != nil { + return "" + } + raw, err := io.ReadAll(io.LimitReader(file, maxInstallRecordBytes)) + _ = file.Close() + if err != nil { + return "" + } + var record installRecord + if json.Unmarshal(raw, &record) != nil || !record.Pinned || record.Tag == "" || record.SHA256 == "" { + return "" + } + actual, err := sha256File(target) + if err != nil || !strings.EqualFold(actual, record.SHA256) { + return "" + } + return record.Tag +} + // Release drops the lock. It is safe on a nil lock and safe to call twice. func (l *InstallLock) Release() { if l == nil || l.file == nil { @@ -608,6 +701,8 @@ func PackageManaged(target string) string { } prefixes := []struct{ prefix, manager string }{ {"/opt/homebrew/", "Homebrew"}, + // Homebrew on Linux keeps its prefix under its own account. + {"/home/linuxbrew/.linuxbrew/", "Homebrew"}, {"/usr/local/Cellar/", "Homebrew"}, {"/nix/store/", "Nix"}, {"/usr/bin/", "the system package manager"}, diff --git a/internal/update/auto_test.go b/internal/update/auto_test.go index 7c828b7ae4..7474acbde4 100644 --- a/internal/update/auto_test.go +++ b/internal/update/auto_test.go @@ -197,6 +197,7 @@ func TestRecordSupersedesUsesPublishedOrdering(t *testing.T) { func TestPackageManagedNamesTheOwner(t *testing.T) { for _, row := range []struct{ path, want string }{ {"/opt/homebrew/bin/codeaf", "Homebrew"}, + {"/home/linuxbrew/.linuxbrew/Cellar/codeaf/0.8.0/bin/codeaf", "Homebrew"}, {"/nix/store/abc-codeaf/bin/codeaf", "Nix"}, {"/usr/bin/codeaf", "the system package manager"}, {"/home/somebody/.local/bin/codeaf", ""}, diff --git a/internal/update/autolaunch_test.go b/internal/update/autolaunch_test.go new file mode 100644 index 0000000000..8589dadcc0 --- /dev/null +++ b/internal/update/autolaunch_test.go @@ -0,0 +1,388 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +// TestOnlyAStableCodeafMovingToANewerStableIsUpdatedUnasked is the rule that +// keeps the automatic road off every build somebody working ON codeaf runs: +// source builds, devaf and stageaf, a dev build installed as codeaf, a release +// candidate, and any other file name. Each is told what is out and never +// replaced unasked. +func TestOnlyAStableCodeafMovingToANewerStableIsUpdatedUnasked(t *testing.T) { + for _, row := range []struct { + name, running, latest, executable string + want bool + }{ + {"a released codeaf behind the newest stable", "v0.7.0", "v0.7.1", "/home/p/.codeaf/bin/codeaf", true}, + {"a released codeaf on Windows", "v0.7.0", "v0.8.0", "/opt/tools/codeaf.exe", true}, + {"already the newest", "v0.7.1", "v0.7.1", "/home/p/.codeaf/bin/codeaf", false}, + {"ahead of the newest", "v0.8.0", "v0.7.1", "/home/p/.codeaf/bin/codeaf", false}, + {"a source build from make build", "a65a9eed", "v0.7.1", "/src/codeaf/bin/codeaf", false}, + {"an unstamped build", "", "v0.7.1", "/src/codeaf/bin/codeaf", false}, + {"a release candidate", "v0.7.1-rc.2", "v0.7.1", "/home/p/.codeaf/bin/codeaf", false}, + {"a dev build installed as codeaf", "dev-20261001-0123456789ab", "dev-20261013-0123456789ac", "/home/p/.codeaf/bin/codeaf", false}, + {"devaf", "dev-20261001-0123456789ab", "dev-20261013-0123456789ac", "/home/p/.codeaf/bin/devaf", false}, + {"stageaf", "staging-20261001-0123456789ab", "staging-20261009-0123456789ac", "/home/p/.codeaf/bin/stageaf", false}, + {"a stable release under another name", "v0.7.0", "v0.7.1", "/home/p/.codeaf/bin/oldaf", false}, + {"a stable release offered a prerelease", "v0.7.0", "v0.8.0-rc.1", "/home/p/.codeaf/bin/codeaf", false}, + {"no executable to name", "v0.7.0", "v0.7.1", "", false}, + } { + t.Run(row.name, func(t *testing.T) { + if got := AutomaticFor(row.running, row.latest, row.executable); got != row.want { + t.Fatalf("AutomaticFor(%q, %q, %q) = %t, want %t", row.running, row.latest, row.executable, got, row.want) + } + }) + } + + // THE NAME IS THE FILE THAT WOULD BE OVERWRITTEN, through every link, so + // one install answers the same on every platform whatever os.Executable + // happens to resolve there. + dir := t.TempDir() + write := func(name string) string { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("bytes"), 0o755); err != nil { + t.Fatal(err) + } + return path + } + link := func(name, target string) string { + path := filepath.Join(dir, name) + if err := os.Symlink(target, path); err != nil { + t.Skipf("this filesystem has no symlinks: %v", err) + } + return path + } + versioned := write("tools/codeaf-v0.7.0") + installed := write("home/.codeaf/bin/codeaf") + for _, row := range []struct { + name, executable string + want bool + }{ + {"a link named codeaf to a file named otherwise", link("codeaf", versioned), false}, + {"a link named devaf to a file named codeaf", link("devaf", installed), true}, + {"a link to a folder holding codeaf", filepath.Join(link("linked-bin", filepath.Dir(installed)), "codeaf"), true}, + } { + t.Run(row.name, func(t *testing.T) { + if got := AutomaticFor("v0.7.0", "v0.7.1", row.executable); got != row.want { + t.Fatalf("AutomaticFor through %q = %t, want %t", row.executable, got, row.want) + } + }) + } +} + +// TestALaunchHearsTheBuildsHalfFromTheCheck proves both launch roads get the +// rule's answer from the check, about the resolved file, and only when a +// release is newer. +func TestALaunchHearsTheBuildsHalfFromTheCheck(t *testing.T) { + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + dir := t.TempDir() + file := filepath.Join(dir, "codeaf") + if err := os.WriteFile(file, []byte("bytes"), 0o755); err != nil { + t.Fatal(err) + } + devaf := filepath.Join(dir, "devaf") + if err := os.Symlink(file, devaf); err != nil { + t.Skipf("this filesystem has no symlinks: %v", err) + } + for _, row := range []struct { + name, running, executable string + want bool + }{ + {"a stable codeaf behind", "v0.7.0", file, true}, + {"devaf linked to that codeaf", "v0.7.0", devaf, true}, + {"a release candidate", "v0.7.1-rc.1", file, false}, + } { + t.Run(row.name, func(t *testing.T) { + profile := t.TempDir() + if err := saveCheckCache(filepath.Join(profile, "update-check.json"), checkCache{CheckedAt: now.Add(-time.Hour), Latest: "v0.7.1", Running: row.running}); err != nil { + t.Fatal(err) + } + answer, newer := CheckLaunch(context.Background(), CheckOptions{ + Running: row.running, ProfileDir: profile, Executable: row.executable, Now: func() time.Time { return now }, + }) + if !newer || answer.Automatic != row.want { + t.Fatalf("answer = %+v, newer %t; want Automatic %t", answer, newer, row.want) + } + }) + } +} + +// TestASlowLaunchIsRememberedForThatReleaseOnly proves the slow-download memory +// answers one release, for a day, and that a success clears it. +func TestASlowLaunchIsRememberedForThatReleaseOnly(t *testing.T) { + profile := t.TempDir() + AutoSlow(profile, "v0.7.1") + state := LoadAutoState(profile) + now := time.Now() + if !state.SlowRecently("v0.7.1", now) { + t.Fatalf("the slow release was not remembered: %+v", state) + } + if state.SlowRecently("v0.7.2", now) { + t.Fatal("a later release was treated as the slow one") + } + if state.SlowRecently("v0.7.1", now.Add(slowInstallQuiet+time.Minute)) { + t.Fatal("the slow release was remembered past its quiet period") + } + if state.Failures != 0 { + t.Fatalf("a slow download counted as a failure: %+v", state) + } + AutoSuccess(profile) + if LoadAutoState(profile).SlowRecently("v0.7.1", now) { + t.Fatal("a success left the slow memory behind") + } +} + +// TestANamedTagPinsTheFileAndOnlyAPersonUnpinsIt proves the pin end to end +// through the one installer: a named tag pins, the automatic road is refused +// while it holds, and a person's plain install moves the file and lets it go. +// Naming the release already on disk pins it in place without a download, and +// a plain install of that same release unpins it the same way. +func TestANamedTagPinsTheFileAndOnlyAPersonUnpinsIt(t *testing.T) { + asset := []byte("codeaf release bytes") + digest := sha256.Sum256(asset) + server, requests := servedRelease(t, asset, hex.EncodeToString(digest[:])) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + install := func(tag string, named, automatic bool) (InstallResult, error) { + return Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.7.1"), Release: Release{Tag: tag, Repository: primaryRepository}, + Target: target, Curl: CurlCommand, AllowDowngrade: named, Automatic: automatic, + }) + } + + pinned, err := install("v0.6.3", true, false) + if err != nil || !pinned.Pinned { + t.Fatalf("named install = %+v, %v; want it pinned", pinned, err) + } + if got := PinnedRelease(target); got != "v0.6.3" { + t.Fatalf("PinnedRelease = %q, want v0.6.3", got) + } + _, err = install("v0.7.1", false, true) + var refusal *RefusalError + if !errors.As(err, &refusal) || !strings.Contains(refusal.Reason, "codeaf v0.6.3 is pinned") { + t.Fatalf("automatic install over a pin = %v, want the pinned refusal", err) + } + if PinnedRelease(target) != "v0.6.3" { + t.Fatal("a refused automatic install disturbed the pin") + } + + moved, err := install("v0.7.1", false, false) + if err != nil || moved.Already || moved.Pinned { + t.Fatalf("plain install = %+v, %v; want a move that unpins", moved, err) + } + if got := PinnedRelease(target); got != "" { + t.Fatalf("PinnedRelease after a plain install = %q, want nothing", got) + } + + before := requests.Load() + inPlace, err := install("v0.7.1", true, false) + if err != nil || !inPlace.Already || !inPlace.Pinned || PinnedRelease(target) != "v0.7.1" { + t.Fatalf("naming the release on disk = %+v, %v, pin %q; want it pinned in place", inPlace, err, PinnedRelease(target)) + } + released, err := install("v0.7.1", false, false) + if err != nil || !released.Already || released.Pinned || PinnedRelease(target) != "" { + t.Fatalf("a plain install of the release on disk = %+v, %v, pin %q; want it unpinned", released, err, PinnedRelease(target)) + } + if requests.Load() != before { + t.Fatalf("pinning in place downloaded %d times", requests.Load()-before) + } + // The automatic road never writes a pin, even when it finds the release + // already there. + if quiet, err := install("v0.7.1", true, true); err != nil || quiet.Pinned || PinnedRelease(target) != "" { + t.Fatalf("an automatic install of the release on disk = %+v, %v, pin %q", quiet, err, PinnedRelease(target)) + } +} + +// TestAPinDiesWithTheBytesItWasWrittenFor proves a codeaf replaced by curl or a +// package manager is not held at a release it no longer is. +func TestAPinDiesWithTheBytesItWasWrittenFor(t *testing.T) { + asset := []byte("pinned bytes") + digest := sha256.Sum256(asset) + server, _ := servedRelease(t, asset, hex.EncodeToString(digest[:])) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.7.1"), Release: Release{Tag: "v0.6.3", Repository: primaryRepository}, + Target: target, AllowDowngrade: true, + }); err != nil { + t.Fatal(err) + } + if PinnedRelease(target) != "v0.6.3" { + t.Fatal("the named install did not pin") + } + if err := os.WriteFile(target, []byte("installed by curl"), 0o755); err != nil { + t.Fatal(err) + } + if got := PinnedRelease(target); got != "" { + t.Fatalf("a replaced file is still pinned at %q", got) + } + if got := PinnedRelease(filepath.Join(t.TempDir(), "codeaf")); got != "" { + t.Fatalf("a file never installed is pinned at %q", got) + } +} + +// TestALaunchHearsThePinWithTheNewerRelease proves the check hands both launch +// roads the pin they need to decide with, and only when something is newer. +func TestALaunchHearsThePinWithTheNewerRelease(t *testing.T) { + asset := []byte("pinned bytes") + digest := sha256.Sum256(asset) + server, _ := servedRelease(t, asset, hex.EncodeToString(digest[:])) + defer server.Close() + target := filepath.Join(t.TempDir(), "codeaf") + if _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.6.3"), Release: Release{Tag: "v0.6.3", Repository: primaryRepository}, + Target: target, AllowDowngrade: true, + }); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + profile := t.TempDir() + if err := saveCheckCache(filepath.Join(profile, "update-check.json"), checkCache{CheckedAt: now.Add(-time.Hour), Latest: "v0.7.1", Running: "v0.6.3"}); err != nil { + t.Fatal(err) + } + answer, newer := CheckLaunch(context.Background(), CheckOptions{ + Running: "v0.6.3", ProfileDir: profile, Executable: target, Now: func() time.Time { return now }, + }) + if !newer || answer.Pinned != "v0.6.3" { + t.Fatalf("launch answer = %+v, %t; want v0.7.1 with the pin", answer, newer) + } +} + +// TestALaunchThatGotNoAnswerDoesNotWaitAgainForAnHour proves the prelaunch look +// never costs a person the same wait twice in a row: a request that got no +// answer is written down, the next launch inside the hour makes none, the one +// after the hour asks again, and an answer clears it. The chat's own look +// behind a drawn frame is never held back by it. +func TestALaunchThatGotNoAnswerDoesNotWaitAgainForAnHour(t *testing.T) { + var hits atomic.Int64 + var answer atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + if !answer.Load() { + http.Error(w, "unreachable", http.StatusBadGateway) + return + } + fmt.Fprint(w, `{"tag_name":"v0.7.1"}`) + })) + defer server.Close() + client := releaseClient(server, "v0.7.0") + profile := t.TempDir() + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + look := func(prelaunch bool) (Available, bool) { + return CheckLaunch(context.Background(), CheckOptions{ + Running: "v0.7.0", ProfileDir: profile, Client: client, Prelaunch: prelaunch, + Now: func() time.Time { return now }, + }) + } + if _, newer := look(true); newer || hits.Load() != 1 { + t.Fatalf("first launch: newer %t, hits %d", newer, hits.Load()) + } + if _, err := os.Stat(filepath.Join(profile, prelaunchMissName)); err != nil { + t.Fatalf("the miss was not written down: %v", err) + } + now = now.Add(59 * time.Minute) + if _, newer := look(true); newer || hits.Load() != 1 { + t.Fatalf("a launch inside the hour asked again: newer %t, hits %d", newer, hits.Load()) + } + look(false) + if hits.Load() != 2 { + t.Fatalf("the chat's own look was held back by the miss: hits %d", hits.Load()) + } + now = now.Add(2 * time.Minute) + answer.Store(true) + if available, newer := look(true); !newer || available.Latest != "v0.7.1" || hits.Load() != 3 { + t.Fatalf("a launch after the hour = %+v, %t, hits %d", available, newer, hits.Load()) + } + if _, err := os.Stat(filepath.Join(profile, prelaunchMissName)); !os.IsNotExist(err) { + t.Fatalf("an answer did not clear the miss: %v", err) + } +} + +// TestAStoppedLookIsNotANetworkThatFailed proves ctrl+c during the look writes +// nothing down, so the next launch still asks. +func TestAStoppedLookIsNotANetworkThatFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + profile := t.TempDir() + stopped, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + CheckLaunch(stopped, CheckOptions{Running: "v0.7.0", ProfileDir: profile, Client: releaseClient(server, "v0.7.0"), Prelaunch: true}) + if _, err := os.Stat(filepath.Join(profile, prelaunchMissName)); !os.IsNotExist(err) { + t.Fatalf("a stopped look was written down as a miss: %v", err) + } +} + +// TestAnInstallReportsTheReleaseArriving proves the progress hook hears the +// asset with the size the server announced — and -1, never a guess, when the +// server announced none. +func TestAnInstallReportsTheReleaseArriving(t *testing.T) { + asset := []byte(strings.Repeat("codeaf", 40_000)) + digest := sha256.Sum256(asset) + checksum := hex.EncodeToString(digest[:]) + for _, row := range []struct { + name string + announce bool + want int64 + }{{"announced", true, int64(len(asset))}, {"chunked", false, -1}} { + t.Run(row.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/checksums.txt"): + fmt.Fprintf(w, "%s %s\n", checksum, assetName()) + default: + if row.announce { + w.Header().Set("Content-Length", fmt.Sprint(len(asset))) + } + _, _ = w.Write(asset) + } + })) + defer server.Close() + var last, total int64 + calls := 0 + _, err := Install(context.Background(), InstallOptions{ + Client: releaseClient(server, "v0.7.0"), Release: Release{Tag: "v0.7.1", Repository: primaryRepository}, + Target: filepath.Join(t.TempDir(), "codeaf"), + Progress: func(received, announced int64) { + calls++ + last, total = received, announced + }, + }) + if err != nil { + t.Fatal(err) + } + if calls == 0 || last != int64(len(asset)) || total != row.want { + t.Fatalf("progress heard %d calls ending at %d of %d, want %d of %d", calls, last, total, len(asset), row.want) + } + }) + } +} + +// assetName is the release asset this machine downloads. +func assetName() string { + return "codeaf-" + runtime.GOOS + "-" + runtime.GOARCH +} diff --git a/internal/update/check.go b/internal/update/check.go index 3fa701c902..573198a41f 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -3,6 +3,7 @@ package update import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -15,6 +16,19 @@ import ( const ( cacheLifetime = 24 * time.Hour channelCacheLifetime = time.Hour + + // PrelaunchLookup is the longest a launch waits on the release API BEFORE + // the chat opens, while a person is looking at an empty terminal. It is half + // the in-chat check's budget ([CheckTimeout]) because that check runs behind + // a drawn frame and this one runs in front of it. A cached answer costs no + // wait at all, so this is paid at most once a day on a stable build. + PrelaunchLookup = 1500 * time.Millisecond + // prelaunchMissQuiet is how long a launch that got no answer stops waiting + // for one. A laptop on a network that drops the request silently would + // otherwise pay [PrelaunchLookup] on every single launch; the chat's own + // check behind the first frame still runs and still refreshes the cache. + prelaunchMissQuiet = time.Hour + prelaunchMissName = "update-check.miss.json" ) // Available is the release comparison shown at launch or by --check. @@ -24,6 +38,16 @@ type Available struct { LatestPublished time.Time RunningPublished time.Time Curl string + // Pinned names the release a person pinned this executable at + // ([PinnedRelease]), or "". It is read only when a newer release is out, + // because that is the only time a pin changes what a launch does. + Pinned string + // Automatic is [AutomaticFor] asked about the release and the executable + // the check was given: whether this build and this file are ever installed + // without anybody asking. It is decided here, beside the request and off + // any frame, because it resolves the file's links; both launch roads read + // it rather than asking again. + Automatic bool } // Newer reports whether the selected release supersedes this build. @@ -83,6 +107,17 @@ type CheckOptions struct { Now func() time.Time Disabled bool Executable string + // Prelaunch marks the look a launch takes BEFORE the chat opens, with a + // person waiting on it. After a request that got no answer it makes none for + // [prelaunchMissQuiet] and answers from the cache alone, and it writes that + // miss down when its own request gets none. The caller bounds the wait with + // its context ([PrelaunchLookup]). + Prelaunch bool +} + +// prelaunchMiss is the one fact the miss file holds. +type prelaunchMiss struct { + MissedAt time.Time `json:"missed_at"` } // CheckLaunch silently checks the release channel this build follows. Every @@ -124,40 +159,77 @@ func CheckLaunch(ctx context.Context, options CheckOptions) (Available, bool) { // channels order by the tag itself, so a missing moment costs them nothing // and they keep their cache untouched. needsMoment := (channel == "dev" || channel == "staging") && cached.LatestPublished.IsZero() - if cachedForRunning && !needsMoment { - answer := Available{ + // Every answer leaves through here, so a newer release always arrives with + // the pin that decides what a launch may do about it. + finish := func(answer Available) (Available, bool) { + newer := answer.Newer() + if newer { + answer.Pinned = PinnedRelease(options.Executable) + answer.Automatic = AutomaticFor(answer.Running, answer.Latest, options.Executable) + } + return answer, newer + } + fromCache := func() (Available, bool) { + if !cachedForRunning { + return Available{}, false + } + return finish(Available{ Latest: cached.Latest, Running: running, Curl: curl, LatestPublished: cached.LatestPublished, RunningPublished: cached.RunningPublished, - } - return answer, answer.Newer() + }) + } + if cachedForRunning && !needsMoment { + return fromCache() } if options.Client == nil { return Available{}, false } + // A LAUNCH THAT GOT NO ANSWER AN HOUR AGO DOES NOT WAIT AGAIN. The cache + // still answers if it can; only the request is skipped. + missPath := config.ProfilePath(options.ProfileDir, prelaunchMissName) + if options.Prelaunch && missedRecently(missPath, now()) { + return fromCache() + } release, err := options.Client.Check(ctx, Choice{Channel: channel, Running: running}) if err != nil { + // A PERSON WHO PRESSED CTRL+C IS NOT A NETWORK THAT FAILED, so only a + // request that ran out of time or was refused is written down. + if options.Prelaunch && !errors.Is(ctx.Err(), context.Canceled) { + _ = writeProfileJSON(missPath, ".update-check-miss-*.json", prelaunchMiss{MissedAt: now()}) + } // THE REFRESH IS BEST-EFFORT. A release the API cannot answer for must // not turn a known offer into no offer at all: a usable cache is still // the answer, moment or no moment, and the installer resolves the // moment it needs when it writes the record. - if cachedForRunning { - answer := Available{ - Latest: cached.Latest, Running: running, Curl: curl, - LatestPublished: cached.LatestPublished, RunningPublished: cached.RunningPublished, - } - return answer, answer.Newer() - } - return Available{}, false + return fromCache() } - answer := Available{ - Latest: release.Tag, Running: running, Curl: curl, - LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + if options.Prelaunch { + _ = os.Remove(missPath) } _ = saveCheckCache(path, checkCache{ CheckedAt: now(), Latest: release.Tag, Running: running, LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, }) - return answer, answer.Newer() + return finish(Available{ + Latest: release.Tag, Running: running, Curl: curl, + LatestPublished: release.PublishedAt, RunningPublished: release.RunningPublishedAt, + }) +} + +// missedRecently reports whether a launch's request got no answer within the +// last [prelaunchMissQuiet]. A missing, unreadable or future-dated file is no +// miss, so a broken file can only ever cost one more wait. +func missedRecently(path string, now time.Time) bool { + raw, err := os.ReadFile(path) + if err != nil { + return false + } + var miss prelaunchMiss + if json.Unmarshal(raw, &miss) != nil || miss.MissedAt.IsZero() { + return false + } + age := now.Sub(miss.MissedAt) + return age >= 0 && age < prelaunchMissQuiet } func loadCheckCache(path string) (checkCache, bool) { @@ -176,17 +248,23 @@ func loadCheckCache(path string) (checkCache, bool) { } func saveCheckCache(path string, cached checkCache) error { + return writeProfileJSON(path, ".update-check-*.json", cached) +} + +// writeProfileJSON lands one small JSON file beside config.json with a +// temporary file and a rename, so a reader never sees half of it. +func writeProfileJSON(path, pattern string, value any) error { if strings.TrimSpace(path) == "" { return nil } - raw, err := json.Marshal(cached) + raw, err := json.Marshal(value) 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") + temporary, err := os.CreateTemp(filepath.Dir(path), pattern) if err != nil { return err } diff --git a/internal/update/client.go b/internal/update/client.go index a3dc167d8a..8f5d74af20 100644 --- a/internal/update/client.go +++ b/internal/update/client.go @@ -43,13 +43,7 @@ func CurlLine(executable, channel string) string { default: channel = "stable" } - name := filepath.Base(strings.TrimSpace(executable)) - if len(name) >= 4 && strings.EqualFold(name[len(name)-4:], ".exe") { - name = name[:len(name)-4] - } - if !installNamePattern.MatchString(name) { - name = "codeaf" - } + name := InstallName(executable) if name == "devaf" { return "curl -fsSL https://agentfield.ai/get/devaf | bash" } @@ -67,6 +61,23 @@ func CurlLine(executable, channel string) string { return line + "bash" } +// InstallName is the file name this executable was installed under, without a +// Windows `.exe`: `codeaf`, `devaf`, `stageaf` or whatever `--name` chose. A +// path that names no usable file reads as `codeaf`, the name every install road +// falls back to. The curl line and the automatic updater's own rule +// ([AutomaticFor]) both ask here, so they can never disagree about which file a +// launch is. +func InstallName(executable string) string { + name := filepath.Base(strings.TrimSpace(executable)) + if len(name) >= 4 && strings.EqualFold(name[len(name)-4:], ".exe") { + name = name[:len(name)-4] + } + if !installNamePattern.MatchString(name) { + return "codeaf" + } + return name +} + const ( // CheckTimeout is the whole-exchange budget for a launch check and for // `codeaf update --check`. THE CHECK CLOCK NEVER COVERS AN INSTALL: a diff --git a/internal/update/install.go b/internal/update/install.go index 6c6a49694c..c692b1f8e5 100644 --- a/internal/update/install.go +++ b/internal/update/install.go @@ -27,7 +27,19 @@ type InstallOptions struct { // ` or `update --version ` — and a rollback is what they asked // for. An automatic or channel-following update never sets it, so a stale // caller cannot silently downgrade a file another window advanced. + // + // IT IS ALSO THE PIN. A release a person named stays where they put it: the + // record marks it pinned, and the automatic road refuses to move it until a + // person installs again without naming a tag, which unpins. AllowDowngrade bool + // Automatic says nobody asked for this install at all: the launch's own road + // or the chat's countdown. It is refused while a person's pin holds the file, + // and it never writes or clears a pin. + Automatic bool + // Progress, when set, hears the release asset arriving: the bytes so far and + // the size the server announced, or -1 when it announced none. It is called + // between reads on the installing goroutine, so it must return quickly. + Progress func(received, total int64) } // InstallResult is the release and executable path that are ready to restart. @@ -37,6 +49,8 @@ type InstallResult struct { // Already is true when the target was already the requested release: a // successful no-op, not a failure, and not something to alarm anyone about. Already bool + // Pinned is true when the file is now pinned at Release by a person. + Pinned bool } // Install downloads, checks, and atomically replaces one executable. @@ -95,10 +109,26 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) // while the file still hashes to it, so an externally replaced binary reads // as unknown and a candidate newer than it is not refused on a stale fact. record, recorded := lock.Record() + // A PERSON'S INSTALL SETS THE PIN EITHER WAY: naming a tag pins it, and + // anything else a person asks for unpins. The automatic road never changes + // it, because nobody said anything. + pin := options.AllowDowngrade && !options.Automatic if recorded && record.Tag == release.Tag { - // ALREADY THERE IS SUCCESS. Nothing is downloaded, nothing is written, - // and the caller is told so it can stay quiet about it. - return InstallResult{Release: release, Path: target, Already: true}, nil + // ALREADY THERE IS SUCCESS. Nothing is downloaded and the caller is told + // so it can stay quiet about it — but a person naming the release that + // is already on disk has just pinned it there, and a person updating + // without naming one has just let it go, so the record learns which. + if !options.Automatic && record.Pinned != pin { + record.Pinned = pin + lock.writeRecord(record) + } + return InstallResult{Release: release, Path: target, Already: true, Pinned: record.Pinned}, nil + } + if recorded && record.Pinned && options.Automatic { + // THE PIN IS ASKED AGAIN UNDER THE LOCK. A launch reads it without the + // lock ([PinnedRelease]), so a person who pinned in another window a + // moment ago is still the one who decides. + return InstallResult{}, &RefusalError{Reason: "codeaf " + record.Tag + " is pinned on this machine · /update or codeaf update installs " + release.Tag + " and unpins it"} } // ONE BEST-EFFORT MOMENT FOR THE RELEASE BEING INSTALLED, AND ONLY WHERE IT // IS THE FACT THAT ORDERS: a NAMED dev or staging tag is resolved without @@ -146,7 +176,7 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) // reads only the constant): a 404 there is an answer, never a reason to go // and ask somewhere else. The result records it. release.Repository = primaryRepository - body, asset, checksums, err := options.Client.downloadRelease(ctx, release, asset, goos, goarch, extension) + body, asset, checksums, err := options.Client.downloadRelease(ctx, release, asset, goos, goarch, extension, options.Progress) if err != nil { return InstallResult{}, err } @@ -182,8 +212,8 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) } // The record names the bytes just written, so the next caller can tell what // is on disk from what it believes is on disk. - lock.RecordInstalled(release.Tag, actual, release.PublishedAt) - return InstallResult{Release: release, Path: target}, nil + lock.recordInstalled(release.Tag, actual, release.PublishedAt, pin) + return InstallResult{Release: release, Path: target, Pinned: pin}, nil } // downloadRelease fetches the asset and its checksums from one release. The @@ -191,12 +221,12 @@ func Install(ctx context.Context, options InstallOptions) (InstallResult, error) // published before the rename (v0.1.0): a person may still name that tag to roll // back to it, and checksums.txt beside it is what vouches for whichever name // answered. -func (c *Client) downloadRelease(ctx context.Context, release Release, asset, goos, goarch, extension string) ([]byte, string, []byte, error) { +func (c *Client) downloadRelease(ctx context.Context, release Release, asset, goos, goarch, extension string, progress func(received, total int64)) ([]byte, string, []byte, error) { shownAsset := asset - body, err := c.downloadAsset(ctx, c.assetURL(release, asset), shownAsset) + body, err := c.downloadAsset(ctx, c.assetURL(release, asset), shownAsset, progress) if isStatus(err, http.StatusNotFound) { legacyAsset := "aforge-" + goos + "-" + goarch + extension // legacy-name - body, err = c.downloadAsset(ctx, c.assetURL(release, legacyAsset), shownAsset) + body, err = c.downloadAsset(ctx, c.assetURL(release, legacyAsset), shownAsset, progress) if err == nil { asset = legacyAsset } @@ -216,7 +246,7 @@ type downloadRead struct { err error } -func (c *Client) downloadAsset(ctx context.Context, rawURL, asset string) ([]byte, error) { +func (c *Client) downloadAsset(ctx context.Context, rawURL, asset string, progress func(received, total int64)) ([]byte, error) { ceiling := c.downloadCeiling() downloadContext, cancel := context.WithTimeout(ctx, ceiling) defer cancel() @@ -244,7 +274,19 @@ func (c *Client) downloadAsset(ctx context.Context, rawURL, asset string) ([]byt if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, &statusError{code: response.StatusCode, resource: asset} } - return readDownloadBody(ctx, downloadContext, response.Body, asset, c.stallWindow(), ceiling) + return readDownloadBody(ctx, downloadContext, response.Body, asset, c.stallWindow(), ceiling, progressOf(progress, response.ContentLength)) +} + +// progressOf binds the announced size to a caller's progress hook, so the +// reader below reports one number and never has to know what the server said. +func progressOf(progress func(received, total int64), total int64) func(int64) { + if progress == nil { + return nil + } + if total <= 0 { + total = -1 + } + return func(received int64) { progress(received, total) } } func responseHeaderTimedOut(err error) bool { @@ -254,7 +296,7 @@ func responseHeaderTimedOut(err error) bool { // readDownloadBody watches successful reads rather than wall time. A slow link // that keeps delivering bytes is healthy; one silent read must not hold the // installer forever even though the HTTP client has no whole-body timeout. -func readDownloadBody(parent, download context.Context, body io.Reader, asset string, stall, ceiling time.Duration) ([]byte, error) { +func readDownloadBody(parent, download context.Context, body io.Reader, asset string, stall, ceiling time.Duration, progress func(int64)) ([]byte, error) { reads := make(chan downloadRead, 1) done := make(chan struct{}) defer close(done) @@ -292,6 +334,9 @@ func readDownloadBody(parent, download context.Context, body io.Reader, asset st case read := <-reads: if len(read.bytes) > 0 { downloaded = append(downloaded, read.bytes...) + if progress != nil { + progress(int64(len(downloaded))) + } if !timer.Stop() { select { case <-timer.C: diff --git a/internal/update/policy.go b/internal/update/policy.go new file mode 100644 index 0000000000..8c78e65c5c --- /dev/null +++ b/internal/update/policy.go @@ -0,0 +1,56 @@ +package update + +import "strings" + +// ── WHO IS UPDATED WITHOUT BEING ASKED ────────────────────────────────────── +// +// The automatic road replaces the file a person runs and restarts them onto the +// new one, so it is for exactly one kind of launch: somebody running a released +// codeaf the way the install line put it there. Everybody who works ON codeaf +// runs something else, and the build already says which: +// +// - `make build`, `go build`, `go run` and `go install` stamp a commit, not a +// release tag (internal/buildinfo), so their channel is "other" and the +// launch check never even makes its request. +// - `devaf` and `stageaf` are dev and staging builds under their own file +// names, and a dev build installed as `codeaf` still carries a `dev-*` tag. +// - A release candidate is somebody trying the next stable early. +// +// So the rule reads the two tags and the file name and NOTHING ELSE — no guess +// about whose machine this is, no repository sniffing, no environment flag a +// developer has to remember to export. Every launch it refuses still hears that +// a release is out; it just is not installed until somebody types `/update` or +// `codeaf update`. + +// autoInstallName is the one file name the automatic road replaces. +const autoInstallName = "codeaf" + +// AutomaticFor reports whether a launch of the build `running`, from the file +// `executable`, may install `latest` on its own. ALL THREE must hold: this +// build is a stable release, `latest` is a newer stable release, and the file +// is installed as `codeaf`. Dev, staging and release-candidate builds, source +// builds, and any file under another name (`devaf`, `stageaf`, a `--name` +// install) are told and never replaced unasked. +// +// THE NAME IS THE NAME OF THE FILE THAT WOULD BE OVERWRITTEN — the path with +// every link resolved, exactly as the installer resolves it — and never the +// spelling a launch was started by. os.Executable answers through links on +// Linux and not on macOS, so asking it directly would let one symlinked +// install update itself on one platform and only be told on the other. +// +// It is the build's half of the decision. The person's half — the `update.auto` +// row, a pinned version, a release they skipped — is asked separately by each +// launch road, because only the road knows whether anybody is there to ask. It +// resolves a path, so a surface asks it off the frame ([CheckLaunch] carries +// the answer as [Available.Automatic]). +func AutomaticFor(running, latest, executable string) bool { + running, latest = strings.TrimSpace(running), strings.TrimSpace(latest) + if Kind(running) != "stable" || Kind(latest) != "stable" { + return false + } + file := canonicalTarget(executable) + if file == "" || InstallName(file) != autoInstallName { + return false + } + return Available{Latest: latest, Running: running}.Newer() +} diff --git a/internal/update/restart_unix.go b/internal/update/restart_unix.go index 9f8aa311c6..a6d7a21efb 100644 --- a/internal/update/restart_unix.go +++ b/internal/update/restart_unix.go @@ -12,3 +12,11 @@ func Restart(plan Plan) error { argv := append([]string{plan.Path}, plan.Args...) return syscall.Exec(plan.Path, argv, os.Environ()) } + +// Relaunch hands this terminal to the build a launch just installed, before +// anything has been drawn. Here it is [Restart]: the process is replaced in +// place, so the shell that started codeaf waits on the new build as though it +// had started it. It returns only when the replacement could not start. +func Relaunch(plan Plan) error { + return Restart(plan) +} diff --git a/internal/update/restart_windows.go b/internal/update/restart_windows.go index 0077fc99cc..50b38904bf 100644 --- a/internal/update/restart_windows.go +++ b/internal/update/restart_windows.go @@ -3,8 +3,10 @@ package update import ( + "errors" "os" "os/exec" + "os/signal" ) // Restart starts the replacement process with this process's terminal streams. @@ -15,3 +17,37 @@ func Restart(plan Plan) error { command.Stderr = os.Stderr return command.Start() } + +// Relaunch hands this console to the build a launch just installed, before +// anything has been drawn. WINDOWS HAS NO EXEC, so the new build runs as a +// child on this console and THIS PROCESS WAITS FOR IT AND EXITS WITH ITS +// STATUS: a parent that returned at once would give the prompt back to the +// shell while the chat was still drawing over it. Like an exec it does not +// return once the replacement has started; it returns only when the +// replacement could not start. +// +// THE WAITING PARENT IGNORES CTRL+C. The console delivers CTRL_C_EVENT to +// every process attached to it, and a Go process with no handler exits on it — +// which would hand the prompt back underneath the running chat, the very thing +// waiting exists to prevent. The child decides what ctrl+c means; this process +// only learns how it ended. +func Relaunch(plan Plan) error { + command := exec.Command(plan.Path, plan.Args...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Start(); err != nil { + return err + } + signal.Ignore(os.Interrupt) + err := command.Wait() + var exit *exec.ExitError + if errors.As(err, &exit) { + os.Exit(exit.ExitCode()) + } + if err != nil { + os.Exit(1) + } + os.Exit(0) + return nil +} diff --git a/scripts/update-drive.sh b/scripts/update-drive.sh index 21c2b7ca01..d7bde12846 100755 --- a/scripts/update-drive.sh +++ b/scripts/update-drive.sh @@ -143,7 +143,7 @@ capture_settings() { # THE ROW'S OWN HINT IS THE PROOF THE CURSOR IS ON IT: the sheet draws the # hint only for the row under the cursor, and this row's hint opens with # these words. - if tmux -L "$SOCK" capture-pane -p -t "$session" | grep -q "keeps this codeaf installation current in the background"; then + if tmux -L "$SOCK" capture-pane -p -t "$session" | grep -q "keeps a released codeaf current"; then landed=yes break fi @@ -160,7 +160,7 @@ capture_settings() { # THE ROW ITSELF IS THE CHECK, not the fact that something drew: the registry's # label, the sentence the registry wrote for it, and the value its reader # resolved. A capture without all three is a failure. - grep -q "keeps this codeaf installation current in the background" "$file" || { + grep -q "keeps a released codeaf current" "$file" || { echo "the settings capture at ${cols}c did not draw the row's own hint:" >&2 cat "$file" >&2 exit 1