From 039f8b76e1071263e6dd40657b32aaa284ae94ea Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 13:43:37 +0200 Subject: [PATCH 01/24] feat(bake): prebuild the repo into session images Bake now checks the repo out on the bake instance, runs .pier/setup.sh to completion, scrubs everything the create cargo delivered, and images the result. The bootstrap reuses a checkout it finds, so sessions from the image fetch only the delta and re-run setup warm. AWS launches from baked images request provisioned-rate volume initialization, falling back when the CLI predates the field. Create, claim and fill progress lines carry elapsed time. --- README.md | 40 +++-- cmd/pier/main.go | 12 +- docs/SPEC.md | 27 +++ internal/driver/awsec2/bake.go | 28 ++- internal/driver/awsec2/create.go | 37 +++- internal/driver/awsec2/create_test.go | 80 +++++++++ internal/driver/driver.go | 10 +- internal/driver/gcpgce/bake.go | 33 +++- internal/driver/gcpgce/create.go | 2 +- internal/driver/payload/prebuild.go | 219 +++++++++++++++++++++++ internal/driver/payload/prebuild_test.go | 182 +++++++++++++++++++ internal/driver/payload/scripts.go | 21 ++- internal/pool/fill.go | 32 +--- internal/pool/pool.go | 3 - internal/ui/ui.go | 11 ++ internal/wizard/wizard.go | 8 +- skills/pier-onboard/SKILL.md | 16 +- 17 files changed, 692 insertions(+), 69 deletions(-) create mode 100644 internal/driver/payload/prebuild.go create mode 100644 internal/driver/payload/prebuild_test.go diff --git a/README.md b/README.md index 77a24b5..e5a3791 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Pier uses one committed `.pier/` directory with three optional files: | File | Runs / read | Contains | |---|---|---| -| `.pier/setup.sh` | Every session's first boot, async, in a `setup` tmux window | Repo state: deps, services, migrations, seeds | +| `.pier/setup.sh` | Once at `pier bake` (prebuild), then every session's first boot, async, in a `setup` tmux window | Repo state: deps, services, migrations, seeds (must be safe to re-run) | | `.pier/include` | At create | Ignored files to carry (env files, local certs) | | `.pier/bake.sh` | Once, during `pier bake` | Toolchains beyond the default image (pnpm, python, rust, ...) | @@ -378,18 +378,36 @@ proxy up — running sessions resolve as .pier (http + https), ports mi - One sudo on first run. macOS only for now. - `pier port 3000` is the manual, zero-sudo fallback everywhere. -### `pier bake`: per-repo images +### `pier bake`: per-repo prebuilt images -A stock create installs the harnesses under cloud-init, which takes minutes. -Baking pays that cost once: +Most of a create's wait is the repo's own setup: dependency installs, +container image builds, seeds. A toolchain-only image can't help with any of +that, so a bake prebuilds the repo: ``` -pier bake # one throwaway instance + your .pier/bake.sh, snapshotted as an image +pier bake # one throwaway instance: harnesses + .pier/bake.sh, then your + # checkout (HEAD) with .pier/setup.sh run to completion, imaged ``` -Creates from a baked image drop to a minute or two. Images are keyed to -the repo, so one project's toolchain never bleeds into another's. Re-baking -supersedes the old image, and `pier teardown` sweeps them all by tag. +Sessions launched from the image find the checkout and everything setup +left around it (installed dependencies, virtualenvs, the docker build cache, +built images, volumes) already on disk. The bootstrap fetches only what +changed, moves the checkout to your branch, and `.pier/setup.sh` re-runs +warm: cache hits instead of a cold install. On AWS the restored volume +is also hydrated at a provisioned rate right after launch, a few cents per +create, so the first commands don't stall on lazily-loaded snapshot blocks. + +Before imaging, the bake scrubs everything the create cargo delivered: +harness auth, tokens, the MCP seed, untracked and `.pier/include` files, +containers (their config holds `env_file` values), setup logs. Every create +pushes those fresh, so scrubbing costs no speed. What setup *derives* from +secrets, such as an env value compiled into a build or a seeded database, is +the repo's to keep out. Anyone who can launch the image can read its disk. + +Freshness is manual: re-run `pier bake` when dependencies drift enough that +the warm re-run gets slow. Images are keyed to the repo, so one project's +toolchain never bleeds into another's. Re-baking supersedes the old image, +and `pier teardown` sweeps them all by tag. ### Warm pools: the create is already done @@ -543,8 +561,10 @@ The choices contributors should know before proposing changes - **Guarded cloud-init, identical on stock and baked images.** Every install step is a no-op when the image already has it. Baking is an optimization, never a requirement. -- **Images are toolchains, sessions are state.** `pier bake` installs - tools. Deps, migrations, and env belong to `.pier/setup.sh` at boot. +- **Images are prebuilt, secrets are per-session.** `pier bake` images a + setup-complete checkout so creates skip the repo's cold setup. Anything + pier pushed is scrubbed before imaging and re-pushed per session, and + `.pier/setup.sh` must stay safe to re-run on a warm disk. - **Dirty state travels as a git patch,** not rsync. Binary-safe, reviewable, applied atomically after checkout. - **Truth over optimism in states.** The ready tag, the setup status file, diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 5dbe729..c6de5e8 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -289,6 +289,7 @@ func cmdNew(args []string) { base = pos[1] } + started := time.Now() cfg, drv := loadDriver() repo := repoRoot() // ctrl-c mid-create must cancel the ctx (not just kill the process) so @@ -364,7 +365,7 @@ func cmdNew(args []string) { sess, err = drv.Create(ctx, driver.CreateSpec{ Name: branch, Repo: repo, Branch: branch, BaseRef: base, Image: image, IdleTimeout: idle, UnattendedCap: cap_, - Progress: func(step string) { fmt.Println(ui.Step(step)) }, + Progress: ui.TimedSteps(started), }) if err != nil { // The instance is already gone (Create destroys its own wreckage), so @@ -1095,7 +1096,7 @@ func poolParams(cfg config.Config, drv driver.Driver, repoRoot string) (pool.Par Image: image, IdleTimeout: idle, UnattendedCap: cap_, Manifest: cfg.Secrets.Manifest, SessionEnv: sessionEnv(cfg), - Progress: func(step string) { fmt.Println(ui.Step(step)) }, + Progress: ui.TimedSteps(time.Now()), }, nil } @@ -1412,10 +1413,11 @@ func cmdBake() { name := filepath.Base(repo) hook := driver.BakeHook(repo) fmt.Printf("%s %s\n", ui.Bold.Render("baking "+name), - ui.Dim.Render("(one temporary instance ~5 min, then an image — ~$1-2/mo storage)")) + ui.Dim.Render("(one temporary instance: harnesses ~5 min plus one full .pier/setup.sh run, then an image — ~$1-3/mo storage)")) if hook != "" { fmt.Println(ui.Step(".pier/bake.sh found — its toolchains bake in")) } + fmt.Println(ui.Step("prebuilding from HEAD — the checkout and setup's artifacts bake in, secrets are scrubbed before imaging")) // This bake supersedes the repo's previous image (and on aws-ec2, once // per config, the legacy shared one). replaces := cfg.BakedReplaces(name) @@ -1425,7 +1427,7 @@ func cmdBake() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() img, err := drv.Bake(ctx, driver.BakeSpec{ - RepoName: name, HookPath: hook, Replaces: replaces, + RepoName: name, RepoRoot: repo, HookPath: hook, Replaces: replaces, }) if err != nil { fatal(err) @@ -1439,7 +1441,7 @@ func cmdBake() { }); err != nil { fatal(err) } - fmt.Println(ui.OK.Render("baked "+img) + ui.Dim.Render(" — new "+name+" sessions now cold-start in ~1-2 min")) + fmt.Println(ui.OK.Render("baked "+img) + ui.Dim.Render(" — new "+name+" sessions start from this checkout; re-run `pier bake` when dependencies drift")) if cfg.PoolSize(name) > 0 { fmt.Println(ui.Dim.Render("the warm pool was built on the old image — members recycle on the next claim or `pier pool fill`")) } diff --git a/docs/SPEC.md b/docs/SPEC.md index e04becf..08754b9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -283,6 +283,33 @@ protected by the repository's `.gitignore`. teardown sweeps every `pier:managed`-tagged image. ~$1/mo snapshot storage per repo. Offered as the wizard's last step when it runs inside a repo; `pier bake` refreshes it. + + **Prebuild (2026-09).** A toolchain-only image turned out to save almost + nothing on real repos: measured on a large monorepo, a baked create was + attach-ready in 72s but `.pier/setup.sh` then ran ~9 min (container + builds with zero cache hits, dependency stores downloaded from scratch), + so baked and stock felt the same. The bake therefore also prebuilds: after + the hook, the bake instance receives a create's exact cargo off the + laptop's HEAD (placeholder branch `pier-prebuild`), bootstraps, and runs + `.pier/setup.sh` to completion; a failed setup aborts the bake. A scrub + then deletes, by exact path, every file the files tar delivered, plus + containers (their config embeds `env_file` values), setup logs and + status, gh/docker/git credential files and `~/.config/pier`, and resets + tracked edits. Images, volumes and the build cache stay. Anything setup + derived from secrets is the repo's responsibility, and is documented as + such. The bootstrap is shared: finding `~/work//.git` it sets the + origin, fetches incrementally, `checkout -f -B` onto the session branch + (untracked artifacts survive) and drops the placeholder, so + `.pier/setup.sh` re-runs warm. That was already the pool-claim contract, + and is now stated for every session: setup must be safe to re-run on a + warm disk. Freshness is manual (`pier bake` again); there is no control + plane to schedule rebakes. + + EBS restores snapshots lazily from S3, and a prebuilt image is gigabytes. + AWS launches from a baked image therefore set `VolumeInitializationRate` + (300 MiB/s, billed per GiB of snapshot data) on the root mapping. A CLI + that predates the field rejects it client-side, and the launch retries + without it. GCE documents no lazy-restore penalty, so it needs nothing. 2. **Overlapped create** — launch the instance first; build the git bundle + secrets tar while it boots; push and bootstrap the moment sshd answers; `.pier/setup.sh` runs asynchronously in a background tmux window while you diff --git a/internal/driver/awsec2/bake.go b/internal/driver/awsec2/bake.go index 3ef27f8..0007fb4 100644 --- a/internal/driver/awsec2/bake.go +++ b/internal/driver/awsec2/bake.go @@ -14,10 +14,12 @@ import ( // Bake launches a throwaway instance with the exact session user-data, lets // cloud-init finish the harness install, runs the repo's .pier/bake.sh (if -// any), and images the result. Because every install step in the user-data is -// guarded, sessions launched from the baked AMI skip straight past it — cold -// create drops to boot + push time (~60-90s). Images are repo-specific: the -// hook is where a repo's toolchains (pnpm, python, ...) get baked in. +// any), prebuilds the repo (checkout + .pier/setup.sh to completion, then a +// scrub), and images the result. Every install step in the user-data is +// guarded and the bootstrap reuses a checkout it finds, so sessions launched +// from the baked AMI skip both the harness install and the repo's cold setup. +// Images are repo-specific: the hook is where a repo's toolchains (pnpm, +// python, ...) get baked in, setup.sh where its dependencies do. func (d *Driver) Bake(ctx context.Context, spec driver.BakeSpec) (string, error) { arch, err := d.archOf(ctx, d.InstanceType) if err != nil { @@ -89,6 +91,22 @@ exit 1` return "", fmt.Errorf(".pier/bake.sh failed — nothing baked: %w", err) } } + if spec.RepoRoot != "" { + supervisor, err := d.SupervisorBin(arch) + if err != nil { + return "", err + } + step := func(s string) { fmt.Println(ui.Step(s)) } + remote := payload.Remote{ + Push: func(ctx context.Context, local, remote string) error { return d.push(ctx, id, local, remote, step) }, + Run: func(ctx context.Context, extra []string, script string) (string, error) { + return d.sshRunOpts(ctx, id, extra, script) + }, + } + if err := payload.Prebuild(ctx, spec.RepoRoot, supervisor, d.Manifest, d.SessionEnv, remote, step); err != nil { + return "", fmt.Errorf("prebuild failed — nothing baked: %w", err) + } + } // Per-instance state must not leak into the image. if _, err := d.sshRun(ctx, id, "rm -f ~/.ssh/authorized_keys && sudo rm -rf /etc/pier"); err != nil { return "", err @@ -105,7 +123,7 @@ exit 1` name := "pier-" + repo + "-" + time.Now().Format("20060102-1504") tags := "Tags=[{Key=pier:managed,Value=1},{Key=pier:repo,Value=" + repo + "}]" img, err := d.aws(ctx, "ec2", "create-image", "--instance-id", id, "--name", name, - "--description", "pier session base for "+repo+" (harnesses + bake hook preinstalled)", + "--description", "pier session base for "+repo+" (harnesses, bake hook, prebuilt checkout)", "--tag-specifications", "ResourceType=image,"+tags, "ResourceType=snapshot,"+tags, diff --git a/internal/driver/awsec2/create.go b/internal/driver/awsec2/create.go index e791b3b..59a16af 100644 --- a/internal/driver/awsec2/create.go +++ b/internal/driver/awsec2/create.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -103,7 +104,7 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv } } - progress("bootstrapping (stock AMI waits for cloud-init here — `pier bake` skips that)") + progress(payload.BootstrapNote(spec.Image)) var fwd []string if pl.ForwardAgent { fwd = []string{"-A"} @@ -128,6 +129,11 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv }, nil } +// volumeInitRate is the provisioned hydration rate (MiB/s) for volumes +// restored from a baked image — the API's maximum, 300, loads a 20 GiB +// prebuild in about 70s. +const volumeInitRate = 300 + // pushBackoff is the first pause between push attempts, doubling after each. // A var so tests can exercise the retry policy without sleeping through it. var pushBackoff = 2 * time.Second @@ -202,8 +208,21 @@ func (d *Driver) launch(ctx context.Context, spec driver.CreateSpec, me, ami, ud {"ResourceType": "instance", "Tags": tagList(spec, me, "pier-"+spec.Name)}, {"ResourceType": "volume", "Tags": tagList(spec, me, "pier-"+spec.Name)}, }) - bdm := fmt.Sprintf(`[{"DeviceName":%q,"Ebs":{"VolumeSize":%d,"VolumeType":"gp3","DeleteOnTermination":true}}]`, - rootDev, d.DiskGiB) + // A volume restored from a snapshot is lazily loaded from S3: every block's + // first read stalls, which a prebuilt image — gigabytes of dependencies + // and container layers — feels on every command of its first minutes. + // Provisioned-rate initialization hydrates the whole volume at a fixed + // rate right after launch instead (billed per GiB of snapshot data, cents + // per create). Stock AMIs are small and cached by AWS, so only baked + // images ask for it. + initRate := "" + if spec.Image != "" { + initRate = fmt.Sprintf(`,"VolumeInitializationRate":%d`, volumeInitRate) + } + bdm := func(initRate string) string { + return fmt.Sprintf(`[{"DeviceName":%q,"Ebs":{"VolumeSize":%d,"VolumeType":"gp3","DeleteOnTermination":true%s}}]`, + rootDev, d.DiskGiB, initRate) + } args := []string{"ec2", "run-instances", "--image-id", ami, @@ -212,7 +231,7 @@ func (d *Driver) launch(ctx context.Context, spec driver.CreateSpec, me, ami, ud "--security-group-ids", sg, "--instance-initiated-shutdown-behavior", "stop", // in-VM shutdown = park "--metadata-options", "HttpTokens=required,HttpEndpoint=enabled", - "--block-device-mappings", bdm, + "--block-device-mappings", bdm(initRate), "--tag-specifications", string(tags), "--user-data", "file://" + udPath, "--query", "Instances[0].InstanceId", @@ -228,6 +247,16 @@ func (d *Driver) launch(ctx context.Context, spec driver.CreateSpec, me, ami, ud return out, nil } lastErr = err + if initRate != "" && strings.Contains(err.Error(), "VolumeInitializationRate") { + // An aws CLI older than the field (mid-2025) rejects it client-side. + // The launch still works without it, just with slower first reads. + if spec.Progress != nil { + spec.Progress("aws CLI predates fast volume initialization — upgrade it for faster first disk reads") + } + initRate = "" + args[slices.Index(args, "--block-device-mappings")+1] = bdm("") + continue + } if strings.Contains(err.Error(), "Invalid IAM Instance Profile") { time.Sleep(3 * time.Second) // IAM propagation continue diff --git a/internal/driver/awsec2/create_test.go b/internal/driver/awsec2/create_test.go index c1713d6..9147e59 100644 --- a/internal/driver/awsec2/create_test.go +++ b/internal/driver/awsec2/create_test.go @@ -3,6 +3,9 @@ package awsec2 import ( "context" "errors" + "os" + "path/filepath" + "strings" "testing" "time" @@ -132,3 +135,80 @@ func TestTagListPoolGen(t *testing.T) { } t.Fatalf("pool create missing %s", TagPool) } + +// fakeAWS puts an aws CLI on PATH that answers the calls launch makes and +// logs every run-instances invocation's arguments, one line each. With +// rejectInitRate it fails the way a CLI older than the field does. +func fakeAWS(t *testing.T, rejectInitRate bool) (log string) { + t.Helper() + bin := t.TempDir() + log = filepath.Join(bin, "run-instances.log") + reject := "" + if rejectInitRate { + reject = `case "$*" in *VolumeInitializationRate*) echo 'Parameter validation failed: Unknown parameter in BlockDeviceMappings[0].Ebs: "VolumeInitializationRate"' >&2; exit 252;; esac` + } + script := "#!/bin/sh\ncase \"$2\" in\n" + + "describe-vpcs) echo vpc-1 ;;\n" + + "describe-security-groups) echo sg-1 ;;\n" + + "describe-images) echo /dev/sda1 ;;\n" + + "run-instances) echo \"$*\" >> " + log + "\n" + reject + "\necho i-new ;;\n" + + "esac\n" + if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + return log +} + +// A prebuilt image is gigabytes of lazily-loaded snapshot: baked launches +// ask EBS to hydrate the volume up front, stock ones (small, AWS-cached) +// don't — and a CLI too old for the field must cost speed, never the launch. +func TestLaunchVolumeInitialization(t *testing.T) { + d := &Driver{InstanceType: "t4g.medium", DiskGiB: 40} + ud := filepath.Join(t.TempDir(), "ud") + os.WriteFile(ud, nil, 0o600) + spec := driver.CreateSpec{Name: "s", Repo: "/tmp/r", Branch: "s"} + + t.Run("baked asks for provisioned-rate hydration", func(t *testing.T) { + log := fakeAWS(t, false) + spec := spec + spec.Image = "ami-baked" + if _, err := d.launch(context.Background(), spec, "me", spec.Image, ud); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(log) + if !strings.Contains(string(b), `"VolumeInitializationRate":300`) { + t.Errorf("baked launch must request volume initialization, ran: %s", b) + } + }) + + t.Run("stock does not", func(t *testing.T) { + log := fakeAWS(t, false) + if _, err := d.launch(context.Background(), spec, "me", "ami-stock", ud); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(log); strings.Contains(string(b), "VolumeInitializationRate") { + t.Errorf("stock launch must not pay for hydration, ran: %s", b) + } + }) + + t.Run("old CLI falls back without it", func(t *testing.T) { + log := fakeAWS(t, true) + spec := spec + spec.Image = "ami-baked" + var notes []string + spec.Progress = func(s string) { notes = append(notes, s) } + id, err := d.launch(context.Background(), spec, "me", spec.Image, ud) + if err != nil || id != "i-new" { + t.Fatalf("launch must survive a CLI that predates the field: id=%q err=%v", id, err) + } + b, _ := os.ReadFile(log) + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(lines) != 2 || strings.Contains(lines[1], "VolumeInitializationRate") { + t.Errorf("want one rejected attempt then one without the field, ran:\n%s", b) + } + if len(notes) != 1 || !strings.Contains(notes[0], "upgrade") { + t.Errorf("the fallback must say how to get the speed back, got %q", notes) + } + }) +} diff --git a/internal/driver/driver.go b/internal/driver/driver.go index bbbe5fd..b3e827b 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -101,9 +101,12 @@ type ClaimSpec struct { // BakeSpec describes one repo's image bake. Images are repo-specific: the // default install serves pier and the harnesses; whatever a repo's toolchain -// needs on top (pnpm, python, ...) comes from its .pier/bake.sh. +// needs on top (pnpm, python, ...) comes from its .pier/bake.sh; and with +// RepoRoot set, the bake is a prebuild — the repo checked out and its +// .pier/setup.sh run to completion, so sessions start from the result. type BakeSpec struct { RepoName string // repo basename; keys the image to its repo + RepoRoot string // local repo root to prebuild from; "" = toolchains only HookPath string // local path to the repo's .pier/bake.sh; "" = none Replaces []string // images this bake supersedes (previous bake, legacy shared image) } @@ -196,8 +199,9 @@ type Driver interface { // Exec runs a one-shot command (status reads, push bootstrap) without a TTY. Exec(ctx context.Context, id string, command string) (string, error) - // Bake builds one repo's prebaked session image (harnesses + the repo's - // .pier/bake.sh toolchains), cutting that repo's cold create to ~60-90s. + // Bake builds one repo's prebaked session image: harnesses, the repo's + // .pier/bake.sh toolchains, and (prebuild) its checkout with + // .pier/setup.sh already run, so creates skip the repo's own setup cost. Bake(ctx context.Context, spec BakeSpec) (imageID string, err error) // Headroom reports account capacity (vCPU quota) for the create-time diff --git a/internal/driver/gcpgce/bake.go b/internal/driver/gcpgce/bake.go index fd7f994..59c9666 100644 --- a/internal/driver/gcpgce/bake.go +++ b/internal/driver/gcpgce/bake.go @@ -16,9 +16,11 @@ import ( // Bake launches a throwaway instance with the exact session user-data, lets // cloud-init finish the harness install, runs the repo's .pier/bake.sh (if // any), and images the boot disk. Because every install step in the user-data -// is guarded, sessions launched from the baked image skip straight past it — -// cold create drops to boot + push time. Images are repo-specific: the hook -// is where a repo's toolchains (pnpm, python, ...) get baked in. +// is guarded, sessions launched from the baked image skip straight past it. +// With a repo root the bake is a prebuild: the checkout and a completed +// .pier/setup.sh are imaged too, so creates skip the repo's cold setup. +// Images are repo-specific: the hook is where a repo's toolchains (pnpm, +// python, ...) get baked in, setup.sh where its dependencies do. func (d *Driver) Bake(ctx context.Context, spec driver.BakeSpec) (string, error) { me, err := d.user(ctx) if err != nil { @@ -79,6 +81,29 @@ exit 1` return "", fmt.Errorf(".pier/bake.sh failed — nothing baked: %w", err) } } + if spec.RepoRoot != "" { + supervisor, err := d.SupervisorBin(archOf(d.MachineType)) + if err != nil { + return "", err + } + remote := payload.Remote{ + Push: func(ctx context.Context, local, remote string) error { + if err := d.scpTo(ctx, id, local, remote); err == nil || ctx.Err() != nil { + return err + } + // One retry, as in create: the tunnel can drop mid-push. + time.Sleep(2 * time.Second) + return d.scpTo(ctx, id, local, remote) + }, + Run: func(ctx context.Context, extra []string, script string) (string, error) { + return d.sshRunOpts(ctx, id, extra, script) + }, + } + step := func(s string) { fmt.Println(ui.Step(s)) } + if err := payload.Prebuild(ctx, spec.RepoRoot, supervisor, d.Manifest, d.SessionEnv, remote, step); err != nil { + return "", fmt.Errorf("prebuild failed — nothing baked: %w", err) + } + } // Per-instance state must not leak into the image. if _, err := d.sshRun(ctx, id, "rm -f ~/.ssh/authorized_keys && sudo rm -rf /etc/pier"); err != nil { return "", err @@ -99,7 +124,7 @@ exit 1` // image is ready. img, err := d.gcloud(ctx, "compute", "images", "create", name, "--source-disk", id, "--source-disk-zone", d.Zone, - "--description", "pier session base for "+repo+" (harnesses + bake hook preinstalled)", + "--description", "pier session base for "+repo+" (harnesses, bake hook, prebuilt checkout)", "--labels", LabelManaged+"=1,"+MetaRepo+"="+repo, "--format", "value(name)") if err != nil { diff --git a/internal/driver/gcpgce/create.go b/internal/driver/gcpgce/create.go index 431690b..0885aa9 100644 --- a/internal/driver/gcpgce/create.go +++ b/internal/driver/gcpgce/create.go @@ -102,7 +102,7 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv } } - progress("bootstrapping (a stock image waits for cloud-init here — `pier bake` skips that)") + progress(payload.BootstrapNote(spec.Image)) var fwd []string if pl.ForwardAgent { fwd = []string{"-A"} diff --git a/internal/driver/payload/prebuild.go b/internal/driver/payload/prebuild.go new file mode 100644 index 0000000..94bfd93 --- /dev/null +++ b/internal/driver/payload/prebuild.go @@ -0,0 +1,219 @@ +package payload + +import ( + "archive/tar" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/usepier/pier/internal/driver" +) + +// --- prebuild ------------------------------------------------------------------- +// A toolchain-only image leaves every create to redo the repo's own setup — +// dependency installs, image builds, seeds — which is most of the wait for any +// real repo. A prebuild pays that once, at bake: the bake instance checks the +// repo out exactly like a session would, runs .pier/setup.sh to completion, +// scrubs everything pier pushed, and only then gets imaged. Sessions launched +// from it find the checkout and setup's artifacts already on disk; their +// bootstrap fetches the delta and setup re-runs warm. + +// PrebuildBranch is the placeholder branch the bake instance's checkout sits +// on. A session's bootstrap moves the checkout to its own branch and deletes +// this one. +const PrebuildBranch = "pier-prebuild" + +// SetupWait bounds how long a prebuild or a pool fill waits for +// .pier/setup.sh. A setup this slow is broken, not warming. +const SetupWait = 45 * time.Minute + +// Remote is the slice of a driver a prebuild drives: copy one file onto the +// bake instance, run one script on it (extra carries ssh flags such as -A). +type Remote struct { + Push func(ctx context.Context, local, remote string) error + Run func(ctx context.Context, extra []string, script string) (string, error) +} + +// Prebuild turns a bake instance (harnesses and bake hook already in place) +// into a setup-complete checkout of repoRoot, then scrubs it for imaging. +// The cargo is exactly a create's, off the laptop's HEAD, so setup runs with +// the secrets and env files a real session gets; the scrub then removes every +// file that cargo delivered. What setup itself derived from them (an env +// value compiled into a build, a seeded database) is the repo's to keep out. +func Prebuild(ctx context.Context, repoRoot string, supervisor []byte, manifest []string, env map[string]string, r Remote, progress func(string)) error { + if progress == nil { + progress = func(string) {} + } + work, err := os.MkdirTemp("", "pier-prebuild-*") + if err != nil { + return err + } + defer os.RemoveAll(work) + spec := driver.CreateSpec{Name: PrebuildBranch, Repo: repoRoot, Branch: PrebuildBranch} + pl, err := Build(ctx, work, spec, supervisor, manifest, env, progress) + if err != nil { + return err + } + scrub, err := ScrubScript(filepath.Join(work, "pier-files.tar"), filepath.Base(repoRoot)) + if err != nil { + return err + } + scrubPath := filepath.Join(work, "pier-scrub.sh") + if err := os.WriteFile(scrubPath, []byte(scrub), 0o755); err != nil { + return err + } + + for _, n := range pl.Notes { + progress(n) + } + for _, p := range pl.Pushes { + if err := r.Push(ctx, p.Local, p.Remote); err != nil { + return err + } + } + progress("checking out " + filepath.Base(repoRoot)) + var fwd []string + if pl.ForwardAgent { + fwd = []string{"-A"} + } + if out, err := r.Run(ctx, fwd, "bash /tmp/pier-bootstrap.sh"); err != nil { + return fmt.Errorf("prebuild checkout: %w\n%s", err, out) + } + if hasSetup(repoRoot) { + progress("running .pier/setup.sh to completion — the slow part every session now skips") + start := time.Now() + exec := func(ctx context.Context, cmd string) (string, error) { return r.Run(ctx, nil, cmd) } + if err := WaitSetup(ctx, exec, SetupWait); err != nil { + return err + } + progress(fmt.Sprintf("setup done in %s", time.Since(start).Round(time.Second))) + } + progress("scrubbing secrets and per-session state before imaging") + if err := r.Push(ctx, scrubPath, "/tmp/pier-scrub.sh"); err != nil { + return err + } + if out, err := r.Run(ctx, nil, "bash /tmp/pier-scrub.sh"); err != nil { + return fmt.Errorf("prebuild scrub: %w\n%s", err, out) + } + return nil +} + +// hasSetup mirrors the setup window's choice: the PIER_SETUP_SCRIPT override +// when it resolves, else the repo's .pier/setup.sh. +func hasSetup(repoRoot string) bool { + if src, _ := setupScriptOverride(repoRoot); src != "" { + return true + } + fi, err := os.Stat(filepath.Join(repoRoot, ".pier", "setup.sh")) + return err == nil && fi.Mode().IsRegular() +} + +// WaitSetup polls the setup status file (the one the supervisor beacons to +// ls/TUI). "running" — and, before the setup window has spun up, a missing +// file — keep it waiting; "0" succeeds; anything else fails with the log tail. +func WaitSetup(ctx context.Context, exec func(ctx context.Context, cmd string) (string, error), timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + out, err := exec(ctx, "cat ~/.pier-setup.status 2>/dev/null || echo none") + if err != nil && ctx.Err() != nil { + return err + } + if err == nil { + switch status := strings.TrimSpace(out); status { + case "0": + return nil + case "none", "running": + default: + tail, _ := exec(ctx, "tail -n 15 ~/.pier-setup.log 2>/dev/null") + return fmt.Errorf(".pier/setup.sh failed (exit %s):\n%s", status, tail) + } + } + if time.Now().After(deadline) { + return fmt.Errorf("setup still running after %s", timeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(setupPoll): + } + } +} + +// setupPoll is WaitSetup's poll interval. A var so tests don't sleep it out. +var setupPoll = 10 * time.Second + +// ScrubScript renders the pre-imaging cleanup for a prebuild: every file the +// files tar delivered (harness auth, tokens, the MCP seed, untracked and +// .pier/include repo files) is deleted by exact path, alongside the state +// pier and a first boot leave that must not outlive this instance. Each +// create re-pushes all of it, so none of it buys a session any speed. +func ScrubScript(filesTar, repo string) (string, error) { + f, err := os.Open(filesTar) + if err != nil { + return "", err + } + defer f.Close() + var home, repoFiles []string + tr := tar.NewReader(f) + for { + h, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + name := filepath.ToSlash(filepath.Clean(h.Name)) + if strings.Contains("/"+name+"/", "/../") { + continue + } + switch { + case strings.HasPrefix(name, "home/"): + home = append(home, strings.TrimPrefix(name, "home/")) + case strings.HasPrefix(name, "repo/"): + repoFiles = append(repoFiles, strings.TrimPrefix(name, "repo/")) + } + } + + var b strings.Builder + b.WriteString(`#!/usr/bin/env bash +# pier prebuild scrub — runs once on the bake instance, right before imaging. +set -uo pipefail +# Processes die with the stop anyway; ending them now keeps the setup log and +# shells from writing past the scrub. +tmux kill-server 2>/dev/null || true +# Containers hold their environment (env_file values included) in their +# config: remove them. Images, volumes and the build cache — the warmth — +# stay, and a session's setup re-run recreates containers in seconds. +ids=$(sudo -n docker ps -aq 2>/dev/null) && [ -n "$ids" ] && sudo -n docker rm -f $ids >/dev/null +cd "$HOME/work/` + repo + `" || exit 1 +# Tracked edits the laptop's dirty patch applied; sessions reset anyway. +git reset -q --hard +`) + for _, p := range repoFiles { + b.WriteString("rm -f -- " + shQuote(p) + "\n") + } + b.WriteString("cd \"$HOME\"\n") + for _, p := range home { + b.WriteString("rm -f -- " + shQuote(p) + "\n") + } + b.WriteString(`# Credentials setup or the harnesses may have written from the pushed ones, +# and first-boot state a session must create for itself. +rm -rf ~/.config/pier ~/.pier-setup.d +rm -f ~/.pier-bootstrapped ~/.pier-setup.status ~/.pier-setup.log ~/.bash_history \ + ~/.config/gh/hosts.yml ~/.docker/config.json ~/.git-credentials ~/.ssh/agent.sock +rm -f /tmp/pier-scrub.sh +echo scrubbed +`) + return b.String(), nil +} + +// shQuote single-quotes s for bash. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/driver/payload/prebuild_test.go b/internal/driver/payload/prebuild_test.go new file mode 100644 index 0000000..1a99026 --- /dev/null +++ b/internal/driver/payload/prebuild_test.go @@ -0,0 +1,182 @@ +package payload + +import ( + "archive/tar" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/usepier/pier/internal/driver" +) + +// The scrub is the only thing between a prebuild's secrets and an image +// anyone in the account can launch: it must delete every file the cargo +// delivered, and nothing setup built — that warmth is what the bake is for. +func TestScrubScriptRemovesCargoAndKeepsWarmth(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + home := t.TempDir() + repo := filepath.Join(home, "work", "myrepo") + mustGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(repo, "tracked.txt"), "clean\n") + mustGit("init", "-q") + mustGit("-c", "user.name=t", "-c", "user.email=t@t", "add", ".") + mustGit("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init") + + cargo := map[string]string{ + "home/.claude/settings.json": "{}", + "home/.codex/auth.json": "secret", + "home/.claude.json": "{}", + "home/.config/pier/env": "GH_TOKEN=secret", + "repo/.env": "DB_PASSWORD=secret", + "repo/it's quoted.env": "x", + } + tarPath := filepath.Join(t.TempDir(), "pier-files.tar") + f, err := os.Create(tarPath) + if err != nil { + t.Fatal(err) + } + tw := tar.NewWriter(f) + for name, body := range cargo { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(body))}); err != nil { + t.Fatal(err) + } + tw.Write([]byte(body)) + // Delivered where the bootstrap extracts it. + if rel, ok := strings.CutPrefix(name, "home/"); ok { + write(filepath.Join(home, rel), body) + } else { + write(filepath.Join(repo, strings.TrimPrefix(name, "repo/")), body) + } + } + tw.Close() + f.Close() + + // What setup built, and first-boot state. + write(filepath.Join(repo, "node_modules", "dep", "index.js"), "warm") + write(filepath.Join(repo, "tracked.txt"), "dirty edit\n") + write(filepath.Join(home, ".cache", "pnpm", "store"), "warm") + for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials"} { + write(filepath.Join(home, p), "x") + } + + script, err := ScrubScript(tarPath, "myrepo") + if err != nil { + t.Fatal(err) + } + // tmux and sudo are stubbed: the scrub must never kill the tmux server or + // touch docker on the machine running the tests. + stubs := t.TempDir() + log := filepath.Join(stubs, "calls") + for _, name := range []string{"tmux", "sudo"} { + write(filepath.Join(stubs, name), "#!/bin/sh\necho "+name+" \"$@\" >> "+log+"\n") + os.Chmod(filepath.Join(stubs, name), 0o755) + } + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+stubs+":"+os.Getenv("PATH"), + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("scrub: %v\n%s", err, out) + } + + for name := range cargo { + p := filepath.Join(home, strings.TrimPrefix(name, "home/")) + if rel, ok := strings.CutPrefix(name, "repo/"); ok { + p = filepath.Join(repo, rel) + } + if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s survived the scrub", name) + } + } + for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials"} { + if _, err := os.Stat(filepath.Join(home, p)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("~/%s survived the scrub", p) + } + } + for _, p := range []string{filepath.Join(repo, "node_modules", "dep", "index.js"), filepath.Join(home, ".cache", "pnpm", "store")} { + if _, err := os.Stat(p); err != nil { + t.Errorf("setup artifact %s was scrubbed: %v", p, err) + } + } + if b, _ := os.ReadFile(filepath.Join(repo, "tracked.txt")); string(b) != "clean\n" { + t.Errorf("tracked edits must be reset, got %q", b) + } + calls, _ := os.ReadFile(log) + if !strings.Contains(string(calls), "tmux kill-server") { + t.Error("the scrub must end the tmux server before imaging") + } + if !strings.Contains(string(calls), "sudo -n docker ps -aq") { + t.Error("the scrub must remove containers (their config holds env_file values), non-interactively") + } +} + +func TestWaitSetup(t *testing.T) { + old := setupPoll + setupPoll = time.Millisecond + t.Cleanup(func() { setupPoll = old }) + + seq := func(answers ...string) func(context.Context, string) (string, error) { + i := 0 + return func(_ context.Context, cmd string) (string, error) { + if strings.HasPrefix(cmd, "tail") { + return "boom", nil + } + a := answers[min(i, len(answers)-1)] + i++ + return a, nil + } + } + if err := WaitSetup(context.Background(), seq("none", "running", "0"), time.Minute); err != nil { + t.Fatalf("a setup that finishes must pass: %v", err) + } + err := WaitSetup(context.Background(), seq("running", "2"), time.Minute) + if err == nil || !strings.Contains(err.Error(), "exit 2") || !strings.Contains(err.Error(), "boom") { + t.Fatalf("a failed setup must fail with its exit code and log tail, got %v", err) + } + if err := WaitSetup(context.Background(), seq("running"), 5*time.Millisecond); err == nil { + t.Fatal("a setup that never finishes must time out") + } +} + +// A session launched from a prebuilt image must reuse the checkout — the +// artifacts around it are the point — rather than fail on `git init` or +// `git remote add` against a repo that already has them. +func TestRenderBootstrapReusesPrebuiltCheckout(t *testing.T) { + spec := driver.CreateSpec{Name: "x", Repo: "/tmp/myrepo", Branch: "feat"} + b := renderBootstrap(spec, "origin", "abc123", "https://github.com/o/r") + for _, want := range []string{ + `if [ -d .git ]; then prebuilt=1; else git init -q -b 'feat'; fi`, + `git remote set-url origin 'https://github.com/o/r' 2>/dev/null || git remote add origin 'https://github.com/o/r'`, + `if [ -n "$prebuilt" ]; then git checkout -qf -B 'feat' abc123; fi`, + `git branch -qD '` + PrebuildBranch + `'`, + } { + if !strings.Contains(b, want) { + t.Errorf("bootstrap missing %q", want) + } + } + if BootstrapNote("") == BootstrapNote("ami-1") { + t.Error("a baked create must not claim to wait on a stock image's cloud-init") + } +} diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index ad39ace..aa21d13 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -174,9 +174,14 @@ set -a; . "$HOME/.config/pier/env" 2>/dev/null || true; set +a mkdir -p "$HOME/work/{{REPO}}" cd "$HOME/work/{{REPO}}" -git init -q -b '{{BRANCH}}' +# A prebuilt image already holds the checkout with setup's artifacts around it +# (node_modules, venvs, build caches — the warmth pier bake paid for), so the +# fetch below is incremental and the branch moves onto it instead of a fresh +# init. Untracked artifacts survive the checkout; tracked state is reset. +prebuilt= +if [ -d .git ]; then prebuilt=1; else git init -q -b '{{BRANCH}}'; fi {{GITCONFIG}} -if [ -n '{{ORIGIN}}' ]; then git remote add origin '{{ORIGIN}}'; fi +if [ -n '{{ORIGIN}}' ]; then git remote set-url origin '{{ORIGIN}}' 2>/dev/null || git remote add origin '{{ORIGIN}}'; fi # ssh origin = fetch rides the laptop's forwarded agent; pre-trust github. case '{{ORIGIN}}' in git@*|ssh://*) mkdir -p "$HOME/.ssh" && ssh-keyscan github.com >> "$HOME/.ssh/known_hosts" 2>/dev/null || true ;; esac # gh brokers git credentials for https origin fetches; on a stock image it may @@ -188,7 +193,9 @@ case '{{MODE}}' in thin) git fetch -q --no-tags origin && git fetch -q /tmp/pier.bundle {{EXPORTREF}} ;; *) git fetch -q /tmp/pier.bundle {{EXPORTREF}} ;; esac +if [ -n "$prebuilt" ]; then git checkout -qf -B '{{BRANCH}}' {{SHA}}; fi git reset -q --hard {{SHA}} +if [ -n "$prebuilt" ] && [ '{{BRANCH}}' != '` + PrebuildBranch + `' ]; then git branch -qD '` + PrebuildBranch + `' 2>/dev/null || true; fi # Uncommitted edits to tracked files, exactly as the laptop had them (a # failed apply fails the create — better than silently missing work). if [ -f /tmp/pier-dirty.patch ]; then git apply /tmp/pier-dirty.patch; fi @@ -218,6 +225,16 @@ rm -f /tmp/pier.bundle /tmp/pier-files.tar /tmp/pier-dirty.patch /tmp/pier-super echo bootstrapped ` +// BootstrapNote is the progress line for the bootstrap step: on a stock image +// it waits out cloud-init's harness install; on a baked one it reuses the +// prebuilt checkout when the image carries one. +func BootstrapNote(image string) string { + if image == "" { + return "bootstrapping (a stock image waits for cloud-init here — `pier bake` skips that)" + } + return "bootstrapping (baked image: harnesses installed, prebuilt checkout reused when present)" +} + func renderBootstrap(spec driver.CreateSpec, mode, sha, origin string) string { return strings.NewReplacer( "{{REPO}}", filepath.Base(spec.Repo), diff --git a/internal/pool/fill.go b/internal/pool/fill.go index 8c9a571..e4629fc 100644 --- a/internal/pool/fill.go +++ b/internal/pool/fill.go @@ -4,10 +4,10 @@ import ( "context" "fmt" "path/filepath" - "strings" "time" "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/driver/payload" ) // Fill reconciles one repo's pool to Params.Size: stale members recycle, @@ -90,32 +90,14 @@ func fillOne(ctx context.Context, p Params, hasSetup bool) error { return p.Driver.Park(ctx, sess.ID) } -// waitSetup polls the member's setup status (the same file the supervisor -// beacons to ls/TUI). "running" — and, before the setup window has spun up, -// a missing file — keep it waiting; "0" succeeds; anything else fails with -// the log tail. +// waitSetup waits out the member's .pier/setup.sh; a failure names the +// member, since fill's log interleaves several. func waitSetup(ctx context.Context, p Params, id string) error { - deadline := time.Now().Add(setupWait) - for { - out, err := p.Driver.Exec(ctx, id, "cat ~/.pier-setup.status 2>/dev/null || echo none") - if err != nil && ctx.Err() != nil { - return err - } - if err == nil { - switch status := strings.TrimSpace(out); status { - case "0": - return nil - case "none", "running": - default: - tail, _ := p.Driver.Exec(ctx, id, "tail -n 15 ~/.pier-setup.log 2>/dev/null") - return fmt.Errorf(".pier/setup.sh failed (exit %s) on the pool member:\n%s", status, tail) - } - } - if time.Now().After(deadline) { - return fmt.Errorf("setup still running after %s — not pooling this member", setupWait) - } - time.Sleep(10 * time.Second) + exec := func(ctx context.Context, cmd string) (string, error) { return p.Driver.Exec(ctx, id, cmd) } + if err := payload.WaitSetup(ctx, exec, payload.SetupWait); err != nil { + return fmt.Errorf("pool member: %w", err) } + return nil } // Drain destroys every pool member of the repo, any generation, and returns diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 447d4a1..d642334 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -35,9 +35,6 @@ const ( // claim burns real money. Exported so doctor and the TUI census can flag // the same corpses reconcile would collect. FillGrace = 2 * time.Hour - // setupWait bounds fill's wait for .pier/setup.sh. A setup this slow is - // broken, not warm. - setupWait = 45 * time.Minute ) // Params carries everything fill and claim need, resolved by the cmd layer diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 328b998..e98bc72 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,8 +5,10 @@ package ui import ( + "fmt" "os" "strings" + "time" "github.com/charmbracelet/lipgloss" ) @@ -36,6 +38,15 @@ func Step(s string) string { return Accent.Render(" ▸") + " " + s } +// TimedSteps prints progress lines as Step does, each suffixed with the time +// since start — so a slow create or fill says which step ate the wait, in the +// terminal and in the background logs alike. +func TimedSteps(start time.Time) func(string) { + return func(s string) { + fmt.Println(Step(s) + Dim.Render(fmt.Sprintf(" +%s", time.Since(start).Round(time.Second)))) + } +} + // Mark renders a green ✓ or red ✗. func Mark(ok bool) string { if ok { diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index cf6e73c..f102752 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -229,7 +229,7 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo } // 5. offer the bundled skills — per-agent confirms, before bake so no - // question hides behind a ~5 min build. + // question hides behind the build. if home, err := os.UserHomeDir(); err == nil { if err := offerSkills(in, &cfg, home); err != nil { return err @@ -240,14 +240,14 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo // inside a repo; otherwise point at `pier bake` from one. fmt.Println() if repo := gitToplevel(); repo == "" { - fmt.Println(ui.Dim.Render(" (images bake per repo: cd && pier bake — ~5 min once, cuts creates to ~1-2 min)")) - } else if name := filepath.Base(repo); yes(in, "bake the session image for "+name+" now? (~5 min once; cuts creates to ~1-2 min)", true) { + fmt.Println(ui.Dim.Render(" (images bake per repo: cd && pier bake — harnesses plus one full .pier/setup.sh run, so creates skip both)")) + } else if name := filepath.Base(repo); yes(in, "bake the session image for "+name+" now? (harnesses ~5 min plus one full .pier/setup.sh run; creates then skip both)", true) { // ctrl-c mid-bake must cancel the ctx so Bake's deferred cleanup can // terminate the temporary instance — it has no supervisor, so a // leaked one never parks itself. bctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) img, err := drv.Bake(bctx, driver.BakeSpec{ - RepoName: name, HookPath: driver.BakeHook(repo), + RepoName: name, RepoRoot: repo, HookPath: driver.BakeHook(repo), Replaces: cfg.BakedReplaces(name), }) stop() diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index 9ce76bb..78db4ea 100644 --- a/skills/pier-onboard/SKILL.md +++ b/skills/pier-onboard/SKILL.md @@ -13,7 +13,7 @@ directory control the rest: | File | When it runs / travels | What belongs in it | |---|---|---| | `.pier/bake.sh` | Once, during `pier bake`, on a throwaway instance that becomes the repo's AMI | **Toolchains** — language runtimes, package managers | -| `.pier/setup.sh` | On every session's first boot, async, after the repo lands | **Repo state** — dependency install, services, migrations, seeds | +| `.pier/setup.sh` | Once during `pier bake` (the prebuild), then on every session's first boot, async, after the repo lands | **Repo state** — dependency install, services, migrations, seeds | | `.pier/include` | List read at create time; matching files ride to the VM | **Ignored files** dev needs — env files, local certs | Your job: inspect this repo, write the files that apply, protect loose local @@ -75,6 +75,13 @@ secrets env (`~/.config/pier/env`) is loaded. Output logs to nonzero exit shows as `(setup failed)` in `pier ls`, so **let failures fail** — start with `set -euo pipefail`, don't swallow errors. +**It must be safe to re-run on a warm disk.** `pier bake` runs it once on a +checkout and images the result. Every session from that image, and every +warm pool claim, runs it again over the dependencies, build caches and +volumes the earlier run left. Use installs that are incremental when +nothing changed (`pnpm install`, `poetry sync`, `docker compose build`, +`docker compose up -d`), and never fail on "already exists". + Prefer the repo's own entry point (`make install`, `make dev-setup`) over duplicating its steps. Typical shape: @@ -123,8 +130,11 @@ committed (but a new, not-yet-committed `.pier/setup.sh` still travels). - The `.pier/` files and any `.gitignore` updates are meant to be committed. Pier runs both scripts with bash, so the exec bit is optional. -- Tell the user: if you wrote or changed `.pier/bake.sh`, run `pier bake` - in the repo (~8 min, once per hook change). Then create a session and +- Tell the user to run `pier bake` in the repo: it installs the bake hook's + toolchains and prebuilds the repo by running `.pier/setup.sh` once, so + sessions start with dependencies already installed. Re-run it after + changing `.pier/bake.sh`, or when dependencies have drifted enough that + session setup gets slow. Then create a session and watch `pier ls` — `(setup running)` should clear; if it shows `(setup failed)`, attach and read `~/.pier-setup.log`. - If `.pier/setup.sh` is heavy (long installs, docker pulls, migrations), From 4af81e3147df9680c89cd02a4eb9d558d3917855 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 14:03:43 +0200 Subject: [PATCH 02/24] feat!: add a shared api layer, speed profiles and a redesigned app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg/pier is now the one API the CLI, the TUI and the coming Mac app use. It owns every capability behind a Client, reports long operations as typed events, returns typed errors, and never prints, exits or reads stdin. Drivers no longer write to the terminal: transfer meters, streamed bake output and transport notices go through fields the frontend sets. Session images are one concept. `pier bake` prebuilds the repo by default and records what it baked from, so pier can remind — never decide — when a rebake would help. Speed profiles (fast, lean, minimal) preset how many ready sessions a baked repo keeps and whether reminders show. Setup shows every default for confirmation and offers to bake the repo it runs in. Settings is one page grouped by cloud, new sessions, idle and cost, and speed, showing only the active cloud. The app has Sessions, Repos and Settings tabs, titled panels, a detail pane and a live cost header. BREAKING CHANGE: `pier pool` is replaced by `pier ready [n]` and `pier repos`, `pier skills` by `pier setup --skills`, and `--no-pool` by `--no-ready`. Baked repos keep one ready session by default. --- cmd/pier/main.go | 1574 +++++++--------------- cmd/pier/main_test.go | 51 - go.mod | 2 +- internal/config/config.go | 374 +++++- internal/driver/awsec2/bake.go | 6 +- internal/driver/awsec2/cli.go | 26 +- internal/driver/awsec2/direct.go | 3 +- internal/driver/awsec2/driver.go | 8 + internal/driver/driver.go | 8 + internal/driver/gcpgce/bake.go | 6 +- internal/driver/gcpgce/cli.go | 26 +- internal/driver/gcpgce/driver.go | 8 + internal/pool/claim.go | 20 +- internal/pool/fill.go | 14 +- internal/pool/pool.go | 2 + internal/tui/logs.go | 215 +-- internal/tui/style.go | 185 +++ internal/tui/tui.go | 2103 +++++++++--------------------- internal/tui/tui_test.go | 918 ++++--------- internal/tui/view.go | 642 +++++++++ internal/ui/ui.go | 11 - internal/wizard/skills.go | 11 +- internal/wizard/wizard.go | 345 ++++- pkg/pier/account.go | 125 ++ pkg/pier/client.go | 203 +++ pkg/pier/create.go | 167 +++ pkg/pier/events.go | 52 + pkg/pier/repos.go | 488 +++++++ pkg/pier/sessions.go | 398 ++++++ pkg/pier/sessions_test.go | 95 ++ 30 files changed, 4501 insertions(+), 3585 deletions(-) create mode 100644 internal/tui/style.go create mode 100644 internal/tui/view.go create mode 100644 pkg/pier/account.go create mode 100644 pkg/pier/client.go create mode 100644 pkg/pier/create.go create mode 100644 pkg/pier/events.go create mode 100644 pkg/pier/repos.go create mode 100644 pkg/pier/sessions.go create mode 100644 pkg/pier/sessions_test.go diff --git a/cmd/pier/main.go b/cmd/pier/main.go index c6de5e8..9074650 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -1,4 +1,8 @@ // pier — coding agent sessions as park-when-idle micro-VMs on your own cloud. +// +// This file is the CLI frontend: it parses flags, calls pkg/pier (the API +// every frontend shares), and renders the result. Behavior lives in the API; +// what's here is presentation. package main import ( @@ -11,31 +15,22 @@ import ( "fmt" "io" "log" - "net" "os" - "os/exec" "os/signal" "path/filepath" - "regexp" - "sort" "strconv" "strings" - "sync" "syscall" "text/tabwriter" "time" "github.com/usepier/pier/internal/config" - "github.com/usepier/pier/internal/driver" - "github.com/usepier/pier/internal/driver/awsec2" - "github.com/usepier/pier/internal/driver/gcpgce" - "github.com/usepier/pier/internal/driver/payload" - "github.com/usepier/pier/internal/pool" "github.com/usepier/pier/internal/proxy" "github.com/usepier/pier/internal/tombstone" "github.com/usepier/pier/internal/tui" "github.com/usepier/pier/internal/ui" "github.com/usepier/pier/internal/wizard" + "github.com/usepier/pier/pkg/pier" ) //go:embed assets @@ -52,6 +47,17 @@ func supervisorBin(arch string) ([]byte, error) { return b, nil } +// options are the API options every CLI command runs with: the terminal +// gets transfer meters and transport notices. +func options() pier.Options { + return pier.Options{ + SupervisorBin: supervisorBin, + Version: version, + Out: os.Stdout, + Notify: func(msg string) { fmt.Fprintln(os.Stderr, ui.Dim.Render("pier: "+msg)) }, + } +} + type helpItem struct { command string description string @@ -64,60 +70,49 @@ type helpSection struct { var helpSections = []helpSection{ { - title: "Create session", + title: "Sessions", items: []helpItem{ - {"pier [base]", "create from base (default HEAD), then attach"}, - {"-d, --detach", "create without attaching"}, - {"--idle ", "set the idle self-park timeout (default from config)"}, - {"--cap ", "set the unattended runaway cap"}, - {"--no-park", "disable idle self-parking"}, - {"--no-pool", "skip this repo's warm pool for this create"}, - }, - }, - { - title: "Manage sessions", - items: []helpItem{ - {"pier ls [--json]", "list sessions (stable JSON with --json)"}, - {"pier attach ", "attach, resuming first if parked"}, + {"pier [base]", "new session off base (default HEAD), then attach"}, + {" -d, --detach", "create without attaching"}, + {" --idle ", "park after this much idle time (default from settings)"}, + {" --cap ", "runaway cap for this session"}, + {" --no-park", "never park this session when idle"}, + {" --no-ready", "launch fresh instead of claiming a ready session"}, + {"pier ls [--json]", "list sessions"}, + {"pier attach ", "attach; a parked session resumes with its tmux restored"}, {"pier logs [-f]", "show or follow the setup log"}, - {"pier keep ", "disable idle self-parking"}, - {"pier resize ", "change VM size (same architecture only)"}, + {"pier keep ", "never park this session when idle"}, + {"pier resize ", "change the VM size (same CPU architecture)"}, {"pier rm [-f]", "destroy a session and its disk"}, }, }, { - title: "Warm pools", + title: "Repos", items: []helpItem{ - {"pier pool", "show warm pool status and cost"}, - {"pier pool set ", "keep warm sessions ready for this repo (0 = off)"}, - {"pier pool fill [--detach]", "top this repo's pool up to size now"}, - {"pier pool drain [repo]", "destroy a repo's warm members"}, + {"pier repos [--json]", "every repo: session image, ready sessions, monthly cost"}, + {"pier bake", "build this repo's session image (repo prebuilt; see settings)"}, + {" --toolchain-only", "keep repo state out of the image for this bake"}, + {" --with-repo", "prebuild the repo into the image for this bake"}, + {"pier ready [n]", "show, or set, how many ready sessions this repo keeps"}, }, }, { - title: "Forwarding & access", + title: "Access", items: []helpItem{ - {"pier proxy", "map sessions to .pier and localhost (macOS)"}, - {"pier port ", "forward ports manually (for example 8080:3000)"}, - {"pier mcp login [server]", "authenticate pending MCP servers in the browser"}, + {"pier proxy", "sessions as .pier, ports mirrored to localhost (macOS)"}, + {"pier port ", "forward ports by hand (8080:3000 = local:session)"}, + {"pier mcp login [server]", "one-time browser approval for OAuth MCP servers"}, }, }, { - title: "Setup & maintenance", + title: "Setup", items: []helpItem{ - {"pier setup", "run the first-time cloud setup"}, - {"pier setup --print-admin", "print setup commands for a cloud admin"}, - {"pier skills", "install or refresh the bundled agent skills"}, + {"pier setup", "first-time setup (re-run any time)"}, + {" --print-admin", "print the account setup for a cloud admin to run"}, + {" --skills", "only install or refresh the bundled agent skills"}, {"pier doctor", "check the environment and cloud account"}, - {"pier bake", "prebake this repo's session image"}, - {"pier teardown", "remove all pier groundwork from the account"}, - }, - }, - { - title: "General", - items: []helpItem{ + {"pier teardown", "remove all pier groundwork and images from the account"}, {"pier version", "print the pier version"}, - {"pier help", "show this help"}, }, }, } @@ -147,16 +142,18 @@ func main() { cmdKeep(args[1:]) case "resize": cmdResize(args[1:]) - case "pool": - cmdPool(args[1:]) + case "repos": + cmdRepos(args[1:]) + case "bake": + cmdBake(args[1:]) + case "ready": + cmdReady(args[1:]) + case pier.RefillCommand: + cmdRefill() case "setup": cmdSetup(args[1:]) - case "skills": - cmdSkills() case "doctor": cmdDoctor() - case "bake": - cmdBake() case "teardown": cmdTeardown() case "version", "-v", "--version": @@ -169,10 +166,10 @@ func main() { } func printUsage() { - fmt.Println("\n " + ui.Title.Render("\u2693 pier") + - ui.Dim.Render(" \u2014 coding agent sessions as park-when-idle micro-VMs on your own cloud") + "\n") + fmt.Println("\n " + ui.Title.Render("⚓ pier") + + ui.Dim.Render(" — coding agent sessions as park-when-idle VMs on your own cloud") + "\n") command := fmt.Sprintf("%-35s", "pier") - fmt.Printf(" %s %s\n", ui.Accent.Render(command), "open the interactive session list") + fmt.Printf(" %s %s\n", ui.Accent.Render(command), "open the app: sessions, repos, settings") for _, section := range helpSections { fmt.Println() fmt.Println(" " + ui.Bold.Render(section.title)) @@ -181,6 +178,7 @@ func printUsage() { fmt.Printf(" %s %s\n", ui.Accent.Render(command), item.description) } } + fmt.Println() } func fatal(err error) { @@ -188,69 +186,38 @@ func fatal(err error) { os.Exit(1) } -func newDriver(cfg config.Config) (driver.Driver, error) { - switch cfg.Driver { - case "", "aws-ec2": - return &awsec2.Driver{ - Profile: cfg.AWS.Profile, - Region: cfg.AWS.Region, - InstanceType: cfg.AWS.InstanceType, - DiskGiB: cfg.AWS.DiskGiB, - Subnet: cfg.AWS.Subnet, - Direct: cfg.AWS.Direct, - StateDir: config.Dir(), - Manifest: cfg.Secrets.Manifest, - SessionEnv: sessionEnv(cfg), - SupervisorBin: supervisorBin, - }, nil - case "gcp-gce": - // An empty project would fall through to the operator's active gcloud - // config — pier must never create resources in whatever project - // happens to be active. - if cfg.GCP.Project == "" { - return nil, fmt.Errorf("gcp.project is not set — run `pier setup`") - } - return &gcpgce.Driver{ - Project: cfg.GCP.Project, - Zone: cfg.GCP.Zone, - MachineType: cfg.GCP.MachineType, - DiskGiB: cfg.GCP.DiskGiB, - StateDir: config.Dir(), - Manifest: cfg.Secrets.Manifest, - SessionEnv: sessionEnv(cfg), - SupervisorBin: supervisorBin, - }, nil - default: - return nil, fmt.Errorf("unknown driver %q", cfg.Driver) +func open() *pier.Client { + c, err := pier.Open(options()) + if err != nil { + fatal(err) } + return c } -// sessionEnv builds ~/.config/pier/env for new sessions: a GitHub credential -// from wherever the laptop already has one (gh login or git's credential -// helper), Claude token from config (macOS keychain escape hatch). -func sessionEnv(cfg config.Config) map[string]string { - env := map[string]string{} - if t := cfg.Secrets.ClaudeOAuthToken; t != "" { - env["CLAUDE_CODE_OAUTH_TOKEN"] = t - } - if t := payload.GitHubToken(); t != "" { - env["GH_TOKEN"] = t - } - return env -} - -func loadDriver() (config.Config, driver.Driver) { - cfg, err := config.Load() +func repoRoot() string { + root, err := pier.RepoRoot("") if err != nil { fatal(err) } - drv, err := newDriver(cfg) - if err != nil { - fatal(err) + return root +} + +// progress renders API events as the CLI's step lines, each with the time +// since the operation began. +func progress(e pier.Event) { + elapsed := ui.Dim.Render(fmt.Sprintf(" +%s", e.Elapsed.Round(time.Second))) + switch e.Kind { + case pier.EventWarn: + fmt.Println(ui.Warn.Render(" !") + " " + e.Message + elapsed) + case pier.EventNote: + fmt.Println(ui.Dim.Render(" · "+e.Message) + elapsed) + default: + fmt.Println(ui.Step(e.Message) + elapsed) } - return cfg, drv } +func stepLine(msg string) { fmt.Println(ui.Step(msg)) } + // --- new session --------------------------------------------------------------- func cmdNew(args []string) { @@ -271,11 +238,11 @@ func cmdNew(args []string) { } } fs := flag.NewFlagSet("new", flag.ExitOnError) - var detach, noPark, noPool bool + var detach, noPark, noReady bool fs.BoolVar(&detach, "d", false, "") fs.BoolVar(&detach, "detach", false, "") fs.BoolVar(&noPark, "no-park", false, "") - fs.BoolVar(&noPool, "no-pool", false, "") + fs.BoolVar(&noReady, "no-ready", false, "") idleS := fs.String("idle", "", "") capS := fs.String("cap", "", "") fs.Parse(flagArgs) @@ -283,216 +250,150 @@ func cmdNew(args []string) { printUsage() os.Exit(1) } - branch := pos[0] - base := "HEAD" + req := pier.CreateRequest{Branch: pos[0], NoReady: noReady, Progress: progress} if len(pos) == 2 { - base = pos[1] - } - - started := time.Now() - cfg, drv := loadDriver() - repo := repoRoot() - // ctrl-c mid-create must cancel the ctx (not just kill the process) so - // Create's deferred cleanup can terminate the half-made instance. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - sessions, err := drv.List(ctx) - if err != nil { - fatal(err) - } - for _, s := range sessions { - if s.PoolGen != "" { - continue // unclaimed pool members hold placeholder names, not sessions' - } - if s.Name == branch { - fatal(fmt.Errorf("session %q already exists — `pier attach %s`", branch, branch)) - } - } - - idle, err := config.ParkDuration(or(*idleS, cfg.IdleTimeout)) - if err != nil { - fatal(fmt.Errorf("--idle: %w", err)) - } - if noPark { - idle = 0 - } - cap_, err := config.ParkDuration(or(*capS, cfg.UnattendedCap)) - if err != nil { - fatal(fmt.Errorf("--cap: %w", err)) + req.Base = pos[1] } - - // Images are repo-specific; a repo that never baked launches from stock - // (guarded cloud-init installs live). - image := cfg.BakedImage(filepath.Base(repo)) - // A repo with a bake hook has declared that stock isn't enough for it — - // its toolchain lives in .pier/bake.sh and nowhere else. Launching stock - // anyway is legal but almost never what was wanted: the create succeeds, - // then .pier/setup.sh dies minutes later on a missing toolchain, and the - // two events look unrelated. Say it here, while it's still one sentence. - if image == "" && driver.BakeHook(repo) != "" { - fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" no baked image for "+filepath.Base(repo)+ - " — launching from stock, so .pier/bake.sh toolchains will be missing and .pier/setup.sh may fail")) - fmt.Println(ui.Dim.Render(" fix: `pier bake` in this repo (~5 min once)")) - } - fmt.Printf("%s %s\n", ui.Bold.Render("creating "+branch), - ui.Dim.Render(fmt.Sprintf("(%s @ %s)", filepath.Base(repo), base))) - - // A configured pool turns the create into claim-a-warm-member (resume + - // freshen, setup already done); an empty or lost pool falls through to the - // full create below. - poolable := cfg.PoolSize(filepath.Base(repo)) > 0 && !noPool - var sess *driver.Session - if poolable { - pp, err := poolParams(cfg, drv, repo) + if *idleS != "" || noPark { + d, err := config.ParkDuration(*idleS) if err != nil { - fatal(err) + fatal(fmt.Errorf("--idle: %w", err)) } - // This create's --idle/--cap land on the claimed member's supervisor. - pp.IdleTimeout, pp.UnattendedCap = idle, cap_ - if sess, err = pool.Claim(ctx, pp, sessions, branch, branch, base); err != nil { - // The pool accelerates, never gates: whatever broke the claim - // (bad name, throttled API), the create below gives the same - // answer or a session. - fmt.Println(ui.Warn.Render("!") + " claim failed: " + err.Error()) - sess = nil - } - if sess == nil { - fmt.Println(ui.Step("no warm member to claim — creating fresh")) + if noPark { + d = 0 } + req.Idle = &d } - if sess == nil { - sess, err = drv.Create(ctx, driver.CreateSpec{ - Name: branch, Repo: repo, Branch: branch, BaseRef: base, Image: image, - IdleTimeout: idle, UnattendedCap: cap_, - Progress: ui.TimedSteps(started), - }) + if *capS != "" { + d, err := config.ParkDuration(*capS) if err != nil { - // The instance is already gone (Create destroys its own wreckage), so - // leave a tombstone: without one this create vanishes without trace, - // and a detached create has no terminal to have shown the error in. - buryCreate(cfg, branch, repo, err) - fatal(err) + fatal(fmt.Errorf("--cap: %w", err)) } + req.Cap = &d } - if poolable { - // Top the pool back up in the background — after a claim (a member was - // consumed) and after a fallback create (the pool was short) alike. - if logPath, err := spawnPoolFill(repo); err != nil { - fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" pool refill didn't start: "+err.Error())) - } else { - fmt.Println(ui.Dim.Render("pool refilling in the background — log: " + ui.Tilde(logPath))) - } + + c := open() + req.RepoRoot = repoRoot() + base := req.Base + if base == "" { + base = "HEAD" + } + fmt.Printf("%s %s\n", ui.Bold.Render("creating "+req.Branch), + ui.Dim.Render(fmt.Sprintf("(%s @ %s)", filepath.Base(req.RepoRoot), base))) + // ctrl-c mid-create must cancel the ctx (not just kill the process) so + // the create's cleanup can terminate the half-made instance. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + res, err := c.Create(ctx, req) + if err != nil { + fatal(err) } stop() // create done — ctrl-c back to its default for the prompt + attach + switch { + case res.RefillErr != nil: + fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" ready-session refill didn't start: "+res.RefillErr.Error())) + case res.RefillLog != "": + fmt.Println(ui.Dim.Render("refilling ready sessions in the background — log: " + ui.Tilde(res.RefillLog))) + } + sess := res.Session fmt.Println(ui.OK.Render("session " + sess.Name + " ready")) + showReminders(c, req.RepoRoot) if detach { fmt.Println(ui.Dim.Render("attach with: pier attach " + sess.Name)) return } // OAuth-backed MCPs need one browser approval each (tokens can't be // copied — they rotate); offer the sweep now, while a human is present. - home, _ := os.UserHomeDir() - if names := payload.OAuthRemotes(home, repo); len(names) > 0 && stdinIsTTY() { + if names := pier.OAuthMCPServers(req.RepoRoot); len(names) > 0 && stdinIsTTY() { if confirm(fmt.Sprintf("mcp %s: run the one-time browser logins now?", strings.Join(names, ", ")), true) { - loginAll(drv, *sess) + loginAll(c, sess) } else { fmt.Println(ui.Dim.Render("later: pier mcp login " + sess.Name)) } } - attach(drv, sess.ID) + attach(c, sess) } -func stdinIsTTY() bool { - fi, err := os.Stdin.Stat() - return err == nil && fi.Mode()&os.ModeCharDevice != 0 +// showReminders prints rebake guidance after a create — at most once a day +// per repo, so it informs without nagging. The Repos tab always shows it. +func showReminders(c *pier.Client, repoRoot string) { + rs := c.Reminders(repoRoot) + if len(rs) == 0 || !remindDue(filepath.Base(repoRoot)) { + return + } + for _, r := range rs { + fmt.Println(ui.Accent.Render(" tip") + " " + r.Message + ui.Dim.Render(" `"+r.Action+"`")) + } } -func or(a, b string) string { - if a != "" { - return a +// remindDue reports whether repo's reminders haven't been shown in the last +// day, and records that they're being shown now. +func remindDue(repo string) bool { + path := filepath.Join(config.Dir(), "reminded.json") + seen := map[string]time.Time{} + if b, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(b, &seen) + } + if time.Since(seen[repo]) < 24*time.Hour { + return false } - return b + seen[repo] = time.Now() + if b, err := json.Marshal(seen); err == nil { + _ = os.WriteFile(path, b, 0o600) + } + return true } -func repoRoot() string { - out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() - if err != nil { - fatal(fmt.Errorf("not inside a git repository")) - } - return strings.TrimSpace(string(out)) +func stdinIsTTY() bool { + fi, err := os.Stdin.Stat() + return err == nil && fi.Mode()&os.ModeCharDevice != 0 } -// attach runs the interactive ssh+tmux for the CLI paths (pier attach, -// pier ); the TUI has its own tea.ExecProcess flow wired through -// tui.Options so a detach lands back on the list. A fresh or just-resumed VM -// reports cloud-running before its transport answers (EC2's SSM agent takes -// ~30s to register; GCE's IAP tunnel has the same window), so an ssh attempt -// that dies instantly gets one bounded wait-for-reachability and a retry -// instead of a raw transport error dump. -func attach(drv driver.Driver, id string) { - fmt.Println(ui.Dim.Render("attaching — detach with C-b d (session keeps running)")) +// attach runs the interactive ssh+tmux in the foreground. A fresh or +// just-resumed VM reports cloud-running before its transport answers, so an +// ssh attempt that dies instantly gets one bounded wait-for-reachability and +// a retry instead of a raw transport error dump. +func attach(c *pier.Client, s pier.Session) { + fmt.Println(ui.Dim.Render("attaching — detach with C-b d (the session keeps running)")) retried := false for { - cmd, err := drv.AttachCommand(context.Background(), id) + cmd, err := c.AttachCommand(context.Background(), s) if err != nil { fatal(err) } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr start := time.Now() runErr := cmd.Run() if runErr == nil { return } - // ssh uses 255 for a transport failure. Other quick failures came from - // the remote command (for example tmux rejecting TERM), so waiting for - // reachability would only hide the real error and run it a second time. - if retried || !retryAttach(runErr, time.Since(start)) { + if retried || !pier.RetryAttach(runErr, time.Since(start)) { fmt.Fprintln(os.Stderr, ui.Bad.Render("pier:"), "attach:", runErr) return } retried = true fmt.Println(ui.Dim.Render("not reachable yet — waiting for the session to come online (fresh VMs take ~30-60s)")) - if err := waitReachable(drv, id, 4*time.Minute); err != nil { + if err := c.WaitReachable(context.Background(), s, 4*time.Minute); err != nil { fatal(err) } } } -func retryAttach(err error, elapsed time.Duration) bool { - var exitErr *exec.ExitError - return elapsed <= 15*time.Second && errors.As(err, &exitErr) && exitErr.ExitCode() == 255 -} - -// waitReachable polls a no-op exec until the driver's ssh transport answers. -func waitReachable(drv driver.Driver, id string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - _, err := drv.Exec(ctx, id, "true") - cancel() - if err == nil { - return nil - } - time.Sleep(3 * time.Second) - } - return fmt.Errorf("session not reachable after %s — `pier ls` to check on it", timeout) -} - -// --- ls / attach / rm / keep ----------------------------------------------------- +// --- sessions --------------------------------------------------------------------- type sessionJSON struct { - ID string `json:"id"` - Name string `json:"name"` - Repository string `json:"repository"` - Branch string `json:"branch"` - Owner string `json:"owner"` - Provider string `json:"provider"` - State driver.State `json:"state"` - SetupState string `json:"setup_state"` - Strained bool `json:"strained"` - CreatedAt *time.Time `json:"created_at"` - MachineType string `json:"machine_type"` - CostNote string `json:"cost_note"` + ID string `json:"id"` + Name string `json:"name"` + Repository string `json:"repository"` + Branch string `json:"branch"` + Owner string `json:"owner"` + Provider string `json:"provider"` + State pier.State `json:"state"` + SetupState string `json:"setup_state"` + Strained bool `json:"strained"` + CreatedAt *time.Time `json:"created_at"` + MachineType string `json:"machine_type"` + CostNote string `json:"cost_note"` } func parseLSArgs(args []string) (bool, error) { @@ -508,7 +409,7 @@ func parseLSArgs(args []string) (bool, error) { return *jsonOutput, nil } -func writeSessionsJSON(w io.Writer, sessions []driver.Session) error { +func writeSessionsJSON(w io.Writer, sessions []pier.Session) error { items := make([]sessionJSON, 0, len(sessions)) for _, s := range sessions { var createdAt *time.Time @@ -531,25 +432,17 @@ func cmdLS(args []string) { if err != nil { fatal(err) } - _, drv := loadDriver() - all, err := listSessions(drv) + c := open() + all, err := c.Sessions(context.Background()) if err != nil { fatal(err) } - // Warm pool members are inventory, not sessions: kept out of the JSON + // Ready sessions are inventory, not sessions: kept out of the JSON // entirely, and one dim summary line below the table instead of rows. - var sessions []driver.Session - members := 0 - for _, s := range all { - if s.PoolGen != "" { - members++ - continue - } - sessions = append(sessions, s) - } - poolLine := "" - if members > 0 { - poolLine = ui.Dim.Render(fmt.Sprintf("+ %d warm pool member(s) — `pier pool`", members)) + sessions, ready := pier.SplitReady(all) + readyLine := "" + if len(ready) > 0 { + readyLine = ui.Dim.Render(fmt.Sprintf("+ %d ready session(s) waiting — `pier repos`", len(ready))) } if jsonOutput { if err := writeSessionsJSON(os.Stdout, sessions); err != nil { @@ -559,28 +452,29 @@ func cmdLS(args []string) { } if len(sessions) == 0 { fmt.Println(ui.Dim.Render("no sessions — start one with `pier `")) - if poolLine != "" { - fmt.Println(poolLine) + if readyLine != "" { + fmt.Println(readyLine) } return } w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) fmt.Fprintln(w, "NAME\tREPO\tSTATE\tAGE\tCOST") anyStrained, anySetupFailed, anyFailedCreate := false, false, false + now := time.Now() for _, s := range sessions { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, s.Repo, stateLabel(s), age(s.Created), s.CostNote) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, s.Repo, stateLabel(s), pier.Age(s.Created, now), s.CostNote) anyStrained = anyStrained || s.Strained anySetupFailed = anySetupFailed || s.Setup == "failed" - anyFailedCreate = anyFailedCreate || s.State == driver.StateFailed + anyFailedCreate = anyFailedCreate || s.State == pier.StateFailed } w.Flush() - if poolLine != "" { - fmt.Println(poolLine) + if readyLine != "" { + fmt.Println(readyLine) } if anyFailedCreate { fmt.Println("\n" + ui.Bad.Render("✗") + ui.Dim.Render(" create failed = the instance was rolled back, nothing is running — `pier rm ` clears the row")) for _, s := range sessions { - if s.State != driver.StateFailed || s.FailReason == "" { + if s.State != pier.StateFailed || s.FailReason == "" { continue } fmt.Println(ui.Dim.Render(" " + s.Name + ": " + tombstone.Summarize(s.FailReason))) @@ -595,8 +489,8 @@ func cmdLS(args []string) { } // stateLabel renders the state plus the supervisor's strain and setup flags. -func stateLabel(s driver.Session) string { - if s.State == driver.StateFailed { +func stateLabel(s pier.Session) string { + if s.State == pier.StateFailed { // "create failed", never a bare "failed": the neighbouring rows say // "setup failed" for a live session whose setup script died, and the // two mean very different things — one has a VM, this one does not. @@ -615,123 +509,43 @@ func stateLabel(s driver.Session) string { return l } -// enrich upgrades StateRunning to working/idle by reading each running -// session's supervisor beacon in parallel. -func enrich(drv driver.Driver, sessions []driver.Session) { - var wg sync.WaitGroup - for i := range sessions { - // Pool members run headless between create and park; their beacon - // detail is nobody's business until they're claimed. - if sessions[i].State != driver.StateRunning || sessions[i].PoolGen != "" { - continue - } - wg.Add(1) - go func(s *driver.Session) { - defer wg.Done() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - out, err := drv.Exec(ctx, s.ID, "cat /run/pier/status.json 2>/dev/null || echo absent") - if err != nil { - return // unreachable (ssm blip) — keep plain "running" - } - if strings.TrimSpace(out) == "absent" { - // Only ready-tagged sessions get here, so no beacon isn't - // mid-create anymore: /run is tmpfs, so right after a resume - // the supervisor hasn't written its first beacon yet. Keep - // plain "running". - return - } - var st struct { - State string `json:"state"` - Bootstrapping bool `json:"bootstrapping"` - Strained bool `json:"strained"` - Setup string `json:"setup"` - } - if json.Unmarshal([]byte(out), &st) != nil { - return - } - // Supervisor up but bootstrap not done: the repo is still on its - // way (or the create died) — either way, not attachable yet. - if st.Bootstrapping { - s.State = driver.StateCreating - return - } - switch st.State { - case "working": - s.State = driver.StateWorking - case "idle": - s.State = driver.StateIdle - } - s.Strained = st.Strained - s.Setup = st.Setup - }(&sessions[i]) +func match(c *pier.Client, query string) pier.Session { + s, err := c.Match(context.Background(), query) + if err != nil { + fatal(err) } - wg.Wait() + return s } -func age(t time.Time) string { - if t.IsZero() { - return "-" - } - d := time.Since(t) - switch { - case d < time.Minute: - return "now" - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) +func requireReady(s pier.Session) { + if err := pier.CheckReady(s); err != nil { + fatal(err) } } -func match(drv driver.Driver, query string) driver.Session { - sessions, err := listSessions(drv) - if err != nil { - fatal(err) - } - var hits []driver.Session - for _, s := range sessions { - if s.PoolGen != "" { - continue // unclaimed pool members are managed via `pier pool`, not by name - } - if s.Name == query { - return s - } - if strings.Contains(s.Name, query) { - hits = append(hits, s) - } - } - switch len(hits) { - case 1: - return hits[0] - case 0: - fatal(fmt.Errorf("no session matching %q", query)) - default: - var names []string - for _, h := range hits { - names = append(names, h.Name) +func resumeIfParked(c *pier.Client, s pier.Session) { + if s.State == pier.StateParked { + fmt.Println(ui.Dim.Render("resuming " + s.Name + " (~20-60s)...")) + if err := c.Resume(context.Background(), s); err != nil { + fatal(err) } - fatal(fmt.Errorf("%q is ambiguous: %s", query, strings.Join(names, ", "))) } - panic("unreachable") } func cmdAttach(args []string) { if len(args) != 1 { fatal(fmt.Errorf("usage: pier attach ")) } - _, drv := loadDriver() - s := match(drv, args[0]) + c := open() + s := match(c, args[0]) requireReady(s) - resumeIfParked(drv, s) - attach(drv, s.ID) + resumeIfParked(c, s) + attach(c, s) } -// cmdLogs prints the session's setup script log without an attach — the -// first question after "(setup failed)" is always "what broke". -f follows -// a still-running setup live. +// cmdLogs prints the session's setup log without an attach — the first +// question after "(setup failed)" is always "what broke". -f follows a +// still-running setup live. func cmdLogs(args []string) { follow := false var rest []string @@ -745,120 +559,64 @@ func cmdLogs(args []string) { if len(rest) != 1 { fatal(fmt.Errorf("usage: pier logs [-f]")) } - _, drv := loadDriver() - showLogs(drv, match(drv, rest[0]), follow) -} - -// showLogs streams the raw log for `pier logs` — pipeable, and the terminal -// interprets the progress-meter escapes natively. The TUI's l key renders a -// sanitized in-place view instead. -func showLogs(drv driver.Driver, s driver.Session, follow bool) { - // A failed create has no VM and no setup log, but it does have the create - // log — which is the log the user is asking for. Serve that instead of - // refusing: "what broke" is the same question either way. - if s.State == driver.StateFailed { - if s.LogPath == "" { - fmt.Println(s.FailReason) - return - } - b, err := os.ReadFile(s.LogPath) - if err != nil { - fmt.Println(s.FailReason) - return + c := open() + s := match(c, rest[0]) + if s.State != pier.StateFailed { + requireReady(s) + if s.State == pier.StateParked { + resumeIfParked(c, s) + if err := c.WaitReachable(context.Background(), s, 4*time.Minute); err != nil { + fatal(err) + } } - os.Stdout.Write(b) - return } - requireReady(s) - if s.State == driver.StateParked { - resumeIfParked(drv, s) - if err := waitReachable(drv, s.ID, 4*time.Minute); err != nil { + if follow && s.State != pier.StateFailed { + cmd, err := c.FollowLogCommand(context.Background(), s) + if err != nil { fatal(err) } + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + _ = cmd.Run() // a followed log ends with ctrl-c; that interrupt is not an error + return } - remote := `[ -f ~/.pier-setup.log ] && cat ~/.pier-setup.log || echo "no setup log — this session ran no setup script"` - if follow { - remote = `[ -f ~/.pier-setup.log ] && exec tail -n +1 -f ~/.pier-setup.log || echo "no setup log — this session ran no setup script"` - } - opts, dest, err := drv.SSHTarget(context.Background(), s.ID) + out, err := c.SetupLog(context.Background(), s, 0) if err != nil { - fatal(err) - } - cmd := exec.Command("ssh", append(opts, dest, remote)...) - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr - // A followed log ends with ctrl-c; that interrupt is not an error. - if err := cmd.Run(); err != nil && !follow { fatal(fmt.Errorf("logs: %w", err)) } + fmt.Println(out) } -// requireReady refuses commands against a still-creating session — cleanly, -// before any ssh is spawned, so the user never sees a raw transport error -// from the window between cloud-running and actually-attachable. -func requireReady(s driver.Session) { - if s.State == driver.StateFailed { - hint := "`pier " + s.Name + "` retries it" - if s.LogPath != "" { - hint += ", " + s.LogPath + " has the create log" - } - fatal(fmt.Errorf("%s never finished creating (%s) — nothing is running; %s", - s.Name, tombstone.Summarize(s.FailReason), hint)) - } - if s.State == driver.StateCreating { - fatal(fmt.Errorf("%s is still setting up — try again when `pier ls` shows it running", s.Name)) - } - if s.State == driver.StateDeleting { - fatal(fmt.Errorf("%s is being deleted", s.Name)) - } -} - -var mcpServerName = regexp.MustCompile(`^[A-Za-z0-9._:@-]+$`) - // cmdMCP: `pier mcp login [server]` — one-time OAuth for MCP // servers whose tokens live in the laptop's keychain and can't be copied // (they rotate; two machines sharing one revoke each other). The callback -// port rides the existing SSM tunnel, so the browser approval on the laptop -// completes the flow inside the VM: click, approve, done — no URL copying. -// Without a server name it sweeps everything still unauthenticated. +// port rides the session's tunnel, so the browser approval on the laptop +// completes the flow inside the VM. Without a server name it sweeps +// everything still unauthenticated. func cmdMCP(args []string) { if len(args) < 2 || args[0] != "login" { fatal(fmt.Errorf("usage: pier mcp login [server]")) } - _, drv := loadDriver() - s := match(drv, args[1]) + c := open() + s := match(c, args[1]) requireReady(s) - resumeIfParked(drv, s) - + resumeIfParked(c, s) if len(args) == 2 { - loginAll(drv, s) + loginAll(c, s) return } - server := args[2] - if !mcpServerName.MatchString(server) { - fatal(fmt.Errorf("implausible server name %q", server)) - } - if err := loginOne(drv, s, server); err != nil { + if err := loginOne(c, s, args[2]); err != nil { fatal(fmt.Errorf("mcp login: %w", err)) } } -// loginAll asks the session which OAuth-backed MCP servers still lack a -// token (seeded ~/.claude.json minus ~/.claude/.credentials.json) and runs -// the browser flow for each, sequentially — one command, N approvals, and -// re-running it is free: already-authenticated servers are skipped. -func loginAll(drv driver.Driver, s driver.Session) { - out, err := drv.Exec(context.Background(), s.ID, - "cat ~/.claude.json 2>/dev/null; printf '\\n---PIER-SPLIT---\\n'; cat ~/.claude/.credentials.json 2>/dev/null") +// loginAll runs the browser flow for every OAuth-backed MCP server still +// lacking a token, sequentially — one command, N approvals, and re-running +// it is free: already-authenticated servers are skipped. +func loginAll(c *pier.Client, s pier.Session) { + pending, err := c.PendingMCP(context.Background(), s) if err != nil { fatal(err) } - cfgRaw, credRaw, _ := strings.Cut(out, "---PIER-SPLIT---") - var pending []string - for _, n := range payload.OAuthRemoteNames([]byte(cfgRaw), payload.Workspace+"/"+s.Repo) { - if !mcpAuthed(credRaw, n) { - pending = append(pending, n) - } - } if len(pending) == 0 { fmt.Println(ui.OK.Render("✓") + " every MCP server in " + s.Name + " is authenticated") return @@ -867,128 +625,71 @@ func loginAll(drv driver.Driver, s driver.Session) { strings.Join(pending, ", ")) for i, server := range pending { fmt.Printf("\n%s %s\n", ui.Accent.Render(fmt.Sprintf("[%d/%d]", i+1, len(pending))), ui.Bold.Render(server)) - if err := loginOne(drv, s, server); err != nil { + if err := loginOne(c, s, server); err != nil { fmt.Fprintln(os.Stderr, ui.Warn.Render(" ! "+server+" didn't finish — retry later with `pier mcp login "+s.Name+" "+server+"`")) } } } -func loginOne(drv driver.Driver, s driver.Session, server string) error { - port, err := freePort() - if err != nil { - return err - } - fmt.Println(ui.Dim.Render(" open the URL below and approve — the callback tunnels into the session; the token survives park/resume")) - cmd, err := drv.MCPLoginCommand(context.Background(), s.ID, server, port) +func loginOne(c *pier.Client, s pier.Session, server string) error { + fmt.Println(ui.Dim.Render(" open the URL below and approve — the callback tunnels into the session; the token survives parking")) + cmd, err := c.MCPLoginCommand(context.Background(), s, server) if err != nil { return err } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr return cmd.Run() } -// mcpAuthed checks the session's credential store for a token under this -// server. Claude keys mcpOAuth by server name (possibly suffixed); an -// unrecognized format fails open — worst case one redundant approval. -func mcpAuthed(credJSON, server string) bool { - var c struct { - McpOAuth map[string]json.RawMessage `json:"mcpOAuth"` - } - if json.Unmarshal([]byte(credJSON), &c) != nil { - return false - } - for k := range c.McpOAuth { - if k == server || strings.HasPrefix(k, server+"|") || strings.HasPrefix(k, server+":") { - return true - } - } - return false -} - // cmdProxy: `pier proxy` — every running session gets a hostname // (.pier) with its listening ports mirrored automatically; live -// connections keep the session from parking. All the machinery lives in -// internal/proxy; runs in the foreground until ctrl-c. +// connections keep the session from parking. Runs in the foreground until +// ctrl-c. func cmdProxy() { // net/http logs transport chatter ("Unsolicited response received...") // through the global logger when probed ports answer oddly. All of the // proxy's real output goes through Options.Out, so drop the rest. log.SetOutput(io.Discard) - _, drv := loadDriver() + c := open() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := proxy.Run(ctx, drv, proxy.Options{StateDir: config.Dir(), Out: os.Stdout}); err != nil { + if err := proxy.Run(ctx, c.Driver(), proxy.Options{StateDir: config.Dir(), Out: os.Stdout}); err != nil { fatal(err) } } // cmdPort: `pier port [port...]` — hold ssh -L forwards open -// by hand. The zero-sudo, works-anywhere fallback to `pier proxy`; "8080:3000" -// maps local:session. +// by hand. The zero-sudo, works-anywhere fallback to `pier proxy`. func cmdPort(args []string) { if len(args) < 2 { fatal(fmt.Errorf("usage: pier port [port...] (3000, or local:session like 8080:3000)")) } - _, drv := loadDriver() - s := match(drv, args[0]) + c := open() + s := match(c, args[0]) var pairs [][2]int for _, a := range args[1:] { - p, err := parsePortPair(a) + p, err := pier.ParsePortPair(a) if err != nil { fatal(err) } pairs = append(pairs, p) } - resumeIfParked(drv, s) + resumeIfParked(c, s) for _, p := range pairs { fmt.Printf(" %s -> %s\n", ui.Bold.Render(fmt.Sprintf("localhost:%d", p[0])), ui.Dim.Render(fmt.Sprintf("%s:%d", s.Name, p[1]))) } fmt.Println(ui.Dim.Render(" forwarding — ctrl-c to stop")) - cmd, err := drv.PortForwardCommand(context.Background(), s.ID, pairs) + cmd, err := c.PortForwardCommand(context.Background(), s, pairs) if err != nil { fatal(err) } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr if err := cmd.Run(); err != nil { fatal(fmt.Errorf("port forward: %w", err)) } } -func parsePortPair(s string) ([2]int, error) { - local, remote, split := strings.Cut(s, ":") - if !split { - remote = local - } - var p [2]int - for i, v := range []string{local, remote} { - n, err := strconv.Atoi(v) - if err != nil || n < 1 || n > 65535 { - return p, fmt.Errorf("bad port %q — want 3000 or local:session like 8080:3000", s) - } - p[i] = n - } - return p, nil -} - -// freePort grabs an OS-assigned port. Local and remote must match: the OAuth -// redirect URL embeds the one port claude registers inside the VM. -func freePort() (int, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - defer l.Close() - return l.Addr().(*net.TCPAddr).Port, nil -} - -func resumeIfParked(drv driver.Driver, s driver.Session) { - if s.State == driver.StateParked { - fmt.Println(ui.Dim.Render("resuming " + s.Name + " (~20-60s)...")) - if err := drv.Resume(context.Background(), s.ID); err != nil { - fatal(err) - } - } -} - func cmdRM(args []string) { force := false var names []string @@ -1002,22 +703,18 @@ func cmdRM(args []string) { if len(names) != 1 { fatal(fmt.Errorf("usage: pier rm [-f]")) } - _, drv := loadDriver() - s := match(drv, names[0]) - // A tombstone has no instance and no disk — rm just forgets the record. - if s.State == driver.StateFailed { - if err := tombstone.Dismiss(config.Dir(), s.Name); err != nil { - fatal(err) - } - fmt.Println(ui.OK.Render("cleared failed create " + s.Name)) - return - } - if !force && !confirm(fmt.Sprintf("destroy session %q and its disk?", s.Name), false) { + c := open() + s := match(c, names[0]) + if s.State != pier.StateFailed && !force && !confirm(fmt.Sprintf("destroy session %q and its disk?", s.Name), false) { return } - if err := drv.Destroy(context.Background(), s.ID); err != nil { + if err := c.Remove(context.Background(), s); err != nil { fatal(err) } + if s.State == pier.StateFailed { + fmt.Println(ui.OK.Render("cleared failed create " + s.Name)) + return + } fmt.Println(ui.OK.Render("destroyed " + s.Name)) } @@ -1025,306 +722,244 @@ func cmdKeep(args []string) { if len(args) != 1 { fatal(fmt.Errorf("usage: pier keep ")) } - _, drv := loadDriver() - s := match(drv, args[0]) - if err := pin(drv, s); err != nil { + c := open() + s := match(c, args[0]) + if err := c.Keep(context.Background(), s); err != nil { fatal(err) } - fmt.Println(ui.OK.Render(s.Name+" pinned") + ui.Dim.Render(" — no idle self-park (unattended cap still applies)")) -} - -// pin disables idle self-park; the supervisor re-reads its conf every tick, -// so this applies live. -func pin(drv driver.Driver, s driver.Session) error { - if s.State == driver.StateParked { - return fmt.Errorf("%s is parked — `pier attach %s` first", s.Name, s.Name) - } - _, err := drv.Exec(context.Background(), s.ID, - "sudo sed -i 's/^idle_timeout=.*/idle_timeout=never/' /etc/pier/supervisor.conf") - return err + fmt.Println(ui.OK.Render(s.Name+" pinned") + ui.Dim.Render(" — never parks when idle (the runaway cap still applies)")) } func cmdResize(args []string) { if len(args) != 2 { - fatal(fmt.Errorf("usage: pier resize ")) + fatal(fmt.Errorf("usage: pier resize ")) } - _, drv := loadDriver() - s := match(drv, args[0]) - requireReady(s) // resizing mid-create would stop the instance under its bootstrap + c := open() + s := match(c, args[0]) + requireReady(s) itype := args[1] - if s.State == driver.StateParked { + if s.State == pier.StateParked { fmt.Printf("%s %s\n", ui.Bold.Render("resizing "+s.Name+" to "+itype), ui.Dim.Render("(parked — stays parked)")) } else { fmt.Printf("%s %s\n", ui.Bold.Render("resizing "+s.Name+" to "+itype), ui.Dim.Render("(parks, resizes, resumes ~1-2 min)")) } - if err := drv.Resize(context.Background(), s.ID, itype); err != nil { + if err := c.Resize(context.Background(), s, itype); err != nil { fatal(err) } fmt.Println(ui.OK.Render(s.Name + " is now a " + itype)) } -// --- warm pools -------------------------------------------------------------------- +// --- repos ------------------------------------------------------------------------ -// poolParams assembles everything internal/pool needs from config + driver, -// including the generation fingerprint — which is why it must run from inside -// the repo (the local setup script feeds the hash). -func poolParams(cfg config.Config, drv driver.Driver, repoRoot string) (pool.Params, error) { - maxAge, err := cfg.PoolMaxAge() - if err != nil { - return pool.Params{}, err - } - idle, err := config.ParkDuration(cfg.IdleTimeout) - if err != nil { - return pool.Params{}, err - } - cap_, err := config.ParkDuration(cfg.UnattendedCap) - if err != nil { - return pool.Params{}, err - } - itype, disk := cfg.AWS.InstanceType, cfg.AWS.DiskGiB - if cfg.Driver == "gcp-gce" { - itype, disk = cfg.GCP.MachineType, cfg.GCP.DiskGiB - } - repo := filepath.Base(repoRoot) - image := cfg.BakedImage(repo) - return pool.Params{ - Driver: drv, - RepoRoot: repoRoot, - Gen: pool.Generation(drv.Name(), image, itype, disk, pool.SetupScript(repoRoot)), - Size: cfg.PoolSize(repo), - MaxAge: maxAge, - Image: image, - IdleTimeout: idle, UnattendedCap: cap_, - Manifest: cfg.Secrets.Manifest, SessionEnv: sessionEnv(cfg), - Progress: ui.TimedSteps(time.Now()), - }, nil +type repoJSON struct { + Name string `json:"name"` + Image string `json:"image"` + BakedAt *time.Time `json:"baked_at"` + ReadyTarget int `json:"ready_target"` + Ready int `json:"ready"` + Filling int `json:"filling"` + Sessions int `json:"sessions"` + MonthlyUSD float64 `json:"monthly_usd"` + Reminders []pier.Reminder `json:"reminders"` } -// cmdPool: repo-scoped warm session pools — parked, setup-complete members -// that `pier ` claims instead of creating. The TUI's w page is the -// primary surface; these commands are its scriptable equivalents. -func cmdPool(args []string) { - if len(args) == 0 { - poolStatus() - return - } - switch args[0] { - case "set": - poolSet(args[1:]) - case "fill": - poolFill(args[1:]) - case "drain": - poolDrain(args[1:]) - default: - fatal(fmt.Errorf("usage: pier pool [set | fill [--detach] | drain [repo]]")) - } -} - -// poolStatus lists every pool the account holds — configured ones and -// leftovers alike — with what the parked members actually cost. -func poolStatus() { - cfg, drv := loadDriver() - sessions, err := drv.List(context.Background()) +func cmdRepos(args []string) { + jsonOutput := len(args) > 0 && args[0] == "--json" + c := open() + sessions, err := c.Sessions(context.Background()) if err != nil { fatal(err) } - repos := map[string]bool{} - for r := range cfg.Pool.Sizes { - repos[r] = true - } - for _, s := range sessions { - if s.PoolGen != "" { - repos[s.Repo] = true + cur, _ := pier.RepoRoot("") + repos := c.Repos(sessions, cur) + if jsonOutput { + items := make([]repoJSON, 0, len(repos)) + for _, r := range repos { + j := repoJSON{Name: r.Name, Image: r.Image, ReadyTarget: r.ReadyTarget, Ready: r.Ready, + Filling: r.Filling, Sessions: r.Sessions, MonthlyUSD: r.MonthlyUSD, Reminders: r.Reminders} + if r.Baked != nil { + t := r.Baked.BakedAt + j.BakedAt = &t + } + items = append(items, j) + } + if err := json.NewEncoder(os.Stdout).Encode(items); err != nil { + fatal(err) } + return } if len(repos) == 0 { - fmt.Println(ui.Dim.Render("no warm pools — `pier pool set ` from a repo keeps sessions ready to claim")) + fmt.Println(ui.Dim.Render("no repos yet — `pier ` inside one starts a session")) return } - // Staleness is only computable for the repo we're standing in: the - // generation hashes its local setup script. - curRepo, curGen := "", "" - if out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output(); err == nil { - root := strings.TrimSpace(string(out)) - if pp, err := poolParams(cfg, drv, root); err == nil { - curRepo, curGen = filepath.Base(root), pp.Gen - } - } - maxAge, _ := cfg.PoolMaxAge() // unparsable → 0, which skips the age check - names := make([]string, 0, len(repos)) - for r := range repos { - names = append(names, r) + w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) + fmt.Fprintln(w, "REPO\tIMAGE\tREADY\tSESSIONS\tIDLE COST") + for _, r := range repos { + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", r.Name, imageLabel(r), readyLabel(r), r.Sessions, money(r.MonthlyUSD)) } - sort.Strings(names) - now := time.Now() - for _, repo := range names { - st := tui.FoldPool(sessions, repo, curRepo, curGen, maxAge, now) - line := fmt.Sprintf("%s: %d/%d warm", repo, st.Ready, cfg.PoolSize(repo)) - if len(st.Ages) > 0 { - line += ui.Dim.Render(" (ages " + strings.Join(st.Ages, ", ") + ")") - } - if st.Filling > 0 { - line += fmt.Sprintf(", %d filling", st.Filling) - } - if st.Stale > 0 { - line += ui.Warn.Render(fmt.Sprintf(", %d stale", st.Stale)) + ui.Dim.Render(" (recycles on next claim/fill)") - } - if st.Ready > 0 { - line += ui.Dim.Render(fmt.Sprintf(" — %d × ~$3-4/mo disk", st.Ready)) - } - if cfg.PoolSize(repo) == 0 { - line += ui.Warn.Render(" — no pool configured") + ui.Dim.Render(" — `pier pool drain "+repo+"` removes them") + w.Flush() + for _, r := range repos { + for _, rem := range r.Reminders { + fmt.Println(ui.Accent.Render(" tip") + " " + rem.Message + ui.Dim.Render(" `"+rem.Action+"`")) } - fmt.Println(line) } } -func poolSet(args []string) { - if len(args) != 1 { - fatal(fmt.Errorf("usage: pier pool set (0-8; 0 turns the pool off — run from inside the repo)")) - } - n, err := strconv.Atoi(args[0]) - if err != nil || n < 0 || n > 8 { - fatal(fmt.Errorf("pool size: want a number 0-8 (got %q)", args[0])) - } - cfg, drv := loadDriver() - root := repoRoot() - if err := applyPoolSize(cfg, drv, root, n, func(s string) { fmt.Println(ui.Step(s)) }); err != nil { - fatal(err) +func imageLabel(r pier.Repo) string { + switch { + case r.Image == "": + return "none" + case r.Baked == nil: + return "baked" } - repo := filepath.Base(root) - if n == 0 { - fmt.Println(ui.OK.Render("pool off for " + repo)) - return + kind := "prebuilt" + if !r.Baked.RepoIncluded { + kind = "toolchains" } - fmt.Println(ui.OK.Render(fmt.Sprintf("pool size %d for %s", n, repo)) + - ui.Dim.Render(" — each warm member costs ~$3-4/mo parked (disk only)")) - fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(poolLogPath(repo)))) + return kind + ", " + pier.Age(r.Baked.BakedAt, time.Now()) + " old" } -// applyPoolSize is the one write path for a pool size, shared by the CLI and -// the TUI: saves the config, then drains (0) or starts a detached fill (>0). -func applyPoolSize(cfg config.Config, drv driver.Driver, repoRoot string, n int, progress func(string)) error { - repo := filepath.Base(repoRoot) - if _, err := config.Update(func(c *config.Config) error { - c.SetPoolSize(repo, n) - return nil - }); err != nil { - return err +func readyLabel(r pier.Repo) string { + if r.ReadyTarget == 0 && r.Ready == 0 && r.Filling == 0 { + return "off" } - if n == 0 { - sessions, err := drv.List(context.Background()) - if err != nil { - return err - } - _, err = pool.Drain(context.Background(), drv, sessions, repo, progress) - return err + l := fmt.Sprintf("%d/%d", r.Ready, r.ReadyTarget) + if r.Filling > 0 { + l += fmt.Sprintf(" (+%d filling)", r.Filling) } - _, err := spawnPoolFill(repoRoot) - return err + return l } -func poolFill(args []string) { - detach := len(args) > 0 && (args[0] == "--detach" || args[0] == "-d") - cfg, drv := loadDriver() - root := repoRoot() - repo := filepath.Base(root) - if cfg.PoolSize(repo) == 0 { - fatal(fmt.Errorf("no pool configured for %s — `pier pool set ` first", repo)) +func money(usd float64) string { + if usd == 0 { + return "$0" } - if detach { - if _, err := spawnPoolFill(root); err != nil { - fatal(err) + return fmt.Sprintf("~$%.0f/mo", usd) +} + +func cmdBake(args []string) { + var include *bool + for _, a := range args { + switch a { + case "--toolchain-only": + f := false + include = &f + case "--with-repo": + t := true + include = &t + default: + fatal(fmt.Errorf("usage: pier bake [--toolchain-only | --with-repo]")) } - fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(poolLogPath(repo)))) - return } - pp, err := poolParams(cfg, drv, root) - if err != nil { - fatal(err) + c := open() + root := repoRoot() // images are repo-specific: bake from inside the repo it serves + name := filepath.Base(root) + withRepo := c.Config().Speed.ImageRepo + if include != nil { + withRepo = *include } - // ctrl-c mid-fill must cancel the ctx (not just kill the process) so - // Create's deferred cleanup can terminate a half-made member. + what := "harnesses ~5 min, then one full .pier/setup.sh run" + if !withRepo { + what = "harnesses and .pier/bake.sh toolchains, ~5 min" + } + fmt.Printf("%s %s\n", ui.Bold.Render("baking "+name), ui.Dim.Render("("+what+" on a temporary instance, then an image — ~$1-3/mo storage)")) + if withRepo { + stepLine("prebuilding from HEAD — the checkout and setup's artifacts go into the image; every secret pier pushed is scrubbed first") + } + // ctrl-c mid-bake must cancel the ctx (not just kill the process) so + // the bake's cleanup can terminate the temporary instance — it has no + // supervisor, so a leaked one never parks itself. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := pool.Fill(ctx, pp); err != nil { + res, err := c.Bake(ctx, pier.BakeRequest{RepoRoot: root, IncludeRepo: include, Progress: progress}) + if err != nil { fatal(err) } + fmt.Println(ui.OK.Render("baked "+res.Image) + ui.Dim.Render(" — new "+name+" sessions start from it; pier reminds you when a rebake would help")) + if res.ReadyStale { + fmt.Println(ui.Dim.Render("ready sessions were built on the old image — they recycle on the next claim or refill")) + } } -func poolDrain(args []string) { - cfg, drv := loadDriver() - var repo string - if len(args) > 0 { - repo = args[0] - } else { - repo = filepath.Base(repoRoot()) +// cmdReady: `pier ready` shows this repo's ready sessions; `pier ready ` +// sets how many to keep (0 turns them off and removes the parked ones). +func cmdReady(args []string) { + c := open() + root := repoRoot() + repo := filepath.Base(root) + if len(args) == 0 { + sessions, err := c.Sessions(context.Background()) + if err != nil { + fatal(err) + } + for _, r := range c.Repos(sessions, root) { + if r.Name != repo { + continue + } + fmt.Printf("%s: %s ready sessions", repo, readyLabel(r)) + if r.Ready+r.Filling > 0 { + fmt.Print(ui.Dim.Render(fmt.Sprintf(" — each ~$%.0f/mo parked (disk only)", c.DiskMonthlyUSD()))) + } + fmt.Println() + if r.Image == "" && r.ReadyTarget == 0 { + fmt.Println(ui.Dim.Render("ready sessions follow the speed settings for repos with a session image — `pier bake` first")) + } + } + return } - sessions, err := drv.List(context.Background()) - if err != nil { - fatal(err) + if len(args) != 1 { + fatal(fmt.Errorf("usage: pier ready [n] (0-8; 0 turns them off)")) } - count, err := pool.Drain(context.Background(), drv, sessions, repo, func(s string) { fmt.Println(ui.Step(s)) }) + n, err := strconv.Atoi(args[0]) + if err != nil || n < 0 || n > 8 { + fatal(fmt.Errorf("ready sessions: want a number 0-8 (got %q)", args[0])) + } + logPath, err := c.SetReady(context.Background(), root, n, func(e pier.Event) { stepLine(e.Message) }) if err != nil { fatal(err) } - if count == 0 { - fmt.Println(ui.Dim.Render("no warm members for " + repo)) + if n == 0 { + fmt.Println(ui.OK.Render("no ready sessions for " + repo)) return } - fmt.Println(ui.OK.Render(fmt.Sprintf("drained %d member(s)", count))) - if n := cfg.PoolSize(repo); n > 0 { - fmt.Println(ui.Dim.Render(fmt.Sprintf("pool size for %s is still %d — the next claim or fill re-warms it; `pier pool set 0` turns it off", repo, n))) - } -} - -func poolLogPath(repo string) string { - return filepath.Join(config.Dir(), "logs", "pool-"+repo+".log") + fmt.Println(ui.OK.Render(fmt.Sprintf("keeping %d ready session(s) for %s", n, repo)) + + ui.Dim.Render(fmt.Sprintf(" — each ~$%.0f/mo parked (disk only)", c.DiskMonthlyUSD()))) + fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(logPath))) } -// spawnPoolFill re-execs `pier pool fill` as a detached child working in -// repoRoot — the spawnCreate pattern — so a refill survives the invoking -// command (or the TUI) exiting. -func spawnPoolFill(repoRoot string) (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - logPath := poolLogPath(filepath.Base(repoRoot)) - if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil { - return "", err +// cmdRefill is the hidden command background refills re-exec. +func cmdRefill() { + c := open() + root := repoRoot() + // ctrl-c must cancel the ctx so a half-made member is terminated. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := c.Refill(ctx, root, progress); err != nil { + fatal(err) } - // Append, never truncate: a claim can spawn a refill while the previous - // fill is still writing here, and clobbering a live writer's log turns - // both runs into confetti. Fills are rare enough that growth is noise. - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return "", err - } - defer f.Close() - cmd := exec.Command(exe, "pool", "fill") - cmd.Dir = repoRoot - cmd.Stdout, cmd.Stderr = f, f - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - if err := cmd.Start(); err != nil { - return "", err - } - go cmd.Wait() // reap if this process outlives the fill - return logPath, nil } -// --- setup / skills / doctor / bake / teardown ------------------------------------ +// --- setup / doctor / teardown ------------------------------------------------------ func cmdSetup(args []string) { - printAdmin := len(args) > 0 && args[0] == "--print-admin" - if err := wizard.Run(newDriver, printAdmin); err != nil { + printAdmin := false + for _, a := range args { + switch a { + case "--print-admin": + printAdmin = true + case "--skills": + cmdSkills() + return + default: + fatal(fmt.Errorf("usage: pier setup [--print-admin | --skills]")) + } + } + if err := wizard.Run(options(), printAdmin); err != nil { fatal(err) } } -// cmdSkills is the standalone version of the wizard's skills step, minus -// the questions — running the command is the confirmation, so it stays -// scriptable. Refreshes the bundled skills for every agent on the machine, +// cmdSkills refreshes the bundled skills for every agent on the machine, // e.g. after a pier upgrade. Idempotent; a no-op prints as such. func cmdSkills() { home, err := os.UserHomeDir() @@ -1351,50 +986,22 @@ func cmdDoctor() { fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" no config yet — checking with defaults (run `pier setup`)")) cfg = config.Default() } - drv, err := newDriver(cfg) + c, err := pier.New(cfg, options()) if err != nil { fatal(err) } - checks := drv.Doctor(context.Background()) - // Orphaned pool members are parked money nothing will ever claim: they - // belong to repos whose pool was turned off (or another machine's config). - // Members unparked past the fill grace are worse — a fill died before its - // member parked, and the instance bills at full rate until something - // notices; the next fill reconciles it away, but only doctor and the pool - // pages will say so unprompted. + var checks []pier.Check if cfgErr == nil { - if sessions, err := drv.List(context.Background()); err == nil { - orphans, stuck := map[string]int{}, map[string]int{} - for _, s := range sessions { - switch { - case s.PoolGen == "" || s.State == driver.StateDeleting: - case s.State != driver.StateParked && s.State != driver.StateDead && - time.Since(s.Created) >= pool.FillGrace: - stuck[s.Repo]++ - case cfg.PoolSize(s.Repo) == 0: - orphans[s.Repo]++ - } - } - for repo, n := range orphans { - checks = append(checks, driver.Check{ - Name: "pool " + repo, - Detail: fmt.Sprintf("%d warm member(s) but no pool configured — `pier pool drain %s`", n, repo), - }) - } - for repo, n := range stuck { - checks = append(checks, driver.Check{ - Name: "pool " + repo, - Detail: fmt.Sprintf("%d member(s) stuck filling for 2h+ — running at full price; `pier pool fill` (from the repo) or `pier pool drain %s` recycles them", n, repo), - }) - } - } + checks = c.Doctor(context.Background()) + } else { + checks = c.Driver().Doctor(context.Background()) } if !printChecks(checks) { os.Exit(1) } } -func printChecks(checks []driver.Check) bool { +func printChecks(checks []pier.Check) bool { allOK := true for _, c := range checks { allOK = allOK && c.OK @@ -1407,76 +1014,12 @@ func printChecks(checks []driver.Check) bool { return allOK } -func cmdBake() { - cfg, drv := loadDriver() - repo := repoRoot() // images are repo-specific: bake from inside the repo it serves - name := filepath.Base(repo) - hook := driver.BakeHook(repo) - fmt.Printf("%s %s\n", ui.Bold.Render("baking "+name), - ui.Dim.Render("(one temporary instance: harnesses ~5 min plus one full .pier/setup.sh run, then an image — ~$1-3/mo storage)")) - if hook != "" { - fmt.Println(ui.Step(".pier/bake.sh found — its toolchains bake in")) - } - fmt.Println(ui.Step("prebuilding from HEAD — the checkout and setup's artifacts bake in, secrets are scrubbed before imaging")) - // This bake supersedes the repo's previous image (and on aws-ec2, once - // per config, the legacy shared one). - replaces := cfg.BakedReplaces(name) - // ctrl-c mid-bake must cancel the ctx (not just kill the process) so - // Bake's deferred cleanup can terminate the temporary instance — it has - // no supervisor, so a leaked one never parks itself. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - img, err := drv.Bake(ctx, driver.BakeSpec{ - RepoName: name, RepoRoot: repo, HookPath: hook, Replaces: replaces, - }) - if err != nil { - fatal(err) - } - // Update, not Save: a bake takes minutes, and cfg was read before it - // started. Saving it wholesale would revert anything written meanwhile — - // including another repo's bake. - if _, err := config.Update(func(c *config.Config) error { - c.RecordBake(name, img) - return nil - }); err != nil { - fatal(err) - } - fmt.Println(ui.OK.Render("baked "+img) + ui.Dim.Render(" — new "+name+" sessions start from this checkout; re-run `pier bake` when dependencies drift")) - if cfg.PoolSize(name) > 0 { - fmt.Println(ui.Dim.Render("the warm pool was built on the old image — members recycle on the next claim or `pier pool fill`")) - } -} - func cmdTeardown() { - _, drv := loadDriver() - if !confirm("remove all pier groundwork and baked images from the account?", false) { + c := open() + if !confirm("remove all pier groundwork, images and ready sessions from the account?", false) { return } - // Pool members are invisible to `pier rm`, so teardown drains every pool - // itself — Teardown refuses while instances exist, and rightly so. - sessions, err := drv.List(context.Background()) - if err != nil { - fatal(err) - } - repos := map[string]bool{} - for _, s := range sessions { - if s.PoolGen != "" { - repos[s.Repo] = true - } - } - for repo := range repos { - if _, err := pool.Drain(context.Background(), drv, sessions, repo, func(s string) { fmt.Println(ui.Step(s)) }); err != nil { - fatal(err) - } - } - if err := drv.Teardown(context.Background()); err != nil { - fatal(err) - } - if _, err := config.Update(func(c *config.Config) error { - c.ClearBakes() - c.Pool.Sizes = nil - return nil - }); err != nil { + if err := c.Teardown(context.Background(), func(e pier.Event) { stepLine(e.Message) }); err != nil { fatal(err) } fmt.Println(ui.OK.Render("groundwork removed — the account is clean")) @@ -1496,237 +1039,30 @@ func confirm(prompt string, def bool) bool { return line == "y" || line == "yes" } -// --- TUI -------------------------------------------------------------------------- +// --- app ---------------------------------------------------------------------------- func cmdTUI() { - cfg, drv := loadDriver() - // Pool sizing edits the repo pier launched from; outside a repo the w page - // is read-only — a fill needs the local checkout and its setup script. - // Inside a repo whose pool params don't resolve (a broken [pool] value, - // say) the page is read-only too, but says why instead of pretending we - // are not in a repo. - curRoot, curRepo, curGen, poolNote := "", "", "", "" - if out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output(); err == nil { - curRoot = strings.TrimSpace(string(out)) - if pp, err := poolParams(cfg, drv, curRoot); err == nil { - curRepo, curGen = filepath.Base(curRoot), pp.Gen - } else { - poolNote = "pool config unusable (" + err.Error() + ")" - } - } - poolMaxAge, _ := cfg.PoolMaxAge() // unparsable → 0, which skips the age check + root, _ := pier.RepoRoot("") + // The app owns the screen: drivers must not write meters or notices into + // it behind bubbletea's back. + appOpts := options() + appOpts.Out, appOpts.Notify = nil, nil err := tui.Run(tui.Options{ - // Async: the TUI opens instantly and the quota fills in when it lands. - FetchQuota: func() string { - q, err := drv.Headroom(context.Background()) + Open: func() (tui.Backend, error) { + c, err := pier.Open(appOpts) if err != nil { - return "" - } - return q.Detail - }, - Fetch: func() ([]driver.Session, error) { return listSessions(drv) }, - AuthExpired: awsec2.LoginExpired, - Reauthenticate: func() *exec.Cmd { - args := []string{"login"} - if cfg.AWS.Profile != "" { - args = append(args, "--profile", cfg.AWS.Profile) - } - return exec.Command("aws", args...) - }, - Destroy: func(s driver.Session) error { - // d on a tombstone clears the record; there is no instance to kill. - if s.State == driver.StateFailed { - return tombstone.Dismiss(config.Dir(), s.Name) - } - return drv.Destroy(context.Background(), s.ID) - }, - Pin: func(s driver.Session) error { - return pin(drv, s) - }, - Resize: func(s driver.Session, itype string) error { - return drv.Resize(context.Background(), s.ID, itype) - }, - Machines: func(s driver.Session) []driver.Machine { - return drv.Machines(s.InstanceType) - }, - // The settings machine picker serves whichever field is selected, so it - // reads the catalog by driver id — not the active driver's — since a - // user on AWS may still edit the gcp machine default. - SettingsMachines: func(driverID, currentType string) []driver.Machine { - if driverID == "gcp-gce" { - return gcpgce.Machines(currentType) - } - return awsec2.Machines(currentType) - }, - CreateDetached: spawnCreate, - FetchLog: func(s driver.Session) (string, error) { - // A tombstone has no VM to read from — its log is the local create - // log, which is the whole point of keeping the row around. - if s.State == driver.StateFailed { - b, err := os.ReadFile(s.LogPath) - if err != nil { - return s.FailReason, nil - } - return string(b), nil - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - // The viewer refetches every few seconds, so the transfer stays - // bounded; `pier logs` prints everything. - out, err := drv.Exec(ctx, s.ID, `[ -f ~/.pier-setup.log ] && tail -n 2000 ~/.pier-setup.log || echo "no setup log — this session ran no setup script"`) - if err == nil && strings.Count(out, "\n") >= 1999 { - out = "… older lines trimmed — pier logs " + s.Name + " prints everything\n" + out + return nil, err // never a typed-nil *Client inside the interface } - return out, err - }, - Attach: func(s driver.Session) (*exec.Cmd, error) { - return drv.AttachCommand(context.Background(), s.ID) - }, - Resume: func(s driver.Session) error { - return drv.Resume(context.Background(), s.ID) - }, - RetryAttach: retryAttach, - WaitReachable: func(s driver.Session) error { - return waitReachable(drv, s.ID, 4*time.Minute) - }, - CurrentRepo: curRepo, - CurrentGen: curGen, - PoolMaxAge: poolMaxAge, - PoolNote: poolNote, - // Sizes reload from disk on each read so the w page sees a `pier pool - // set` from another terminal. - PoolSizes: func() map[string]int { - c, err := config.Load() - if err != nil { - return cfg.Pool.Sizes - } - return c.Pool.Sizes - }, - PoolSet: func(size int) (string, error) { - c, err := config.Load() - if err != nil { - return "", err - } - if err := applyPoolSize(c, drv, curRoot, size, nil); err != nil { - return "", err - } - if size == 0 { - return "", nil - } - return poolLogPath(curRepo), nil - }, - PoolFill: func() (string, error) { - return spawnPoolFill(curRoot) - }, - PoolDrain: func(repo string) (int, error) { - sessions, err := drv.List(context.Background()) - if err != nil { - return 0, err - } - return pool.Drain(context.Background(), drv, sessions, repo, nil) + return c, nil }, + RepoRoot: root, + Version: version, }) - if err != nil { - fatal(err) - } -} - -// createLogPath is where a detached create's output lands. spawnCreate writes -// it and the create itself reads it back to point its tombstone at the log — -// so the formula lives here once rather than being re-derived on both sides. -// It shares the graveyard's escaping, so feat/login and feat-login get their -// own logs instead of overwriting each other's. -func createLogPath(branch string) string { - return filepath.Join(config.Dir(), "logs", "create-"+tombstone.FileStem(branch)+".log") -} - -// listSessions is the one list every surface shows: the driver's real -// sessions plus local tombstones for creates that died before becoming one. -// A tombstone whose name came back as a live session (the retry worked) is -// dropped here, so a successful re-create silently clears its own gravestone. -func listSessions(drv driver.Driver) ([]driver.Session, error) { - sessions, err := drv.List(context.Background()) - if err != nil { - return nil, err - } - enrich(drv, sessions) - merged, revived := mergeTombstones(sessions, tombstone.List(config.Dir())) - for _, name := range revived { - tombstone.Dismiss(config.Dir(), name) - } - return merged, nil -} - -// mergeTombstones appends a failed row per tombstone and reports the ones the -// cloud has since answered for. A name that is live again means the create -// was retried and worked, so its gravestone is stale — the user shouldn't -// have to clear a row that the obvious next action already resolved. -func mergeTombstones(sessions []driver.Session, recs []tombstone.Record) (merged []driver.Session, revived []string) { - live := make(map[string]bool, len(sessions)) - for _, s := range sessions { - live[s.Name] = true - } - for _, r := range recs { - if live[r.Name] { - revived = append(revived, r.Name) - continue - } - sessions = append(sessions, driver.Session{ - Name: r.Name, Repo: r.Repo, Branch: r.Branch, Driver: r.Driver, - State: driver.StateFailed, Created: r.When, - FailReason: r.Reason, LogPath: r.LogPath, - CostNote: "—", // nothing is running; nothing is being charged - }) - } - return sessions, revived -} - -// buryCreate records a failed create so it leaves a trace in the list instead -// of a silent gap. Best-effort: the create already failed and its own error is -// what the user acts on — a graveyard write that fails must not mask it. -func buryCreate(cfg config.Config, branch, repo string, cause error) { - rec := tombstone.Record{ - Name: branch, Repo: filepath.Base(repo), Branch: branch, - Driver: or(cfg.Driver, "aws-ec2"), Reason: cause.Error(), When: time.Now(), - } - if p := createLogPath(branch); fileExists(p) { - rec.LogPath = p - } - _ = tombstone.Write(config.Dir(), rec) -} - -func fileExists(p string) bool { - fi, err := os.Stat(p) - return err == nil && !fi.IsDir() -} - -// spawnCreate re-execs `pier --detach` as a detached child (own -// session, output to a log file), so a TUI-initiated create runs in the -// background and survives the TUI closing. The list shows it as "creating" -// until the bootstrap finishes and the supervisor beacon appears. -func spawnCreate(branch string) (string, error) { - if err := exec.Command("git", "rev-parse", "--show-toplevel").Run(); err != nil { - return "", fmt.Errorf("not inside a git repository — run pier from the repo to fork") - } - exe, err := os.Executable() - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Join(config.Dir(), "logs"), 0o700); err != nil { - return "", err + if errors.Is(err, tui.ErrNoConfig) { + fmt.Println(ui.Dim.Render("pier isn't set up yet — run `pier setup`")) + return } - logPath := createLogPath(branch) - f, err := os.Create(logPath) if err != nil { - return "", err - } - defer f.Close() - cmd := exec.Command(exe, branch, "--detach") - cmd.Stdout, cmd.Stderr = f, f - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - if err := cmd.Start(); err != nil { - return "", err + fatal(err) } - go cmd.Wait() // reap if the TUI outlives the create - return logPath, nil } diff --git a/cmd/pier/main_test.go b/cmd/pier/main_test.go index b2bf7fa..2f7bf90 100644 --- a/cmd/pier/main_test.go +++ b/cmd/pier/main_test.go @@ -1,49 +1,13 @@ package main import ( - "os/exec" "strings" "testing" "time" "github.com/usepier/pier/internal/driver" - "github.com/usepier/pier/internal/tombstone" ) -// A failed create used to vanish without trace: the instance rolled itself -// back and nothing was left to say a session had been asked for at all. The -// tombstone rides the list so the failure is visible — but only until the -// cloud can answer for that name itself. -func TestMergeTombstones(t *testing.T) { - live := []driver.Session{{Name: "kur-3814", State: driver.StateIdle}} - recs := []tombstone.Record{ - {Name: "kur-3817", Repo: "flb", Reason: "scp: Connection closed", LogPath: "/tmp/c.log"}, - {Name: "kur-3814", Reason: "an earlier attempt that has since worked"}, - } - - merged, revived := mergeTombstones(live, recs) - if len(merged) != 2 { - t.Fatalf("want the live session plus one tombstone, got %+v", merged) - } - if merged[0].Name != "kur-3814" || merged[0].State != driver.StateIdle { - t.Errorf("a live session must survive the merge untouched, got %+v", merged[0]) - } - got := merged[1] - if got.Name != "kur-3817" || got.State != driver.StateFailed { - t.Errorf("want kur-3817 as a failed row, got %+v", got) - } - if got.FailReason == "" || got.LogPath != "/tmp/c.log" { - t.Errorf("a tombstone must carry why it died and where to read more, got %+v", got) - } - if got.CostNote != "—" { - t.Errorf("nothing is running, so nothing is billing; got CostNote %q", got.CostNote) - } - // The obvious next action — re-create it — must clear the row by itself. - if len(revived) != 1 || revived[0] != "kur-3814" { - t.Errorf("a tombstone whose name came back live must be reported stale, got %v", revived) - } -} - // "create failed" and "setup failed" sit in the same column and mean opposite // things: one has no VM at all, the other has a running one whose setup // script died. They must never render as the same word. @@ -99,18 +63,3 @@ func TestParseLSArgs(t *testing.T) { t.Error("unknown flags must be rejected") } } - -func TestRetryAttachOnlyForQuickSSHTransportFailure(t *testing.T) { - exit := func(code string) error { - return exec.Command("sh", "-c", "exit "+code).Run() - } - if !retryAttach(exit("255"), time.Second) { - t.Error("quick ssh transport failure should retry") - } - if retryAttach(exit("1"), time.Second) { - t.Error("remote command failure should not retry") - } - if retryAttach(exit("255"), 16*time.Second) { - t.Error("slow transport failure should not retry") - } -} diff --git a/go.mod b/go.mod index f6eee2b..646a491 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,12 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/internal/config/config.go b/internal/config/config.go index 116478c..cd02265 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,7 +20,39 @@ type Config struct { AWS AWS `toml:"aws"` GCP GCP `toml:"gcp"` Pool Pool `toml:"pool"` + Speed Speed `toml:"speed"` Secrets Secrets `toml:"secrets"` + // Images records what each repo's session image was baked from — when, + // and from which .pier scripts — so pier can remind (never decide) when a + // rebake would help. Keyed by repo basename, like the image ids. + Images map[string]ImageInfo `toml:"images,omitempty"` +} + +// Speed holds the two settings a speed profile presets, plus how images +// are built and when pier suggests rebuilding them. +type Speed struct { + // ReadySessions is how many parked, setup-complete sessions pier keeps + // ready for each repo that has a session image (a repo's own entry in + // pool.sizes overrides it). Parked = disk-only cost while waiting. + ReadySessions int `toml:"ready_sessions"` + // BakeReminders: suggest `pier bake` when a repo has no image, its image + // gets old, or its .pier scripts changed since the bake. Never automatic. + BakeReminders bool `toml:"bake_reminders"` + // ImageRepo: bakes include the repo checkout with .pier/setup.sh already + // run (the fast default). false bakes toolchains only, for teams that keep + // repo state out of images. + ImageRepo bool `toml:"image_repo"` + // ReminderAge is how old an image gets before pier suggests a rebake: + // "30d", "72h", or "never". + ReminderAge string `toml:"reminder_age"` +} + +// ImageInfo is what one repo's image was baked from. +type ImageInfo struct { + BakedAt time.Time `toml:"baked_at"` + SetupSHA string `toml:"setup_sha,omitempty"` // sha256 of .pier/setup.sh at bake; "" = none + BakeSHA string `toml:"bake_sha,omitempty"` // sha256 of .pier/bake.sh at bake; "" = none + RepoIncluded bool `toml:"repo_included"` } type AWS struct { @@ -55,7 +87,7 @@ type GCP struct { } // Pool configures repo-scoped warm session pools. Strictly opt-in: a pool -// exists only for repos with a size entry. Managed by `pier pool set`, not +// exists only for repos with a size entry. Managed by `pier ready`, not // the TUI settings — like baked images, a pool is a per-repo cost decision. type Pool struct { // MaxAge recycles members older than this, bounding how far a warm @@ -91,6 +123,7 @@ func Default() Config { MachineType: "e2-medium", DiskGiB: 40, }, + Speed: Speed{ReadySessions: 1, BakeReminders: true, ImageRepo: true, ReminderAge: "30d"}, } } @@ -216,16 +249,32 @@ func (c *Config) ClearBakes() { } c.AWS.BakedAMI = "" c.AWS.BakedAMIs = nil + c.Images = nil } -// PoolSize is repo's configured warm-pool size (0 = no pool). -func (c Config) PoolSize(repo string) int { return c.Pool.Sizes[repo] } +// PoolSize is how many ready sessions pier keeps for repo: the repo's own +// entry when it has one, otherwise the speed default — but only for repos +// with a session image. Without one a ready session would pay the full cold +// setup on every refill, which is the cost the reminder to bake points at. +func (c Config) PoolSize(repo string) int { + if n, ok := c.Pool.Sizes[repo]; ok { + return n + } + if c.BakedImage(repo) == "" { + return 0 + } + return c.Speed.ReadySessions +} -// SetPoolSize records repo's desired pool size; 0 removes the entry so the -// config doesn't accumulate dead rows for drained pools. +// SetPoolSize records repo's ready-session count. A count equal to what the +// default would give removes the entry, so the repo follows later default +// changes; anything else (0 included) is pinned for this repo. func (c *Config) SetPoolSize(repo string, n int) { - if n <= 0 { - delete(c.Pool.Sizes, repo) + if n < 0 { + n = 0 + } + delete(c.Pool.Sizes, repo) + if c.PoolSize(repo) == n { return } if c.Pool.Sizes == nil { @@ -234,6 +283,90 @@ func (c *Config) SetPoolSize(repo string, n int) { c.Pool.Sizes[repo] = n } +// PoolRepos lists every repo with a nonzero ready-session target: explicit +// entries plus baked repos riding the default. +func (c Config) PoolRepos() []string { + seen := map[string]bool{} + var out []string + add := func(r string) { + if !seen[r] && c.PoolSize(r) > 0 { + seen[r] = true + out = append(out, r) + } + } + for r := range c.Pool.Sizes { + add(r) + } + for r := range c.bakedRepos() { + add(r) + } + return out +} + +// bakedRepos is the set of repos with an image under the active driver. +func (c Config) bakedRepos() map[string]bool { + m := map[string]string{} + if c.gcp() { + m = c.GCP.BakedImages + } else { + m = c.AWS.BakedAMIs + } + out := map[string]bool{} + for r, img := range m { + if img != "" { + out[r] = true + } + } + return out +} + +// Profile names the speed preset the current settings match: "fast" +// (reminders + 1 ready session), "lean" (reminders, none ready), "minimal" +// (neither), or "custom" for anything else. +func (c Config) Profile() string { + switch { + case c.Speed.BakeReminders && c.Speed.ReadySessions == 1: + return "fast" + case c.Speed.BakeReminders && c.Speed.ReadySessions == 0: + return "lean" + case !c.Speed.BakeReminders && c.Speed.ReadySessions == 0: + return "minimal" + } + return "custom" +} + +// ApplyProfile sets the two settings a speed preset stands for. +func (c *Config) ApplyProfile(p string) error { + switch p { + case "fast": + c.Speed.BakeReminders, c.Speed.ReadySessions = true, 1 + case "lean": + c.Speed.BakeReminders, c.Speed.ReadySessions = true, 0 + case "minimal": + c.Speed.BakeReminders, c.Speed.ReadySessions = false, 0 + default: + return fmt.Errorf("speed profile: want fast, lean or minimal (got %q)", p) + } + return nil +} + +// ReminderAge parses speed.reminder_age; 0 = never remind about age. +func (c Config) ReminderAge() time.Duration { + d, err := parseDays(c.Speed.ReminderAge) + if err != nil { + return 30 * 24 * time.Hour + } + return d +} + +// RecordImageInfo stores what repo's new image was baked from. +func (c *Config) RecordImageInfo(repo string, info ImageInfo) { + if c.Images == nil { + c.Images = map[string]ImageInfo{} + } + c.Images[repo] = info +} + // PoolMaxAge parses pool.max_age, defaulting to 14 days. time.ParseDuration // has no days unit, so "14d" is handled here (whole days only). func (c Config) PoolMaxAge() (time.Duration, error) { @@ -254,6 +387,25 @@ func (c Config) PoolMaxAge() (time.Duration, error) { return d, nil } +// parseDays reads "30d", "72h" or "never" ("" and "never" = 0). +func parseDays(s string) (time.Duration, error) { + if s == "" || s == "never" { + return 0, nil + } + if days, ok := strings.CutSuffix(s, "d"); ok { + n, err := strconv.Atoi(days) + if err != nil || n <= 0 { + return 0, fmt.Errorf("want a positive duration like 30d or 72h (got %q)", s) + } + return time.Duration(n) * 24 * time.Hour, nil + } + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return 0, fmt.Errorf("want a positive duration like 30d or 72h (got %q)", s) + } + return d, nil +} + // ParkDuration parses "30m" / "8h" / "never" (or "0") into a duration; // 0 means disabled. func ParkDuration(s string) (time.Duration, error) { @@ -286,7 +438,8 @@ type Option struct { // Suffix). Validation always runs through Set, the single write path. type Field struct { Key string - Group string // "session", "aws", "gcp" + Group string // a Groups key + Cloud string // "aws" / "gcp": applies only while that cloud is active; "" = always Label string // human label shown on the row Hint string // short one-liner beside the value Detail string // multi-line (\n-split) footer for the selected field @@ -328,37 +481,36 @@ var ( diskOpts = []Option{{Value: "20", Label: "20 GiB"}, {Value: "40", Label: "40 GiB"}, {Value: "80", Label: "80 GiB"}, {Value: "160", Label: "160 GiB"}} ) -// Settings lists the settable fields in display order, grouped session → aws → -// gcp. Secrets and baked images are deliberately absent: those are managed by -// `pier setup` and `pier bake`, and the TUI shows them read-only. +// Groups orders the settings page's sections. Every field belongs to one. +var Groups = []struct{ Key, Title string }{ + {"cloud", "Cloud"}, + {"sessions", "New sessions"}, + {"idle", "Idle & cost"}, + {"speed", "Speed"}, +} + +// Settings lists the settable fields in display order, grouped by what the +// user is thinking about when they open the page. Fields tagged with a Cloud +// only apply (and only show) while that cloud is active. Secrets and baked +// images are absent on purpose: `pier setup` and `pier bake` manage them. var Settings = []Field{ { - Key: "driver", Group: "session", Label: "cloud", Hint: "runs new sessions", + Key: "driver", Group: "cloud", Label: "provider", Hint: "where sessions run", Kind: KindChoice, NoCustom: true, Default: "aws-ec2", Options: []Option{ - {Value: "aws-ec2", Label: "AWS EC2", Desc: "Amazon EC2 · direct ssh or SSM"}, - {Value: "gcp-gce", Label: "GCP Compute Engine", Desc: "Google Compute Engine · IAP tunnel"}, + {Value: "aws-ec2", Label: "AWS", Desc: "Amazon EC2 · direct ssh or SSM"}, + {Value: "gcp-gce", Label: "GCP", Desc: "Google Compute Engine · IAP tunnel"}, }, - Detail: "Which provider runs new sessions.\nExisting sessions stay on the cloud they were built on; the other cloud's settings wait dimmed below until you switch.", - }, - { - Key: "idle_timeout", Group: "session", Label: "auto-park", Hint: "park when detached & quiet", - Kind: KindChoice, Options: idleOpts, Default: "30m", - Detail: "A session detached and quiet this long parks itself: the VM stops, the disk stays (~$3-4/mo), and attaching resumes it in ~20-60s.\n`pier keep` exempts one session; --idle overrides one create.\nformat: 30m, 2h, or never", - }, - { - Key: "unattended_cap", Group: "session", Label: "unattended cap", Hint: "park even mid-run", - Kind: KindChoice, Options: capOpts, Default: "8h", - Detail: "Parks a session even while the agent is still busy, once you've been detached this long — a runaway loop can't burn compute for days.\n--cap overrides one create.\nformat: 8h or never", + Detail: "Which provider runs new sessions.\nExisting sessions stay on the cloud they were built on. Switching shows that cloud's settings here; run `pier doctor` afterwards to check its groundwork.", }, { - Key: "aws.profile", Group: "aws", Label: "profile", Hint: "AWS CLI profile for every call", - Kind: KindText, - Detail: "The AWS CLI profile every pier command runs under — SSO, MFA, and its default region all come from it.\nset one up with `aws configure`.", + Key: "aws.profile", Group: "cloud", Cloud: "aws", Label: "CLI profile", Hint: "AWS CLI profile pier uses", + Kind: KindText, Empty: "(default)", + Detail: "The AWS CLI profile every pier command runs under — SSO, MFA and credentials all come from it.\nset one up with `aws configure`.", }, { - Key: "aws.region", Group: "aws", Label: "region", Hint: "where new VMs launch", - Kind: KindChoice, Default: "eu-central-1", + Key: "aws.region", Group: "cloud", Cloud: "aws", Label: "region", Hint: "where new VMs launch", + Kind: KindChoice, Default: "eu-central-1", Empty: "from the CLI profile", Options: []Option{ {Value: "us-east-1", Desc: "N. Virginia"}, {Value: "us-east-2", Desc: "Ohio"}, @@ -369,59 +521,114 @@ var Settings = []Field{ {Value: "ap-southeast-1", Desc: "Singapore"}, {Value: "ap-northeast-1", Desc: "Tokyo"}, }, - Detail: "New session VMs launch here; existing sessions stay where they are.\nafter switching, `pier doctor` checks the groundwork exists in the new region.\nformat: us-east-1, eu-central-1, …", + Detail: "New session VMs launch here; existing sessions stay where they are.\nAfter switching, `pier doctor` checks the groundwork exists in the new region. Session images are per region: rebake there.", }, { - Key: "aws.instance_type", Group: "aws", Label: "machine", Hint: "default VM for new sessions", - Kind: KindMachine, Default: "t4g.medium", - Detail: "New sessions start on this VM type. Undersize freely — `m` resizes any live session in about a minute, disk intact.", + Key: "gcp.project", Group: "cloud", Cloud: "gcp", Label: "project", Hint: "project sessions live in", + Kind: KindText, + Detail: "Sessions are created in this project — pier always passes it explicitly, never your active gcloud default. Required on GCP.", }, { - Key: "aws.disk_gib", Group: "aws", Label: "disk", Hint: "per-session root disk", - Kind: KindChoice, Options: diskOpts, Suffix: " GiB", Default: "40", - Detail: "Root disk for each new session — it's what survives parking and what a parked session costs (~$3-4/mo).\nwhole GiB, at least 8", + Key: "gcp.zone", Group: "cloud", Cloud: "gcp", Label: "zone", Hint: "where new VMs launch", + Kind: KindChoice, Default: "europe-west3-a", + Options: []Option{ + {Value: "us-central1-a", Desc: "Iowa"}, + {Value: "us-east1-b", Desc: "S. Carolina"}, + {Value: "europe-west1-b", Desc: "Belgium"}, + {Value: "europe-west3-a", Desc: "Frankfurt"}, + {Value: "europe-west4-a", Desc: "Netherlands"}, + {Value: "asia-southeast1-a", Desc: "Singapore"}, + }, + Detail: "New session VMs launch in this zone; existing sessions stay put.", }, { - Key: "aws.direct", Group: "aws", Label: "connection", Hint: "how ssh reaches the VM", + Key: "aws.direct", Group: "cloud", Cloud: "aws", Label: "connection", Hint: "direct is fastest; SSM if port 22 is blocked", Kind: KindChoice, NoCustom: true, Default: "direct ssh", Options: []Option{ {Value: "true", Label: "direct ssh", Desc: "straight to the VM's public IP — full speed"}, {Value: "false", Label: "SSM tunnel", Desc: "everything through SSM (~1 MB/s)"}, }, - Detail: "How the terminal reaches the VM.\ndirect ssh dials the public IP (full speed), opens TCP 22 to your current IP only, and falls back to the SSM tunnel by itself when that's blocked.\nSSM tunnel forces the tunnel — for networks that block outbound 22 or orgs that disallow the ingress calls.", + Detail: "How your terminal reaches the VM.\ndirect ssh dials the public IP at full speed, opens TCP 22 to your current IP only, and falls back to the SSM tunnel by itself when that's blocked.\nSSM tunnel forces the tunnel — for networks that block outbound 22 or orgs that disallow the ingress rule.", }, { - Key: "aws.subnet", Group: "aws", Label: "subnet", Hint: "only without a default VPC", - Kind: KindText, Empty: "(default VPC)", + Key: "aws.subnet", Group: "cloud", Cloud: "aws", Label: "subnet", Hint: "only needed without a default VPC", + Kind: KindText, Empty: "default VPC", Detail: "Only for accounts whose default VPC was deleted: session VMs launch in this subnet. Leave empty otherwise.\nformat: subnet-0abc123…", }, { - Key: "gcp.project", Group: "gcp", Label: "project", Hint: "project sessions are created in", - Kind: KindText, - Detail: "Sessions are created in this project — pier always passes it explicitly, never your active gcloud default. Required when cloud is GCP.", + Key: "aws.instance_type", Group: "sessions", Cloud: "aws", Label: "machine", Hint: "VM for new sessions", + Kind: KindMachine, Default: "t4g.medium", + Detail: "New sessions start on this VM type. Undersize freely — `m` on the Sessions tab resizes a live session in about a minute, disk intact.", }, { - Key: "gcp.zone", Group: "gcp", Label: "zone", Hint: "where new VMs launch", - Kind: KindChoice, Default: "europe-west3-a", + Key: "gcp.machine_type", Group: "sessions", Cloud: "gcp", Label: "machine", Hint: "VM for new sessions", + Kind: KindMachine, Default: "e2-medium", + Detail: "New sessions start on this machine type. Undersize freely — `m` on the Sessions tab resizes a live session in a couple of minutes, disk intact.", + }, + { + Key: "aws.disk_gib", Group: "sessions", Cloud: "aws", Label: "disk", Hint: "per-session disk; survives parking", + Kind: KindChoice, Options: diskOpts, Suffix: " GiB", Default: "40", + Detail: "Root disk for each new session. It's what survives parking, and what a parked session costs: about $0.08-0.10 per GiB-month (40 GiB ≈ $3-4/mo).\nWith a prebuilt image, leave room for dependencies and container images.", + }, + { + Key: "gcp.disk_gib", Group: "sessions", Cloud: "gcp", Label: "disk", Hint: "per-session disk; survives parking", + Kind: KindChoice, Options: diskOpts, Suffix: " GiB", Default: "40", + Detail: "Boot disk for each new session. It's what survives parking, and what a parked session costs (40 GiB ≈ $3-4/mo).", + }, + { + Key: "idle_timeout", Group: "idle", Label: "park after", Hint: "park when detached and quiet", + Kind: KindChoice, Options: idleOpts, Default: "30m", + Detail: "A session you've detached from that goes quiet this long parks itself: the VM stops and only its disk costs money. Attaching resumes it in ~20-60s with your tmux windows and agent conversations restored.\n`pier keep` exempts one session; --idle overrides one create.", + }, + { + Key: "unattended_cap", Group: "idle", Label: "runaway cap", Hint: "park even while the agent keeps working", + Kind: KindChoice, Options: capOpts, Default: "8h", + Detail: "Parks a session even while the agent is busy, once you've been detached this long — a looping agent can't burn compute for days.\n--cap overrides one create.", + }, + { + Key: "speed.profile", Group: "speed", Label: "profile", Hint: "preset for the two rows below", + Kind: KindChoice, NoCustom: true, Default: "fast", Options: []Option{ - {Value: "us-central1-a", Desc: "Iowa"}, - {Value: "us-east1-b", Desc: "S. Carolina"}, - {Value: "europe-west1-b", Desc: "Belgium"}, - {Value: "europe-west3-a", Desc: "Frankfurt"}, - {Value: "europe-west4-a", Desc: "Netherlands"}, - {Value: "asia-southeast1-a", Desc: "Singapore"}, + {Value: "fast", Label: "Fast", Desc: "image + 1 ready session per repo · ~25s starts"}, + {Value: "lean", Label: "Lean", Desc: "image only · ~1-2 min starts, image storage only"}, + {Value: "minimal", Label: "Minimal", Desc: "nothing stored · full setup every start, $0 idle"}, }, - Detail: "New session VMs launch in this zone; existing sessions stay put.\nformat: europe-west3-a, us-central1-a, …", + Detail: "A profile is a preset for bake reminders and ready sessions. Change either row and the profile reads custom.\nFast: new sessions claim a parked, set-up session (~25s); each ready session costs its parked disk.\nLean: new sessions boot from the repo's image and re-run setup warm.\nMinimal: nothing is stored; every session runs the full setup.", }, { - Key: "gcp.machine_type", Group: "gcp", Label: "machine", Hint: "default VM for new sessions", - Kind: KindMachine, Default: "e2-medium", - Detail: "New sessions start on this machine type. Undersize freely — `m` resizes any live session in a couple of minutes, disk intact.", + Key: "speed.ready_sessions", Group: "speed", Label: "ready sessions", Hint: "parked and set up, per baked repo", + Kind: KindChoice, NoCustom: true, Default: "1", + Options: []Option{ + {Value: "0", Label: "none"}, + {Value: "1", Desc: "the next session starts in ~25s"}, + {Value: "2", Desc: "two quick starts back to back"}, + {Value: "3"}, + }, + Detail: "How many parked, setup-complete sessions pier keeps for each repo that has a session image. They're stopped VMs: each costs only its disk while waiting, and runs only while being refilled.\nOverride one repo on the Repos tab.", }, { - Key: "gcp.disk_gib", Group: "gcp", Label: "disk", Hint: "per-session boot disk", - Kind: KindChoice, Options: diskOpts, Suffix: " GiB", Default: "40", - Detail: "Boot disk for each new session — it's what survives parking and what a parked session costs (~$3-4/mo).\nwhole GiB, at least 10", + Key: "speed.bake_reminders", Group: "speed", Label: "bake reminders", Hint: "suggest `pier bake` when it would help", + Kind: KindChoice, NoCustom: true, Default: "on", + Options: []Option{ + {Value: "true", Label: "on", Desc: "when a repo has no image, it gets old, or .pier scripts changed"}, + {Value: "false", Label: "off"}, + }, + Detail: "pier never rebakes on its own. With reminders on it points out when a bake would make sessions start faster.", + }, + { + Key: "speed.image_repo", Group: "speed", Label: "image contents", Hint: "what `pier bake` puts in a repo's image", + Kind: KindChoice, NoCustom: true, Default: "toolchains + repo", + Options: []Option{ + {Value: "true", Label: "toolchains + repo", Desc: "checkout with setup already run — fastest starts"}, + {Value: "false", Label: "toolchains only", Desc: "keeps repo state out of images"}, + }, + Detail: "toolchains + repo: the bake runs .pier/setup.sh on a checkout, scrubs every secret pier pushed, and images the result; sessions only fetch what changed and re-run setup warm.\ntoolchains only: images carry the harnesses and .pier/bake.sh tools; every session runs the full setup.\nApplies to the next `pier bake`.", + }, + { + Key: "speed.reminder_age", Group: "speed", Label: "rebake after", Hint: "remind when an image gets this old", + Kind: KindChoice, Default: "30d", + Options: []Option{{Value: "14d"}, {Value: "30d"}, {Value: "60d"}, {Value: "never"}}, + Detail: "Once a repo's image is this old, pier suggests a rebake so new sessions start with current dependencies.", }, } @@ -454,10 +661,31 @@ func Get(c Config, key string) string { return c.GCP.MachineType case "gcp.disk_gib": return strconv.Itoa(c.GCP.DiskGiB) + case "speed.profile": + return c.Profile() + case "speed.ready_sessions": + return strconv.Itoa(c.Speed.ReadySessions) + case "speed.bake_reminders": + return strconv.FormatBool(c.Speed.BakeReminders) + case "speed.image_repo": + return strconv.FormatBool(c.Speed.ImageRepo) + case "speed.reminder_age": + return c.Speed.ReminderAge } return "" } +// Visible reports whether a field applies under the config's active cloud. +func (c Config) Visible(f Field) bool { + switch f.Cloud { + case "aws": + return !c.gcp() + case "gcp": + return c.gcp() + } + return true +} + // Set mutates one whitelisted scalar, validating it before it lands. A // rejected value never touches the config, so the caller can keep the field // open for a fix. Changes apply to new sessions only. @@ -547,6 +775,34 @@ func Set(c *Config, key, val string) error { default: return fmt.Errorf("aws.direct: want true or false (got %q)", val) } + case "speed.profile": + return c.ApplyProfile(strings.ToLower(strings.TrimSpace(val))) + case "speed.ready_sessions": + n, err := strconv.Atoi(strings.TrimSpace(val)) + if err != nil || n < 0 || n > 8 { + return fmt.Errorf("speed.ready_sessions: want 0-8 (got %q)", val) + } + c.Speed.ReadySessions = n + case "speed.bake_reminders", "speed.image_repo": + var b bool + switch strings.ToLower(strings.TrimSpace(val)) { + case "true", "yes", "on": + b = true + case "false", "no", "off": + default: + return fmt.Errorf("%s: want on or off (got %q)", key, val) + } + if key == "speed.bake_reminders" { + c.Speed.BakeReminders = b + } else { + c.Speed.ImageRepo = b + } + case "speed.reminder_age": + val = strings.TrimSpace(val) + if _, err := parseDays(val); err != nil { + return fmt.Errorf("speed.reminder_age: %v, or never", err) + } + c.Speed.ReminderAge = val default: keys := make([]string, len(Settings)) for i, s := range Settings { diff --git a/internal/driver/awsec2/bake.go b/internal/driver/awsec2/bake.go index 0007fb4..9204444 100644 --- a/internal/driver/awsec2/bake.go +++ b/internal/driver/awsec2/bake.go @@ -64,7 +64,7 @@ func (d *Driver) Bake(ctx context.Context, spec driver.BakeSpec) (string, error) os.Rename(d.keyPath("bake")+".pub", d.keyPath(id)+".pub") defer os.Remove(d.keyPath(id)) defer os.Remove(d.keyPath(id) + ".pub") - fmt.Println(ui.Step("bake instance " + id + " launched — installing harnesses (a few minutes)")) + spec.Step("bake instance " + id + " launched — installing harnesses (a few minutes)") if err := d.waitSSH(ctx, id, 240*time.Second); err != nil { return "", err @@ -83,7 +83,7 @@ exit 1` return "", fmt.Errorf("harness install did not complete: %w", err) } if spec.HookPath != "" { - fmt.Println(ui.Step("running .pier/bake.sh (output follows)")) + spec.Step("running .pier/bake.sh (output follows)") if err := d.scpTo(ctx, id, spec.HookPath, "/tmp/pier-bake.sh", "-q"); err != nil { return "", err } @@ -112,7 +112,7 @@ exit 1` return "", err } - fmt.Println(ui.Step("imaging — stop, snapshot, register (a few minutes)")) + spec.Step("imaging — stop, snapshot, register (a few minutes)") if _, err := d.aws(ctx, "ec2", "stop-instances", "--instance-ids", id); err != nil { return "", err } diff --git a/internal/driver/awsec2/cli.go b/internal/driver/awsec2/cli.go index bbfc5de..29aa83f 100644 --- a/internal/driver/awsec2/cli.go +++ b/internal/driver/awsec2/cli.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -112,13 +113,13 @@ func (d *Driver) sshRunOpts(ctx context.Context, id string, extra []string, scri return strings.TrimSpace(string(out)), nil } -// sshStream is sshRun with output flowing straight to the terminal — for +// sshStream is sshRun with output streaming to d.Out as it arrives — for // long user-visible steps (the bake hook) where buffered output would look // like a hang. func (d *Driver) sshStream(ctx context.Context, id, script string) error { args := append(d.sshOpts(ctx, id), "agent@"+id, script) cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.Stdout, cmd.Stderr = d.out(), d.out() return cmd.Run() } @@ -128,9 +129,9 @@ func (d *Driver) scpTo(ctx context.Context, id, local, remote string, extra ...s args := append(append(d.sshOpts(ctx, id), extra...), local, "agent@"+id+":"+remote) cmd := exec.CommandContext(ctx, "scp", args...) // scp draws its progress meter only when stdout is a terminal — so big - // pushes (the repo bundle) show live progress interactively and stay - // silent when piped. - cmd.Stdout = os.Stdout + // pushes (the repo bundle) show live progress when the frontend hands + // its terminal in as Out, and stay silent otherwise. + cmd.Stdout = d.out() var errb bytes.Buffer cmd.Stderr = &errb if err := cmd.Run(); err != nil { @@ -159,3 +160,18 @@ func (d *Driver) newKeypair(id string) (string, error) { } return strings.TrimSpace(string(pub)), nil } + +// out is Out, or a sink when the frontend wants no driver output. +func (d *Driver) out() io.Writer { + if d.Out == nil { + return io.Discard + } + return d.Out +} + +// notify passes a transport notice to the frontend, if it listens. +func (d *Driver) notify(msg string) { + if d.Notify != nil { + d.Notify(msg) + } +} diff --git a/internal/driver/awsec2/direct.go b/internal/driver/awsec2/direct.go index 57fac77..8c718b6 100644 --- a/internal/driver/awsec2/direct.go +++ b/internal/driver/awsec2/direct.go @@ -17,7 +17,6 @@ import ( "io" "net" "net/http" - "os" "strings" "time" ) @@ -310,6 +309,6 @@ func (d *Driver) directNotice(id, msg string) { } d.dmu.Unlock() if !seen { - fmt.Fprintln(os.Stderr, "pier: "+msg) + d.notify(msg) } } diff --git a/internal/driver/awsec2/driver.go b/internal/driver/awsec2/driver.go index 1d14e25..6ec18cd 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -13,6 +13,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "os/exec" "slices" @@ -60,6 +61,13 @@ type Driver struct { // SupervisorBin returns the embedded pier-supervisor binary for an arch // ("arm64"/"amd64"). SupervisorBin func(arch string) ([]byte, error) + // Out receives transfer meters and streamed remote output (the bake + // hook). nil discards it: a frontend speaking a protocol on stdout (the + // `pier api` server) must never have a driver write there behind it. + Out io.Writer + // Notify receives one-off transport notices ("using the ssm tunnel"). + // nil discards them. + Notify func(string) callerARN string // cached diff --git a/internal/driver/driver.go b/internal/driver/driver.go index b3e827b..ceac2e9 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -109,6 +109,14 @@ type BakeSpec struct { RepoRoot string // local repo root to prebuild from; "" = toolchains only HookPath string // local path to the repo's .pier/bake.sh; "" = none Replaces []string // images this bake supersedes (previous bake, legacy shared image) + Progress func(step string) +} + +// Step reports one bake phase; a nil Progress discards it. +func (s BakeSpec) Step(msg string) { + if s.Progress != nil { + s.Progress(msg) + } } // BakeHook returns the repo's .pier/bake.sh, "" when absent. It runs on the diff --git a/internal/driver/gcpgce/bake.go b/internal/driver/gcpgce/bake.go index 59c9666..c41b941 100644 --- a/internal/driver/gcpgce/bake.go +++ b/internal/driver/gcpgce/bake.go @@ -54,7 +54,7 @@ func (d *Driver) Bake(ctx context.Context, spec driver.BakeSpec) (string, error) // Destroy also drops the known_hosts entry — the next bake reuses this // deterministic name with a fresh host key. defer d.Destroy(context.WithoutCancel(ctx), id) - fmt.Println(ui.Step("bake instance " + id + " launched — installing harnesses (a few minutes)")) + spec.Step("bake instance " + id + " launched — installing harnesses (a few minutes)") if err := d.waitSSH(ctx, id, 300*time.Second); err != nil { return "", err @@ -73,7 +73,7 @@ exit 1` return "", fmt.Errorf("harness install did not complete: %w", err) } if spec.HookPath != "" { - fmt.Println(ui.Step("running .pier/bake.sh (output follows)")) + spec.Step("running .pier/bake.sh (output follows)") if err := d.scpTo(ctx, id, spec.HookPath, "/tmp/pier-bake.sh", "-q"); err != nil { return "", err } @@ -109,7 +109,7 @@ exit 1` return "", err } - fmt.Println(ui.Step("imaging — stop, snapshot the boot disk (a few minutes)")) + spec.Step("imaging — stop, snapshot the boot disk (a few minutes)") // Synchronous stop: images create wants the disk quiesced. if _, err := d.gcloud(ctx, "compute", "instances", "stop", id, "--zone", d.Zone); err != nil { return "", err diff --git a/internal/driver/gcpgce/cli.go b/internal/driver/gcpgce/cli.go index 3126988..db69ec3 100644 --- a/internal/driver/gcpgce/cli.go +++ b/internal/driver/gcpgce/cli.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -142,13 +143,13 @@ func (d *Driver) sshRunOpts(ctx context.Context, id string, extra []string, scri return strings.TrimSpace(string(out)), nil } -// sshStream is sshRun with output flowing straight to the terminal — for +// sshStream is sshRun with output streaming to d.Out as it arrives — for // long user-visible steps (the bake hook) where buffered output would look // like a hang. func (d *Driver) sshStream(ctx context.Context, id, script string) error { args := append(d.sshOpts(id), "agent@"+id, script) cmd := execCommandContext(ctx, "ssh", args...) - cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + cmd.Stdout, cmd.Stderr = d.out(), d.out() return cmd.Run() } @@ -158,9 +159,9 @@ func (d *Driver) scpTo(ctx context.Context, id, local, remote string, extra ...s args := append(append(d.sshOpts(id), extra...), local, "agent@"+id+":"+remote) cmd := execCommandContext(ctx, "scp", args...) // scp draws its progress meter only when stdout is a terminal — so big - // pushes (the repo bundle) show live progress interactively and stay - // silent when piped. - cmd.Stdout = os.Stdout + // pushes (the repo bundle) show live progress when the frontend hands + // its terminal in as Out, and stay silent otherwise. + cmd.Stdout = d.out() var errb bytes.Buffer cmd.Stderr = &errb if err := cmd.Run(); err != nil { @@ -207,3 +208,18 @@ func (d *Driver) waitSSH(ctx context.Context, id string, timeout time.Duration) } return fmt.Errorf("session %s not reachable after %s", id, timeout) } + +// out is Out, or a sink when the frontend wants no driver output. +func (d *Driver) out() io.Writer { + if d.Out == nil { + return io.Discard + } + return d.Out +} + +// notify passes a transport notice to the frontend, if it listens. +func (d *Driver) notify(msg string) { + if d.Notify != nil { + d.Notify(msg) + } +} diff --git a/internal/driver/gcpgce/driver.go b/internal/driver/gcpgce/driver.go index 7b9994b..fbfe8c0 100644 --- a/internal/driver/gcpgce/driver.go +++ b/internal/driver/gcpgce/driver.go @@ -29,6 +29,7 @@ import ( "encoding/json" "fmt" "hash/fnv" + "io" "os" "os/exec" "path" @@ -77,6 +78,13 @@ type Driver struct { // SupervisorBin returns the embedded pier-supervisor binary for an arch // ("arm64"/"amd64"). SupervisorBin func(arch string) ([]byte, error) + // Out receives transfer meters and streamed remote output (the bake + // hook). nil discards it: a frontend speaking a protocol on stdout (the + // `pier api` server) must never have a driver write there behind it. + Out io.Writer + // Notify receives one-off transport notices ("using the ssm tunnel"). + // nil discards them. + Notify func(string) principal string // cached } diff --git a/internal/pool/claim.go b/internal/pool/claim.go index a6ac7a5..e7efdc2 100644 --- a/internal/pool/claim.go +++ b/internal/pool/claim.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -58,12 +59,12 @@ func Claim(ctx context.Context, p Params, sessions []driver.Session, name, branc if member == nil { return nil, nil } - progress(fmt.Sprintf("claimed warm member %s — resuming", member.Name)) + progress(fmt.Sprintf("claimed ready session %s — resuming", member.Name)) if err := freshen(ctx, p, member, name, branch, baseRef); err != nil { progress("freshen failed (" + err.Error() + ")") - progress("destroying the member — its state is untrusted — and creating fresh") + progress("destroying that ready session — its state is untrusted — and creating fresh") if derr := p.Driver.Destroy(context.WithoutCancel(ctx), member.ID); derr != nil { - progress("destroy failed too (" + derr.Error() + ") — check `pier pool` for a leftover member") + progress("destroy failed too (" + derr.Error() + ") — check `pier repos` for a leftover ready session") } return nil, nil } @@ -122,7 +123,7 @@ func freshen(ctx context.Context, p Params, member *driver.Session, name, branch progress(n) } for _, push := range pl.Pushes { - if err := scpTo(ctx, opts, dest, push.Local, push.Remote); err != nil { + if err := scpTo(ctx, p.Out, opts, dest, push.Local, push.Remote); err != nil { if ctx.Err() != nil { return err } @@ -130,7 +131,7 @@ func freshen(ctx context.Context, p Params, member *driver.Session, name, branch // settles, exactly as during create. progress("push interrupted — retrying") time.Sleep(2 * time.Second) - if err := scpTo(ctx, opts, dest, push.Local, push.Remote); err != nil { + if err := scpTo(ctx, p.Out, opts, dest, push.Local, push.Remote); err != nil { return err } } @@ -146,12 +147,15 @@ func freshen(ctx context.Context, p Params, member *driver.Session, name, branch return nil } -func scpTo(ctx context.Context, opts []string, dest, local, remote string) error { +func scpTo(ctx context.Context, out io.Writer, opts []string, dest, local, remote string) error { args := slices.Concat(opts, []string{local, dest + ":" + remote}) cmd := exec.CommandContext(ctx, "scp", args...) // scp draws its progress meter only when stdout is a terminal, same as - // the drivers' pushes. - cmd.Stdout = os.Stdout + // the drivers' pushes; Params.Out decides whether there is one. + if out == nil { + out = io.Discard + } + cmd.Stdout = out var errb bytes.Buffer cmd.Stderr = &errb if err := cmd.Run(); err != nil { diff --git a/internal/pool/fill.go b/internal/pool/fill.go index e4629fc..236b484 100644 --- a/internal/pool/fill.go +++ b/internal/pool/fill.go @@ -31,30 +31,30 @@ func Fill(ctx context.Context, p Params) error { // two disagree about a member only across a re-bake racing a create. Known, // accepted: the claimer fails loudly and falls back to a fresh create. for _, m := range plan.Stale { - progress("recycling stale member " + m.Name) + progress("recycling stale ready session " + m.Name) if err := p.Driver.Destroy(ctx, m.ID); err != nil { return err } } if plan.Fill == 0 { - progress(fmt.Sprintf("pool %s is full (%d ready, %d filling)", repo, len(plan.Ready), plan.InFlight)) + progress(fmt.Sprintf("%s has its ready sessions (%d ready, %d filling)", repo, len(plan.Ready), plan.InFlight)) return nil } // Quota gate, conservative: at least 2 vCPUs per member. Pools must not // eat the headroom real sessions need; an unreadable quota is not fatal — // the create itself fails loudly on the provider's limit. if q, err := p.Driver.Headroom(ctx); err == nil && q.Limit > 0 && q.Limit-q.Used < 2*plan.Fill { - return fmt.Errorf("filling %d member(s) needs more vCPU headroom than %s has — raise the quota or shrink the pool", + return fmt.Errorf("filling %d ready session(s) needs more vCPU headroom than %s has — raise the quota or lower the ready count", plan.Fill, q.Detail) } hasSetup := len(SetupScript(p.RepoRoot)) > 0 for i := range plan.Fill { - progress(fmt.Sprintf("filling member %d/%d", i+1, plan.Fill)) + progress(fmt.Sprintf("filling ready session %d/%d", i+1, plan.Fill)) if err := fillOne(ctx, p, hasSetup); err != nil { return err } } - progress(fmt.Sprintf("pool %s is full (%d ready)", repo, len(plan.Ready)+plan.Fill)) + progress(fmt.Sprintf("%s has its ready sessions (%d ready)", repo, len(plan.Ready)+plan.Fill)) return nil } @@ -77,11 +77,11 @@ func fillOne(ctx context.Context, p Params, hasSetup bool) error { return err } if hasSetup { - p.progress()("waiting for .pier/setup.sh — a member is only warm once setup finished") + p.progress()("waiting for .pier/setup.sh — a session is only ready once setup finished") if err := waitSetup(ctx, p, sess.ID); err != nil { // An unfinished setup is exactly what the pool exists to avoid: // destroy rather than park a member that would claim cold. - p.progress()("destroying the failed member") + p.progress()("destroying the failed ready session") _ = p.Driver.Destroy(context.WithoutCancel(ctx), sess.ID) return err } diff --git a/internal/pool/pool.go b/internal/pool/pool.go index d642334..15c9171 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -14,6 +14,7 @@ import ( "encoding/hex" "fmt" "hash/fnv" + "io" "os" "path/filepath" "slices" @@ -55,6 +56,7 @@ type Params struct { Manifest []string // $HOME-relative files, same as the driver gets SessionEnv map[string]string // ~/.config/pier/env content, same as the driver gets Progress func(string) + Out io.Writer // scp meters during a claim; nil discards } func (p *Params) progress() func(string) { diff --git a/internal/tui/logs.go b/internal/tui/logs.go index abc52a7..7cbbd17 100644 --- a/internal/tui/logs.go +++ b/internal/tui/logs.go @@ -1,20 +1,30 @@ package tui import ( - "fmt" + "context" + "os" + "path/filepath" "regexp" "strings" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/usepier/pier/internal/ui" + "github.com/usepier/pier/pkg/pier" ) -// The l key: the session's setup log as a pager inside the TUI — scroll, -// live-follow while the setup still runs, esc back to the list. The CLI -// `pier logs` stays the raw pipeable path; this view answers "what is it -// doing" without leaving the screen. +// The l key: a session's setup log inside the app — scroll, live-follow +// while setup still runs, esc back. `pier logs` stays the raw pipeable path. + +type logView struct { + sess pier.Session + text string // sanitized; the source for rewrapping + lines []string // text wrapped to the viewer width + off int // first visible line + stick bool // pinned to the tail: new content keeps the end in view + loading bool + polling bool +} type logMsg struct { name string @@ -25,116 +35,127 @@ type logMsg struct { type logPollMsg struct{} // logPollCmd refetches on a slow cadence while the viewer is open, so a -// running setup streams in place. One ssh exec per tick over the tunnel — -// the same weight as the list's beacon probe. +// running setup streams in place — one ssh exec per tick, the same weight +// as the list's beacon read. func logPollCmd() tea.Cmd { return tea.Tick(4*time.Second, func(time.Time) tea.Msg { return logPollMsg{} }) } +func (m model) openLogs(s pier.Session) (tea.Model, tea.Cmd) { + if s.State != pier.StateFailed { + if err := pier.CheckReady(s); err != nil { + m.fail(err) + return m, nil + } + if s.State == pier.StateParked { + m.note(s.Name + " is parked — attach to resume it, or `pier logs " + s.Name + "`") + return m, nil + } + } + m.ov = ovLogs + m.log = logView{sess: s, stick: true, loading: true} + return m, m.fetchLog() +} + func (m model) fetchLog() tea.Cmd { - s := m.logSess - fetch := m.opts.FetchLog + s, be := m.log.sess, m.be return func() tea.Msg { - text, err := fetch(s) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // The viewer refetches every few seconds, so the transfer stays + // bounded; `pier logs` prints everything. + text, err := be.SetupLog(ctx, s, 2000) return logMsg{s.Name, text, err} } } -func (m model) updateLogs(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "q", "esc": - m.mode = modeList - m.status = "" - case "up", "k": - m.logOff = max(0, m.logOff-1) - m.logStick = false - case "down", "j": - m.logOff = min(m.maxLogOff(), m.logOff+1) - m.logStick = m.logOff == m.maxLogOff() - case "pgup", "b": - m.logOff = max(0, m.logOff-m.logBodyH()) - m.logStick = false - case "pgdown", " ": - m.logOff = min(m.maxLogOff(), m.logOff+m.logBodyH()) - m.logStick = m.logOff == m.maxLogOff() - case "g": - m.logOff, m.logStick = 0, false - case "G": - m.logOff, m.logStick = m.maxLogOff(), true - case "r": - if !m.logLoading { - m.logLoading = true - return m, m.fetchLog() +func (m model) onLog(msg logMsg) (tea.Model, tea.Cmd) { + if m.ov != ovLogs || msg.name != m.log.sess.Name { + return m, nil + } + m.log.loading = false + if msg.err != nil { + m.fail(msg.err) + } else { + m.log.text = sanitizeLog(msg.text) + m.log.relayout(m.logWidth()) + if m.log.stick { + m.log.off = m.log.maxOff(m.logBodyH()) } } + // Keep following while the log can still grow. + if m.log.sess.State != pier.StateFailed && !m.log.polling { + m.log.polling = true + return m, logPollCmd() + } return m, nil } -// setLogText replaces the viewer content: sanitize once, rewrap to the -// current width, and keep the view pinned to the tail while following. -func (m *model) setLogText(text string) { - m.logText = sanitizeLog(text) - m.relayoutLog() -} - -func (m *model) relayoutLog() { - m.logLines = wrapLines(m.logText, m.logWidth()) - if m.logStick || m.logOff > m.maxLogOff() { - m.logOff = m.maxLogOff() +func (m model) keyLogs(k tea.KeyMsg) (tea.Model, tea.Cmd) { + h := m.logBodyH() + switch k.String() { + case "esc", "q", "l": + m.ov = ovNone + m.log.polling = false + case "up", "k": + m.log.off = max(m.log.off-1, 0) + m.log.stick = false + case "down", "j": + m.log.off = min(m.log.off+1, m.log.maxOff(h)) + m.log.stick = m.log.off == m.log.maxOff(h) + case "pgup", "b": + m.log.off = max(m.log.off-h, 0) + m.log.stick = false + case "pgdown", " ", "f": + m.log.off = min(m.log.off+h, m.log.maxOff(h)) + m.log.stick = m.log.off == m.log.maxOff(h) + case "g", "home": + m.log.off, m.log.stick = 0, false + case "G", "end": + m.log.off, m.log.stick = m.log.maxOff(h), true } + return m, nil } -func (m model) logWidth() int { - if m.width >= 12 { - return m.width - 4 - } - return 96 +func (l *logView) relayout(width int) { + l.lines = wrapLines(l.text, width) } -func (m model) logBodyH() int { - if m.height >= 12 { - return m.height - 6 - } - return 20 -} +func (l logView) maxOff(h int) int { return max(len(l.lines)-h, 0) } -func (m model) maxLogOff() int { - return max(0, len(m.logLines)-m.logBodyH()) -} +// logWidth / logBodyH are the viewer panel's inner size. +func (m model) logWidth() int { return max(m.width-4, 20) } +func (m model) logBodyH() int { return max(m.bodyHeight()-2, 3) } func (m model) logsView() string { - var b strings.Builder - head := " " + ui.Title.Render("⚓ setup log") + ui.Dim.Render(" "+m.logSess.Name) - if m.logLoading && m.logText == "" { - head += ui.Dim.Render(" fetching…") - } - b.WriteString("\n" + head + "\n\n") - h := m.logBodyH() - end := min(len(m.logLines), m.logOff+h) - for _, line := range m.logLines[m.logOff:end] { - b.WriteString(" " + styleLogLine(line) + "\n") - } - if len(m.logLines) == 0 && !m.logLoading { - b.WriteString(ui.Dim.Render(" (empty log)") + "\n") + var lines []string + switch { + case m.log.loading && len(m.log.lines) == 0: + lines = []string{sDim.Render("fetching the log…")} + case len(m.log.lines) == 0: + lines = []string{sDim.Render("the log is empty so far")} + default: + end := min(m.log.off+h, len(m.log.lines)) + for _, l := range m.log.lines[m.log.off:end] { + lines = append(lines, styleLogLine(l)) + } } - b.WriteString("\n") - - if m.status != "" && m.statusBad { - b.WriteString(" " + ui.Bad.Render("! "+m.status) + "\n") + note := "following" + if !m.log.stick { + note = pct(m.log.off+h, len(m.log.lines)) } - info := fmt.Sprintf("%d lines", len(m.logLines)) - if m.maxLogOff() > 0 { - info = fmt.Sprintf("lines %d-%d of %d", m.logOff+1, end, len(m.logLines)) + if m.log.sess.State == pier.StateFailed { + note = "create log" } - if m.logStick { - info += " · following" + return panel("setup log · "+m.log.sess.Name, note, lines, m.width, m.bodyHeight(), true) +} + +func pct(seen, total int) string { + if total == 0 { + return "" } - b.WriteString(" " + ui.Dim.Render(info) + "\n") - b.WriteString(" " + ui.Keys("↑/↓", "scroll", "g/G", "top/end", "r", "refresh", "esc", "back") + "\n") - return b.String() + return itoa(min(seen, total)*100/total) + "%" } // styleLogLine keeps the body plain and lets pier's own outcome markers @@ -142,9 +163,11 @@ func (m model) logsView() string { func styleLogLine(l string) string { switch { case strings.HasPrefix(l, "pier setup: FAILED"): - return ui.Bad.Render(l) + return sBad.Render(l) case strings.HasPrefix(l, "pier setup: done"): - return ui.OK.Render(l) + return sOK.Render(l) + case strings.HasPrefix(l, "==>"): + return sAccent.Render(l) } return l } @@ -193,3 +216,15 @@ func wrapLines(text string, width int) []string { } return out } + +// tilde shortens a path under home to ~/… for display. +func tilde(p string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + if rest, ok := strings.CutPrefix(p, home+string(filepath.Separator)); ok { + return "~/" + rest + } + return p +} diff --git a/internal/tui/style.go b/internal/tui/style.go new file mode 100644 index 0000000..0fa2f49 --- /dev/null +++ b/internal/tui/style.go @@ -0,0 +1,185 @@ +package tui + +import ( + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "github.com/usepier/pier/pkg/pier" +) + +// The look: ANSI palette colors, so pier follows the user's terminal theme; +// one teal accent; titled panels with the title set into the border (btop); +// a soft highlight bar for the selected row (opencode); a colored state dot +// per session (herdr). Everything else stays quiet. +var ( + cAccent = lipgloss.Color("6") + cDim = lipgloss.Color("8") + cFaint = lipgloss.Color("7") + cOK = lipgloss.Color("2") + cWarn = lipgloss.Color("3") + cBad = lipgloss.Color("1") + cStrain = lipgloss.Color("208") + cSelBg = lipgloss.AdaptiveColor{Light: "254", Dark: "236"} + + sAccent = lipgloss.NewStyle().Foreground(cAccent) + sBold = lipgloss.NewStyle().Bold(true) + sDim = lipgloss.NewStyle().Foreground(cDim) + sFaint = lipgloss.NewStyle().Foreground(cFaint) + sOK = lipgloss.NewStyle().Foreground(cOK) + sWarn = lipgloss.NewStyle().Foreground(cWarn) + sBad = lipgloss.NewStyle().Foreground(cBad) + sStrain = lipgloss.NewStyle().Foreground(cStrain) + sBrand = lipgloss.NewStyle().Foreground(cAccent).Bold(true) + + sTabOn = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(cAccent).Bold(true).Padding(0, 1) + sTabOff = lipgloss.NewStyle().Foreground(cFaint).Padding(0, 1) + sSel = lipgloss.NewStyle().Background(cSelBg).Bold(true) + sKey = lipgloss.NewStyle().Foreground(cAccent).Bold(true) +) + +// panel draws a rounded box w wide and h tall with title set into the top +// border and an optional right-aligned note beside it. Lines are clipped to +// the inner width and padded; missing lines render blank. A focused panel +// draws its border in the accent. +func panel(title, note string, lines []string, w, h int, focused bool) string { + if w < 8 { + w = 8 + } + if h < 3 { + h = 3 + } + bc := sDim + tc := sFaint.Bold(true) + if focused { + bc = sAccent + tc = sBrand + } + inner := w - 2 + content := inner - 2 + + head := bc.Render("─ ") + tc.Render(title) + " " + if note != "" { + n := " " + sDim.Render(note) + " " + pad := inner - lipgloss.Width(head) - lipgloss.Width(n) - 1 + if pad < 1 { + n, pad = "", inner-lipgloss.Width(head) + } + head += bc.Render(strings.Repeat("─", max(pad, 0))) + n + bc.Render("─") + } else { + head += bc.Render(strings.Repeat("─", max(inner-lipgloss.Width(head), 0))) + } + head = ansi.Truncate(head, inner, "") + var b strings.Builder + b.WriteString(bc.Render("╭") + head + bc.Render("╮") + "\n") + for i := 0; i < h-2; i++ { + l := "" + if i < len(lines) { + l = lines[i] + } + b.WriteString(bc.Render("│") + " " + fit(l, content) + " " + bc.Render("│") + "\n") + } + b.WriteString(bc.Render("╰" + strings.Repeat("─", inner) + "╯")) + return b.String() +} + +// fit clips s to w visible cells and pads it to exactly w. +func fit(s string, w int) string { + if w <= 0 { + return "" + } + s = ansi.Truncate(s, w, "…") + if pad := w - lipgloss.Width(s); pad > 0 { + s += strings.Repeat(" ", pad) + } + return s +} + +// selected renders a row as the highlight bar: styles stripped (their +// resets would punch holes in the background), full width. +func selected(s string, w int) string { + return sSel.Render(fit(ansi.Strip(s), w)) +} + +// keys renders a hint strip: keys("enter", "attach", "q", "quit"). +func keys(pairs ...string) string { + var parts []string + for i := 0; i+1 < len(pairs); i += 2 { + parts = append(parts, sKey.Render(pairs[i])+" "+sDim.Render(pairs[i+1])) + } + return strings.Join(parts, " ") +} + +var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// badge is a session's state as a colored dot and word, with the +// supervisor's strain and setup flags appended. +func badge(s pier.Session, frame int) (dot, word string) { + switch s.State { + case pier.StateWorking: + dot, word = sOK.Render("●"), sOK.Render("working") + case pier.StateRunning: + dot, word = sAccent.Render("●"), sAccent.Render("attached") + case pier.StateIdle: + dot, word = sWarn.Render("●"), sWarn.Render("idle") + case pier.StateCreating: + dot, word = sAccent.Render(spinFrames[frame%len(spinFrames)]), sAccent.Render("creating") + case pier.StateParked: + dot, word = sDim.Render("○"), sDim.Render("parked") + case pier.StateDeleting: + dot, word = sDim.Render("◌"), sDim.Render("deleting") + case pier.StateFailed: + dot, word = sBad.Render("✗"), sBad.Render("create failed") + default: + dot, word = sBad.Render("✗"), sBad.Render(string(s.State)) + } + if s.Strained { + word += sStrain.Render(" · strained") + } + switch s.Setup { + case "running": + word += sAccent.Render(" · setup") + case "failed": + word += sBad.Render(" · setup failed") + } + return dot, word +} + +// field renders a detail row: a dim fixed-width label, then the value. +func field(label, value string) string { + return sDim.Render(fit(label, 10)) + value +} + +func ago(t time.Time) string { + a := pier.Age(t, time.Now()) + if a == "now" || a == "-" { + return a + } + return a + " ago" +} + +// wrap breaks plain text into lines of at most w cells, on spaces. +func wrap(text string, w int) []string { + if w < 10 { + w = 10 + } + var out []string + for _, para := range strings.Split(text, "\n") { + line := "" + for _, word := range strings.Fields(para) { + switch { + case line == "": + line = word + case len(line)+1+len(word) > w: + out = append(out, line) + line = word + default: + line += " " + word + } + } + out = append(out, line) + } + return out +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7990d6d..ad05597 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,283 +1,189 @@ -// Package tui is the bare `pier` screen: a session list you can attach to, -// plus new/delete/pin/refresh. Full-screen on the alternate buffer, one -// accent color, states color-coded; quitting restores the shell untouched. -// Attach hands the terminal to ssh via tea.ExecProcess, so a tmux detach -// lands back on this list instead of the shell. Quota loads asynchronously -// so the screen opens instantly; a `loaded` flag separates "fetching" from -// "genuinely empty" so the list never flashes "0 sessions" before the first -// fetch lands. +// Package tui is pier's app: Sessions, Repos and Settings tabs over the +// pkg/pier API. It holds no behavior of its own — every action is one call +// on the Backend — so the CLI, this TUI, and the Mac app stay in step. package tui import ( + "context" "errors" - "fmt" "os/exec" - "sort" + "path/filepath" + "strconv" "strings" "time" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/usepier/pier/internal/config" - "github.com/usepier/pier/internal/driver" - "github.com/usepier/pier/internal/pool" - "github.com/usepier/pier/internal/ui" + "github.com/usepier/pier/pkg/pier" ) +// Backend is the slice of *pier.Client the app uses. An interface so tests +// can drive the app without a cloud. +type Backend interface { + Cloud() string + Sessions(ctx context.Context) ([]pier.Session, error) + Repos(sessions []pier.Session, curRoot string) []pier.Repo + Headroom(ctx context.Context) (pier.Quota, error) + Remove(ctx context.Context, s pier.Session) error + Keep(ctx context.Context, s pier.Session) error + Resume(ctx context.Context, s pier.Session) error + Resize(ctx context.Context, s pier.Session, machineType string) error + Machines(s pier.Session) []pier.Machine + AttachCommand(ctx context.Context, s pier.Session) (*exec.Cmd, error) + WaitReachable(ctx context.Context, s pier.Session, timeout time.Duration) error + SetupLog(ctx context.Context, s pier.Session, lines int) (string, error) + SpawnCreate(repoRoot, branch string) (string, error) + SpawnBake(repoRoot string) (string, error) + SetReady(ctx context.Context, repoRoot string, n int, progress pier.Progress) (string, error) + DrainReady(ctx context.Context, repo string, progress pier.Progress) (int, error) + Settings() []pier.SettingValue + Set(key, value string) error + MachineCatalog(currentType string) []pier.Machine + ReauthCommand() *exec.Cmd + DiskMonthlyUSD() float64 +} + +// Options configures the app. type Options struct { - FetchQuota func() string // e.g. "12/32 vCPUs in use"; nil/"" hides it - Fetch func() ([]driver.Session, error) - // AuthExpired identifies a fetch error that an interactive login can fix; - // Reauthenticate builds that foreground login command. Together they make - // enter recover the list in place instead of forcing a trip back to shell. - AuthExpired func(error) bool - Reauthenticate func() *exec.Cmd - Destroy func(driver.Session) error - Pin func(driver.Session) error - // CreateDetached starts a background create and returns its log path; - // the TUI stays open and the session appears in the list as "creating". - // nil disables creating from the TUI (tests). - CreateDetached func(branch string) (logPath string, err error) - // Resize + Machines power the m key: Machines lists same-arch picks for a - // session so nobody memorizes type names. nil hides the key. - Resize func(s driver.Session, instanceType string) error - Machines func(s driver.Session) []driver.Machine - // SettingsMachines is the machine catalog for the settings machine picker, - // given a driver id ("aws-ec2"/"gcp-gce") and the currently configured - // type. nil falls the machine fields back to free-text editing. - SettingsMachines func(driverID, currentType string) []driver.Machine - // FetchLog returns a session's setup log for the l key's in-place viewer. - // nil hides the key. - FetchLog func(s driver.Session) (string, error) - // Attach returns a fresh ssh command for one attach attempt; the TUI runs - // it via tea.ExecProcess so the program survives a detach and lands back - // on the list. nil disables the enter key (tests). - Attach func(s driver.Session) (*exec.Cmd, error) - // Resume unparks a session's VM and blocks until its transport answers. - Resume func(s driver.Session) error - // RetryAttach + WaitReachable power the one bounded reconnect after a fast - // ssh transport failure (fresh or just-resumed VMs); nil disables the retry. - RetryAttach func(err error, elapsed time.Duration) bool - WaitReachable func(s driver.Session) error - // Pools power the w key's warm-pool page and the header's warm count. - // PoolSizes re-reads the configured repo→size map (config may change - // between opens). CurrentRepo/CurrentGen describe the repo pier launched - // from ("" outside a repo): only its row is size-editable — a fill needs - // the local checkout — and only its members can be staleness-checked (the - // generation hashes the local setup script). nil PoolSizes hides the page. - PoolSizes func() map[string]int - CurrentRepo string - CurrentGen string - // PoolMaxAge is the configured member recycle age (0 = don't check); - // members past it show stale. PoolNote explains a pool page that is - // read-only for a reason worth a sentence (a broken [pool] config, say) - // instead of the generic run-from-a-repo hint. - PoolMaxAge time.Duration - PoolNote string - // PoolSet saves the current repo's size and reconciles: 0 drains inline, - // >0 starts a detached fill and returns its log path. - PoolSet func(size int) (logPath string, err error) - // PoolFill starts a detached fill for the current repo. - PoolFill func() (logPath string, err error) - // PoolDrain destroys a repo's warm members — any repo; draining needs no - // local checkout — and returns how many went down. - PoolDrain func(repo string) (int, error) + // Open builds a fresh backend: at start, and again after a settings + // change that alters which cloud calls go to. + Open func() (Backend, error) + RepoRoot string // the repo pier launched from; "" outside one + Version string } -// PoolStats is one repo's warm-member census, shared by the TUI page and -// `pier pool`. -type PoolStats struct { - Ready, Filling, Stale int - Ages []string // ready members' ages, newest first as listed -} +// ErrNoConfig means pier has never been set up on this machine. +var ErrNoConfig = errors.New("pier is not set up") -// FoldPool folds one repo's unclaimed members out of a session list, judging -// them the way reconcile would: dead, wrong-generation, over the recycle age, -// or unparked past the fill grace all count stale — a member whose fill died -// hours ago must not read "filling" forever. curGen gates the generation -// check: only the repo the process runs from can compute its current -// generation, so other repos' members never show gen-stale (they recycle on -// their own machines' claims). maxAge 0 skips the age check. -func FoldPool(sessions []driver.Session, repo, curRepo, curGen string, maxAge time.Duration, now time.Time) PoolStats { - var st PoolStats - for _, s := range sessions { - if s.PoolGen == "" || s.Repo != repo { - continue - } - lived := now.Sub(s.Created) - switch { - case s.State == driver.StateDeleting: - case s.State == driver.StateDead, - repo == curRepo && s.PoolGen != curGen, - maxAge > 0 && lived >= maxAge: - st.Stale++ - case s.State == driver.StateParked: - st.Ready++ - st.Ages = append(st.Ages, age(s.Created)) - case lived >= pool.FillGrace: - st.Stale++ - default: - st.Filling++ +// Run opens the app full-screen until the user quits. +func Run(opts Options) error { + be, err := opts.Open() + if err != nil { + if strings.Contains(err.Error(), "no config at") { + return ErrNoConfig } + return err } - return st -} - -func Run(opts Options) error { - _, err := tea.NewProgram(model{opts: opts, loading: true}, tea.WithAltScreen()).Run() + _, err = tea.NewProgram(newModel(opts, be), tea.WithAltScreen()).Run() return err } -type mode int +type tab int const ( - modeList mode = iota - modeNew - modeConfirm - modeSettings - modeResize - modeLogs - modePool + tabSessions tab = iota + tabRepos + tabSettings +) + +var tabNames = []string{"Sessions", "Repos", "Settings"} + +// overlay is what sits on top of the current tab, if anything. +type overlay int + +const ( + ovNone overlay = iota + ovNew // branch name input + ovConfirm // y/n on confirmAction + ovResize // machine picker for the selected session + ovLogs // setup log viewer + ovPick // settings choice picker + ovEdit // settings free-text editor + ovHelp ) type model struct { - opts Options - sessions []driver.Session - members []driver.Session // unclaimed warm pool members, split from sessions - quota string - cursor int - mode mode - input string // branch name being typed in modeNew - status string // transient line: errors, confirmations - statusBad bool // status is an error (red) vs a notice (accent) - loading bool - loaded bool // first fetch has landed - // recoverable authentication state; while required, enter runs the - // foreground provider login instead of acting on a session row - authRequired bool - reauthenticating bool - polling bool // an auto-refresh poll is scheduled (creates in flight) - watch int // poll rounds left after a spawn (until it's listable) - frame int // spinner frame - // attach-in-flight state; enter is a no-op while attachSess is set - attachSess driver.Session // row being attached; zero Name = none in flight - attachStart time.Time // last ExecProcess launch; feeds RetryAttach - attachRetried bool // one bounded reconnect per attach, like the CLI - // settings page state; cfg loads fresh each time s opens the page - cfg *config.Config - setIdx int // index into config.Settings (the selected field) - editing bool // free-text editor open for the selected field - setInput string - // settings picker sub-state: a choice/machine field's dropdown - picking bool - pickOpts []config.Option // options shown (machine catalog converted in) + opts Options + be Backend + repo string // basename of opts.RepoRoot + + tab tab + ov overlay + + all []pier.Session // everything Sessions() returned + sessions []pier.Session // real sessions (ready ones split out) + ready []pier.Session + repos []pier.Repo + quota string + + loading, loaded bool + authRequired bool + + sessIdx, repoIdx, setIdx int + + input string // ovNew branch / ovEdit value + + // ovConfirm: the question and what a yes runs. + confirmQ string + confirmDo func() tea.Cmd + + status string + statusBad bool + statusAt time.Time + + frame int + polling bool + watch int // extra poll rounds after a spawn, until the new row shows + + // attach in flight: one bounded reconnect per attach, like the CLI + attaching string + attachStart time.Time + attachRetried bool + + machines []pier.Machine pickIdx int - // resize picker state; machines reload each time m opens the picker - machines []driver.Machine - machIdx int - // pool page state (modePool); sizes reload each time w opens the page - poolRepos []string // rows: configured repos ∪ repos with members ∪ current repo - poolSizes map[string]int - poolIdx int - poolPend int // pending size for the current repo's row; -1 = none - poolConfirm bool // drain y/n pending - // log viewer state (modeLogs); content refetches on a slow cadence so a - // still-running setup streams in place - logSess driver.Session - logText string // sanitized log, source of truth for rewrapping - logLines []string // logText wrapped to the terminal width - logOff int // first visible wrapped line - logStick bool // pinned to the tail: new content keeps the end in view - logLoading bool - logPolling bool - width int - height int -} + pickOpts []config.Option -func anyCreating(sessions []driver.Session) bool { - for _, s := range sessions { - if s.State == driver.StateCreating { - return true - } - } - return false -} + settings []pier.SettingValue -// anyFilling reports whether an unclaimed member is mid-fill (not yet parked); -// the open pool page keeps refreshing while one is. -func anyFilling(members []driver.Session) bool { - for _, s := range members { - switch s.State { - case driver.StateParked, driver.StateDead, driver.StateDeleting: - default: - return true - } - } - return false + log logView + + width, height int } -// splitMembers separates unclaimed pool members from real sessions: members -// never sit in the session table (they're plumbing, not work) — they show on -// the pool page and in the header's warm count. -func splitMembers(all []driver.Session) (sessions, members []driver.Session) { - for _, s := range all { - if s.PoolGen != "" { - members = append(members, s) - } else { - sessions = append(sessions, s) - } +func newModel(opts Options, be Backend) model { + m := model{opts: opts, be: be, loading: true, settings: be.Settings()} + if opts.RepoRoot != "" { + m.repo = filepath.Base(opts.RepoRoot) } - return sessions, members + return m } +// --- messages --------------------------------------------------------------------- + type sessionsMsg struct { - sessions []driver.Session - err error + all []pier.Session + err error } - type quotaMsg string - type tickMsg struct{} - -// pollMsg drives the slow auto-refresh that runs while a session is creating, -// so a background create's progress lands in the list without pressing r. type pollMsg struct{} -// spawnedMsg reports a detached create kicked off (or failed to). -type spawnedMsg struct { - branch string - log string - err error -} - -// poolActMsg reports an async pool action (size apply, fill, drain). -type poolActMsg struct { - note string - err error +// doneMsg reports a background action; refresh asks for a new list. +type doneMsg struct { + note string + err error + refresh bool + watch bool // a new row is on its way: poll until it shows } - -// resumedMsg reports a parked VM's blocking resume finished (or failed). type resumedMsg struct { - s driver.Session + s pier.Session err error } - -// attachDoneMsg arrives when the foreground ssh exits: detach, remote exit, -// or transport error. type attachDoneMsg struct { - s driver.Session + s pier.Session err error } - -// reachableMsg reports the bounded wait before the one attach retry. type reachableMsg struct { - s driver.Session + s pier.Session + err error +} +type reauthMsg struct{ err error } +type reopenedMsg struct { + be Backend err error } - -// reauthenticatedMsg arrives after the foreground cloud login exits. -type reauthenticatedMsg struct{ err error } func tickCmd() tea.Cmd { return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) @@ -288,1437 +194,626 @@ func pollCmd() tea.Cmd { } func (m model) fetch() tea.Msg { - ss, err := m.opts.Fetch() - return sessionsMsg{ss, err} + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + all, err := m.be.Sessions(ctx) + return sessionsMsg{all, err} +} + +func (m model) fetchQuota() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + q, err := m.be.Headroom(ctx) + if err != nil { + return quotaMsg("") + } + return quotaMsg(q.Detail) } func (m model) Init() tea.Cmd { - cmds := []tea.Cmd{m.fetch, tickCmd()} - if m.opts.FetchQuota != nil { - cmds = append(cmds, func() tea.Msg { return quotaMsg(m.opts.FetchQuota()) }) + return tea.Batch(m.fetch, m.fetchQuota, tickCmd()) +} + +// act runs f in the background and reports its outcome as a doneMsg. +func act(f func() doneMsg) tea.Cmd { return func() tea.Msg { return f() } } + +func (m *model) note(s string) { m.status, m.statusBad, m.statusAt = s, false, time.Now() } +func (m *model) fail(err error) { + m.status, m.statusBad, m.statusAt = err.Error(), true, time.Now() +} + +// inFlight reports whether something is changing on its own (a create, a +// refill, a bake), so the list keeps refreshing without a keypress. +func (m model) inFlight() bool { + for _, s := range m.all { + switch s.State { + case pier.StateCreating, pier.StateDeleting: + return true + } + if s.PoolGen != "" && s.State != pier.StateParked && s.State != pier.StateDead { + return true + } + } + for _, r := range m.repos { + if pier.BakeRunning(r.Name) { + return true + } } - return tea.Batch(cmds...) + return false } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.log.relayout(m.logWidth()) + return m, nil + + case tickMsg: + m.frame++ + // Notices fade; errors stay until the next action. + if m.status != "" && !m.statusBad && time.Since(m.statusAt) > 8*time.Second { + m.status = "" + } + return m, tickCmd() + + case quotaMsg: + m.quota = string(msg) + return m, nil + case sessionsMsg: m.loading = false - m.loaded = true if msg.err != nil { - m.authRequired = m.opts.AuthExpired != nil && m.opts.AuthExpired(msg.err) - m.status, m.statusBad = msg.err.Error(), true + if pier.IsAuthExpired(msg.err) { + m.authRequired = true + m.status, m.statusBad = "your cloud login expired — enter signs in again", true + return m, nil + } + m.fail(msg.err) return m, nil } - m.authRequired = false - m.sessions, m.members = splitMembers(msg.sessions) - if m.cursor >= len(m.sessions) { - m.cursor = max(0, len(m.sessions)-1) - } - if m.mode == modePool { - m.reindexPools() - } - // While anything is still creating, keep refreshing on our own so a - // background create walks to "running" without the user pressing r — - // likewise while the open pool page has a member mid-fill. - if !m.polling && (anyCreating(m.sessions) || m.mode == modePool && anyFilling(m.members)) { - m.polling = true - return m, pollCmd() - } - return m, nil - case pollMsg: - m.polling = false + m.loaded, m.authRequired = true, false + m.all = msg.all + m.sessions, m.ready = pier.SplitReady(msg.all) + m.repos = m.be.Repos(msg.all, m.opts.RepoRoot) + m.sessIdx = clamp(m.sessIdx, len(m.sessions)) + m.repoIdx = clamp(m.repoIdx, len(m.repos)) if m.watch > 0 { m.watch-- } - // watch keeps the chain alive right after a spawn, before the new - // instance is visible in the provider's list. - if m.watch > 0 || anyCreating(m.sessions) || m.mode == modePool && anyFilling(m.members) { - m.polling = true - return m, tea.Batch(m.fetch, pollCmd()) // silent refresh: no spinner flicker - } - return m, nil - case spawnedMsg: - if msg.err != nil { - m.status, m.statusBad = msg.err.Error(), true - return m, nil - } - // Terse on purpose: the ◐ row appearing below already says "watch - // here", and every extra word pushes the log path off the right edge - // of a ~100-column terminal. - m.status, m.statusBad = "creating "+msg.branch+" in the background (log: "+ui.Tilde(msg.log)+")", false - m.watch = 12 // ~60s of polling even before the instance shows up - if !m.polling { + if (m.inFlight() || m.watch > 0) && !m.polling { m.polling = true return m, pollCmd() } return m, nil - case quotaMsg: - m.quota = string(msg) - return m, nil - case poolActMsg: - m.loading = false + + case pollMsg: + m.polling = false + m.loading = true + return m, m.fetch + + case doneMsg: if msg.err != nil { - m.status, m.statusBad = msg.err.Error(), true - return m, nil + m.fail(msg.err) + } else if msg.note != "" { + m.note(msg.note) } - m.status, m.statusBad = msg.note, false - if m.opts.PoolSizes != nil { - m.poolSizes = m.opts.PoolSizes() // a size apply just changed config + if msg.watch { + m.watch = 12 } - m.reindexPools() - m.loading = true - // A detached fill takes a beat to launch its instance; without the - // watch window the poll chain would die on the first refresh that - // still shows nothing filling — same grace a spawned create gets. - m.watch = max(m.watch, 24) // ~2min - cmds := []tea.Cmd{m.fetch, tickCmd()} - if !m.polling { // a fill may now be in flight — watch it land - m.polling = true - cmds = append(cmds, pollCmd()) + if msg.refresh { + m.loading = true + return m, m.fetch } - return m, tea.Batch(cmds...) + return m, nil + case resumedMsg: - m.loading = false if msg.err != nil { - m.attachSess = driver.Session{} - m.status, m.statusBad = "resume "+msg.s.Name+": "+msg.err.Error(), true + m.attaching = "" + m.fail(msg.err) return m, nil } - return m.startAttach(msg.s) + msg.s.State = pier.StateRunning + return m.execAttach(msg.s) + case attachDoneMsg: - if msg.err == nil { // clean detach or remote exit - m.attachSess, m.attachRetried = driver.Session{}, false - m.loading = true - m.status, m.statusBad = "detached from "+msg.s.Name+" — it keeps running", false - return m, tea.Batch(m.fetch, tickCmd()) - } - if !m.attachRetried && m.opts.RetryAttach != nil && m.opts.WaitReachable != nil && - m.opts.RetryAttach(msg.err, time.Since(m.attachStart)) { + if msg.err != nil && !m.attachRetried && pier.RetryAttach(msg.err, time.Since(m.attachStart)) { m.attachRetried = true - m.loading = true - m.status, m.statusBad = "not reachable yet — waiting for "+msg.s.Name+" to come online (~30-60s)", false - wait := m.opts.WaitReachable - return m, tea.Batch(tickCmd(), func() tea.Msg { return reachableMsg{msg.s, wait(msg.s)} }) + m.note("not reachable yet — waiting for " + msg.s.Name + " to come online") + s := msg.s + return m, func() tea.Msg { + return reachableMsg{s, m.be.WaitReachable(context.Background(), s, 4*time.Minute)} + } } - status := "attach " + msg.s.Name + ": " + msg.err.Error() - var exitErr *exec.ExitError - if errors.As(msg.err, &exitErr) && exitErr.ExitCode() != 255 && time.Since(m.attachStart) < 15*time.Second { - // the remote bootstrap-marker guard exits 1 fast when setup is - // still running — point at the log instead of a bare exit status - status += " — it may still be setting up; l shows the setup log" + m.attaching = "" + if msg.err != nil { + m.fail(errors.New("attach: " + msg.err.Error())) } - m.attachSess, m.attachRetried = driver.Session{}, false m.loading = true - m.status, m.statusBad = status, true - return m, tea.Batch(m.fetch, tickCmd()) // the refreshed state often explains it + return m, m.fetch + case reachableMsg: - m.loading = false if msg.err != nil { - m.attachSess, m.attachRetried = driver.Session{}, false - m.status, m.statusBad = msg.err.Error(), true + m.attaching = "" + m.fail(msg.err) return m, nil } - return m.startAttach(msg.s) // second and final attempt; attachRetried stays set - case reauthenticatedMsg: - m.reauthenticating = false + return m.execAttach(msg.s) + + case reauthMsg: if msg.err != nil { - m.status, m.statusBad = "reauthentication failed: "+msg.err.Error(), true + m.fail(errors.New("sign-in didn't finish: " + msg.err.Error())) return m, nil } - m.authRequired = false - m.loaded, m.loading = false, true - m.status, m.statusBad = "authenticated — refreshing sessions…", false - return m, tea.Batch(m.fetch, tickCmd()) - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - if m.mode == modeLogs { - m.relayoutLog() - } - return m, nil - case logMsg: - if m.mode != modeLogs || msg.name != m.logSess.Name { - return m, nil // stale fetch from a viewer already left - } - m.logLoading = false + m.loading = true + m.note("signed in — refreshing") + return m, m.fetch + + case reopenedMsg: if msg.err != nil { - m.status, m.statusBad = msg.err.Error(), true + m.fail(msg.err) return m, nil } - m.status = "" - m.setLogText(msg.text) - return m, nil + m.be = msg.be + m.settings = m.be.Settings() + m.loading = true + return m, tea.Batch(m.fetch, m.fetchQuota) + + case logMsg: + return m.onLog(msg) + case logPollMsg: - m.logPolling = false - if m.mode != modeLogs { + if m.ov != ovLogs { + m.log.polling = false return m, nil } - m.logPolling = true - cmds := []tea.Cmd{logPollCmd()} - if !m.logLoading { - m.logLoading = true - cmds = append(cmds, m.fetchLog()) - } - return m, tea.Batch(cmds...) - case tickMsg: - if m.loading { - m.frame++ - return m, tickCmd() - } - return m, nil + return m, m.fetchLog() + case tea.KeyMsg: - switch m.mode { - case modeNew: - return m.updateNew(msg) - case modeConfirm: - return m.updateConfirm(msg) - case modeSettings: - return m.updateSettings(msg) - case modeResize: - return m.updateResize(msg) - case modeLogs: - return m.updateLogs(msg) - case modePool: - return m.updatePool(msg) - } - return m.updateList(msg) + return m.onKey(msg) } return m, nil } -func (m model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Expired auth is a focused recovery state: enter temporarily hands the - // terminal to the provider login, then a successful login refetches the - // list. Keep the original error visible until that happens. +func clamp(i, n int) int { + if i >= n { + i = n - 1 + } + if i < 0 { + i = 0 + } + return i +} + +// --- keys -------------------------------------------------------------------------- + +func (m model) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if k.String() == "ctrl+c" { + return m, tea.Quit + } + switch m.ov { + case ovNew: + return m.keyNew(k) + case ovConfirm: + return m.keyConfirm(k) + case ovResize: + return m.keyResize(k) + case ovLogs: + return m.keyLogs(k) + case ovPick: + return m.keyPick(k) + case ovEdit: + return m.keyEdit(k) + case ovHelp: + m.ov = ovNone + return m, nil + } + if m.authRequired { - switch msg.String() { - case "q", "ctrl+c", "esc": - return m, tea.Quit + switch k.String() { case "enter": - if m.reauthenticating || m.opts.Reauthenticate == nil { - return m, nil - } - cmd := m.opts.Reauthenticate() - if cmd == nil { - return m, nil - } - m.reauthenticating = true - m.status, m.statusBad = "reauthenticating…", false - return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return reauthenticatedMsg{err} }) + return m, tea.ExecProcess(m.be.ReauthCommand(), func(err error) tea.Msg { return reauthMsg{err} }) + case "q", "esc": + return m, tea.Quit } return m, nil } - m.status = "" - switch msg.String() { - case "q", "ctrl+c", "esc": + + switch k.String() { + case "q": return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.sessions)-1 { - m.cursor++ - } - case "enter": - if len(m.sessions) == 0 || m.opts.Attach == nil || m.attachSess.Name != "" { - return m, nil - } - s := m.sessions[m.cursor] - switch s.State { - case driver.StateCreating: - m.status, m.statusBad = s.Name+" is still setting up — attach when it shows running", false - return m, nil - case driver.StateDeleting: - m.status, m.statusBad = s.Name+" is being deleted", false - return m, nil - case driver.StateDead: - m.status, m.statusBad = s.Name+" is dead — no VM to attach to", false + case "esc": + if m.tab != tabSessions { + m.tab = tabSessions return m, nil - case driver.StateFailed: - m.status, m.statusBad = s.Name+" never finished creating — n starts it again, d clears the row", false - return m, nil - case driver.StateParked: - if m.opts.Resume == nil { - m.status, m.statusBad = s.Name+" is parked — pier attach "+s.Name+" resumes it", false - return m, nil - } - m.attachSess, m.loading = s, true - m.status, m.statusBad = "resuming "+s.Name+" (~20-60s)…", false - resume := m.opts.Resume - return m, tea.Batch(tickCmd(), func() tea.Msg { return resumedMsg{s, resume(s)} }) } - return m.startAttach(s) - case "l": - if len(m.sessions) > 0 && m.opts.FetchLog != nil { - s := m.sessions[m.cursor] - switch s.State { - case driver.StateCreating: - m.status, m.statusBad = s.Name+" is still setting up — logs once it shows running", false - return m, nil - case driver.StateDeleting: - m.status, m.statusBad = s.Name+" is being deleted", false - return m, nil - case driver.StateParked: - // resuming a VM is a billing change — never a side effect of - // opening a log view - m.status, m.statusBad = s.Name+" is parked — pier logs "+s.Name+" resumes it and prints the log", false - return m, nil - case driver.StateDead: - m.status, m.statusBad = s.Name+" is dead — no VM to read the log from", false - return m, nil - case driver.StateFailed: - // The create log is local and outlived the instance, so a - // tombstone can still explain itself — as long as the create - // wrote one (a foreground create printed to the terminal). - if s.LogPath == "" { - m.status, m.statusBad = s.Name+": "+s.FailReason, true - return m, nil - } - } - m.mode = modeLogs - m.logSess, m.logText, m.logLines = s, "", nil - m.logOff, m.logStick, m.logLoading = 0, true, true - cmds := []tea.Cmd{m.fetchLog()} - if !m.logPolling { - m.logPolling = true - cmds = append(cmds, logPollCmd()) - } - return m, tea.Batch(cmds...) - } - case "n": - m.mode = modeNew - m.input = "" - case "d": - if len(m.sessions) > 0 { - m.mode = modeConfirm - } - case "p": - if len(m.sessions) > 0 { - s := m.sessions[m.cursor] - if s.State == driver.StateFailed { - m.status, m.statusBad = s.Name+" never finished creating — nothing to pin", false - return m, nil - } - m.loading = true - return m, tea.Batch(func() tea.Msg { - if err := m.opts.Pin(s); err != nil { - return sessionsMsg{nil, err} - } - return m.fetch() - }, tickCmd()) - } - case "m": - if len(m.sessions) > 0 && m.opts.Resize != nil && m.opts.Machines != nil { - s := m.sessions[m.cursor] - if s.State == driver.StateCreating { - m.status, m.statusBad = s.Name+" is still setting up — resize once it shows running", false - return m, nil - } - if s.State == driver.StateFailed { - m.status, m.statusBad = s.Name+" never finished creating — nothing to resize", false - return m, nil - } - if s.State == driver.StateDeleting { - m.status, m.statusBad = s.Name+" is being deleted", false - return m, nil - } - m.machines = m.opts.Machines(s) - if len(m.machines) == 0 { - m.status, m.statusBad = "no machine picks for this driver — use pier resize ", false - return m, nil - } - m.machIdx = 0 - for i, mc := range m.machines { - if mc.Type == s.InstanceType { - m.machIdx = i - } - } - m.mode = modeResize - } - case "w": - if m.opts.PoolSizes == nil { - return m, nil - } - return m.openPools() - case "s": - cfg, err := config.Load() - if err != nil { - m.status, m.statusBad = err.Error(), true - return m, nil - } - m.cfg, m.setIdx, m.editing, m.picking = &cfg, 0, false, false - m.mode = modeSettings + return m, tea.Quit + case "tab", "right": + m.tab = (m.tab + 1) % 3 + return m, nil + case "shift+tab", "left": + m.tab = (m.tab + 2) % 3 + return m, nil + case "1", "2", "3": + m.tab = tab(k.String()[0] - '1') + return m, nil + case "?": + m.ov = ovHelp + return m, nil case "r": m.loading = true - return m, tea.Batch(m.fetch, tickCmd()) - } - return m, nil -} - -// startAttach hands the terminal to ssh via tea.ExecProcess: bubbletea leaves -// the alt screen, restores the shell termios, runs the command, then restores -// the TUI and delivers attachDoneMsg — a tmux detach lands back on this list. -func (m model) startAttach(s driver.Session) (tea.Model, tea.Cmd) { - cmd, err := m.opts.Attach(s) - if err != nil { - m.attachSess, m.loading = driver.Session{}, false - m.status, m.statusBad = err.Error(), true + m.note("refreshing") + return m, tea.Batch(m.fetch, m.fetchQuota) + case "s": + m.tab = tabSettings return m, nil } - m.attachSess, m.attachStart, m.loading = s, time.Now(), false - m.status, m.statusBad = "attaching to "+s.Name+" — detach with C-b d (session keeps running)", false - return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return attachDoneMsg{s, err} }) + switch m.tab { + case tabSessions: + return m.keySessions(k) + case tabRepos: + return m.keyRepos(k) + default: + return m.keySettings(k) + } } -func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.editing { - return m.updateSettingEdit(msg) - } - if m.picking { - return m.updateSettingPick(msg) - } - m.status = "" - switch msg.String() { - case "q", "esc", "ctrl+c": - m.mode = modeList +func (m model) keySessions(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { case "up", "k": - if m.setIdx > 0 { - m.setIdx-- - } + m.sessIdx = clamp(m.sessIdx-1, len(m.sessions)) + return m, nil case "down", "j": - if m.setIdx < len(config.Settings)-1 { - m.setIdx++ + m.sessIdx = clamp(m.sessIdx+1, len(m.sessions)) + return m, nil + case "n": + if m.opts.RepoRoot == "" { + m.fail(errors.New("new sessions come from a repo — run pier from inside one")) + return m, nil } - case "enter": - return m.openSetting() + m.ov, m.input = ovNew, "" + return m, nil } - return m, nil -} - -// openSetting opens the selected field for editing: a picker for choice and -// machine fields, the free-text editor for the rest. A machine field with no -// catalog wired (tests) falls back to the editor. -func (m model) openSetting() (tea.Model, tea.Cmd) { - f := config.Settings[m.setIdx] - cur := config.Get(*m.cfg, f.Key) - switch f.Kind { - case config.KindChoice: - m.picking, m.pickOpts, m.pickIdx = true, f.Options, optIndex(f.Options, cur) - case config.KindMachine: - var cat []driver.Machine - if m.opts.SettingsMachines != nil { - cat = m.opts.SettingsMachines(driverID(f.Group), cur) + s, ok := m.current() + if !ok { + return m, nil + } + switch k.String() { + case "enter": + return m.startAttach(s) + case "d": + q := "destroy " + s.Name + " and its disk?" + if s.State == pier.StateFailed { + q = "clear the failed create " + s.Name + "?" // nothing to destroy + } + return m.confirm(q, func() tea.Cmd { + return act(func() doneMsg { + err := m.be.Remove(context.Background(), s) + return doneMsg{note: "removed " + s.Name, err: err, refresh: true, watch: true} + }) + }), nil + case "p": + return m, act(func() doneMsg { + err := m.be.Keep(context.Background(), s) + return doneMsg{note: s.Name + " won't park when idle (the runaway cap still applies)", err: err} + }) + case "m": + if err := pier.CheckReady(s); err != nil { + m.fail(err) + return m, nil } - if len(cat) == 0 { - m.editing, m.setInput = true, cur + m.machines = m.be.Machines(s) + if len(m.machines) == 0 { + m.fail(errors.New("no machine catalog here — `pier resize " + s.Name + " ` takes any type")) return m, nil } - m.pickOpts = make([]config.Option, len(cat)) - for i, mc := range cat { - m.pickOpts[i] = config.Option{Value: mc.Type, Desc: fmt.Sprintf("%2s vCPU · %3s GiB · %s", mc.CPU, mc.Mem, mc.Cost)} + m.ov, m.pickIdx = ovResize, 0 + for i, mc := range m.machines { + if mc.Type == s.InstanceType { + m.pickIdx = i + } } - m.picking, m.pickIdx = true, optIndex(m.pickOpts, cur) - default: - m.editing, m.setInput = true, cur + return m, nil + case "l": + return m.openLogs(s) } return m, nil } -func (m model) updateSettingEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "ctrl+c": - m.editing, m.status = false, "" - case "enter": - return m.saveSetting(m.setInput) - case "backspace": - if len(m.setInput) > 0 { - m.setInput = m.setInput[:len(m.setInput)-1] - } - default: - if msg.Type == tea.KeyRunes && !strings.ContainsRune(string(msg.Runes), ' ') { - m.setInput += string(msg.Runes) - } +func (m model) current() (pier.Session, bool) { + if len(m.sessions) == 0 { + return pier.Session{}, false } - return m, nil + return m.sessions[clamp(m.sessIdx, len(m.sessions))], true } -func (m model) updateSettingPick(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - f := config.Settings[m.setIdx] - last := len(m.pickOpts) // the "custom…" row sits one past the real options - if f.NoCustom { - last = len(m.pickOpts) - 1 +// startAttach resumes a parked session first (blocking, off the UI loop), +// then hands the terminal to ssh+tmux. +func (m model) startAttach(s pier.Session) (tea.Model, tea.Cmd) { + if m.attaching != "" { + return m, nil } - switch msg.String() { - case "q", "esc", "ctrl+c": - m.picking, m.status = false, "" - case "up", "k": - if m.pickIdx > 0 { - m.pickIdx-- - } - case "down", "j": - if m.pickIdx < last { - m.pickIdx++ - } - case "enter": - if !f.NoCustom && m.pickIdx == len(m.pickOpts) { // custom… → free text - m.picking = false - m.editing, m.setInput = true, config.Get(*m.cfg, f.Key) - return m, nil - } - return m.saveSetting(m.pickOpts[m.pickIdx].Value) + if err := pier.CheckReady(s); err != nil { + m.fail(err) + return m, nil } - return m, nil + m.attaching, m.attachRetried = s.Name, false + if s.State == pier.StateParked { + m.note("resuming " + s.Name + " — about 20-60s, tmux comes back as you left it") + return m, func() tea.Msg { return resumedMsg{s, m.be.Resume(context.Background(), s)} } + } + return m.execAttach(s) } -// saveSetting validates val through config.Set (the single write path) and -// persists it. A rejected value keeps the editor/picker open so it can be -// fixed; the config on disk is never touched by a bad value. -func (m model) saveSetting(val string) (tea.Model, tea.Cmd) { - f := config.Settings[m.setIdx] - // Validate against the page's copy first so a bad value never reaches - // disk and the editor can stay open on it. - if err := config.Set(m.cfg, f.Key, val); err != nil { - m.status, m.statusBad = err.Error(), true - return m, nil - } - // Then write through Update rather than saving the page's copy: the TUI - // can sit on this page while a `pier bake` finishes in another terminal, - // and saving a stale struct would erase the image it just recorded. - saved, err := config.Update(func(c *config.Config) error { - return config.Set(c, f.Key, val) - }) +func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { + cmd, err := m.be.AttachCommand(context.Background(), s) if err != nil { - m.status, m.statusBad = err.Error(), true + m.attaching = "" + m.fail(err) return m, nil } - m.cfg = &saved - m.editing, m.picking = false, false - m.status, m.statusBad = f.Label+" saved — applies to new sessions", false - return m, nil + m.attachStart = time.Now() + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return attachDoneMsg{s, err} }) } -// optIndex is the position of val in opts, or 0 (a custom value not in the -// list simply lands the cursor on the first option). -func optIndex(opts []config.Option, val string) int { - for i, o := range opts { - if o.Value == val { - return i - } - } - return 0 +func (m model) confirm(q string, do func() tea.Cmd) model { + m.ov, m.confirmQ, m.confirmDo = ovConfirm, q, do + return m } -// driverID maps a settings group to the driver id whose machine catalog and -// conventions it follows. -func driverID(group string) string { - if group == "gcp" { - return "gcp-gce" +func (m model) keyConfirm(k tea.KeyMsg) (tea.Model, tea.Cmd) { + m.ov = ovNone + if k.String() == "y" || k.String() == "Y" { + return m, m.confirmDo() } - return "aws-ec2" + return m, nil } -func (m model) updateResize(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc", "ctrl+c": - m.mode = modeList - case "up", "k": - if m.machIdx > 0 { - m.machIdx-- - } - case "down", "j": - if m.machIdx < len(m.machines)-1 { - m.machIdx++ - } - case "enter": - s := m.sessions[m.cursor] - t := m.machines[m.machIdx].Type - m.mode = modeList - if t == s.InstanceType { - m.status, m.statusBad = s.Name+" is already a "+t, false +func (m model) keyNew(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.Type { + case tea.KeyEsc: + m.ov = ovNone + return m, nil + case tea.KeyEnter: + branch := strings.TrimSpace(m.input) + m.ov = ovNone + if branch == "" { return m, nil } - m.loading = true - m.status, m.statusBad = "resizing "+s.Name+" to "+t+" — a running session rides one park+resume (~40-60s)", false - return m, tea.Batch(func() tea.Msg { - if err := m.opts.Resize(s, t); err != nil { - return sessionsMsg{nil, err} - } - return m.fetch() - }, tickCmd()) + root := m.opts.RepoRoot + return m, act(func() doneMsg { + log, err := m.be.SpawnCreate(root, branch) + return doneMsg{note: "creating " + branch + " in the background · log " + tilde(log), err: err, refresh: true, watch: true} + }) + case tea.KeyBackspace: + if r := []rune(m.input); len(r) > 0 { + m.input = string(r[:len(r)-1]) + } + return m, nil + case tea.KeyRunes, tea.KeySpace: + m.input += string(k.Runes) + return m, nil } return m, nil } -func (m model) updateNew(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "ctrl+c": - m.mode = modeList +func (m model) keyResize(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "esc", "q": + m.ov = ovNone + case "up", "k": + m.pickIdx = clamp(m.pickIdx-1, len(m.machines)) + case "down", "j": + m.pickIdx = clamp(m.pickIdx+1, len(m.machines)) case "enter": - if m.input == "" { + m.ov = ovNone + s, ok := m.current() + if !ok { return m, nil } - if m.opts.CreateDetached == nil { // no creator wired (tests) - m.mode = modeList + t := m.machines[m.pickIdx].Type + if t == s.InstanceType { return m, nil } - for _, s := range m.sessions { - if s.Name == m.input { - m.mode = modeList - m.status, m.statusBad = fmt.Sprintf("session %q already exists", m.input), true - return m, nil - } - } - branch := m.input - m.mode, m.input = modeList, "" - return m, func() tea.Msg { - log, err := m.opts.CreateDetached(branch) - return spawnedMsg{branch, log, err} - } - case "backspace": - if len(m.input) > 0 { - m.input = m.input[:len(m.input)-1] - } - default: - if msg.Type == tea.KeyRunes && !strings.ContainsRune(string(msg.Runes), ' ') { - m.input += string(msg.Runes) - } + m.note("resizing " + s.Name + " to " + t + " — parks, resizes, resumes (~1-2 min)") + return m, act(func() doneMsg { + err := m.be.Resize(context.Background(), s, t) + return doneMsg{note: s.Name + " is now a " + t, err: err, refresh: true} + }) } return m, nil } -func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "y": - s := m.sessions[m.cursor] - m.mode = modeList - m.loading = true - return m, tea.Batch(func() tea.Msg { - if err := m.opts.Destroy(s); err != nil { - return sessionsMsg{nil, err} - } - return m.fetch() - }, tickCmd()) - default: - m.mode = modeList - } - return m, nil -} +// --- repos --------------------------------------------------------------------------- -// openPools enters the w page: sizes reload fresh (config may have changed -// under us), rows rebuild from config ∪ live members, and the cursor lands on -// the repo pier launched from — the only size-editable row. -func (m model) openPools() (tea.Model, tea.Cmd) { - m.poolSizes = m.opts.PoolSizes() - m.poolRepos = poolRepoRows(m.poolSizes, m.members, m.opts.CurrentRepo) - m.poolIdx, m.poolPend, m.poolConfirm = 0, -1, false - for i, r := range m.poolRepos { - if r == m.opts.CurrentRepo { - m.poolIdx = i - } +func (m model) currentRepo() (pier.Repo, bool) { + if len(m.repos) == 0 { + return pier.Repo{}, false } - m.mode = modePool - if !m.polling && anyFilling(m.members) { - m.polling = true - return m, pollCmd() - } - return m, nil + return m.repos[clamp(m.repoIdx, len(m.repos))], true } -// reindexPools rebuilds the page's rows after a refresh or action, keeping the -// cursor on the same repo when it survives. A vanished row (drained from -// another terminal mid-refresh) takes its staged edit and confirm prompt with -// it — they referred to a repo that no longer has a row. -func (m *model) reindexPools() { - cur := "" - if m.poolIdx < len(m.poolRepos) { - cur = m.poolRepos[m.poolIdx] - } - m.poolRepos = poolRepoRows(m.poolSizes, m.members, m.opts.CurrentRepo) - m.poolIdx = 0 - found := false - for i, r := range m.poolRepos { - if r == cur { - m.poolIdx, found = i, true - } - } - if !found { - m.poolPend, m.poolConfirm = -1, false - } -} - -// poolRepoRows is every repo worth a row: configured pools, repos that still -// have members on the provider (orphans included — they cost money), and the -// repo pier launched from, so + works before any pool exists. -func poolRepoRows(sizes map[string]int, members []driver.Session, curRepo string) []string { - set := map[string]bool{} - for r := range sizes { - set[r] = true - } - for _, s := range members { - set[s.Repo] = true - } - if curRepo != "" { - set[curRepo] = true - } - rows := make([]string, 0, len(set)) - for r := range set { - rows = append(rows, r) - } - sort.Strings(rows) - return rows -} - -func (m model) updatePool(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.poolConfirm { - m.poolConfirm = false - if msg.String() == "y" { - repo := m.poolRepos[m.poolIdx] - drain := m.opts.PoolDrain - m.loading = true - return m, tea.Batch(func() tea.Msg { - n, err := drain(repo) - if err != nil { - return poolActMsg{err: err} - } - return poolActMsg{note: fmt.Sprintf("drained %d member(s) from %s", n, repo)} - }, tickCmd()) - } +func (m model) keyRepos(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "up", "k": + m.repoIdx = clamp(m.repoIdx-1, len(m.repos)) + return m, nil + case "down", "j": + m.repoIdx = clamp(m.repoIdx+1, len(m.repos)) return m, nil } - m.status = "" - row := "" - if m.poolIdx < len(m.poolRepos) { - row = m.poolRepos[m.poolIdx] + r, ok := m.currentRepo() + if !ok { + return m, nil } - switch msg.String() { - case "q", "esc", "ctrl+c": - if m.poolPend >= 0 { // first esc cancels the unapplied size - m.poolPend = -1 - return m, nil - } - m.mode = modeList - case "up", "k": - if m.poolIdx > 0 { - m.poolIdx-- - m.poolPend = -1 - } - case "down", "j": - if m.poolIdx < len(m.poolRepos)-1 { - m.poolIdx++ - m.poolPend = -1 - } - case "+", "=", "right": - return m.bumpPool(row, 1) - case "-", "_", "left": - return m.bumpPool(row, -1) - case "enter": - if row == "" || row != m.opts.CurrentRepo || m.opts.PoolSet == nil || - m.poolPend < 0 || m.poolPend == m.poolSizes[row] { - m.poolPend = -1 + switch k.String() { + case "b": + if !r.Current { + m.fail(errors.New("bake from inside the repo: cd to " + r.Name + " and run pier")) return m, nil } - size, set := m.poolPend, m.opts.PoolSet - m.poolPend = -1 - m.loading = true - return m, tea.Batch(func() tea.Msg { - logPath, err := set(size) - switch { - case err != nil: - return poolActMsg{err: err} - case size == 0: - return poolActMsg{note: "pool off — members drained"} - default: - return poolActMsg{note: fmt.Sprintf("pool size %d — filling in the background (log: %s)", size, ui.Tilde(logPath))} - } - }, tickCmd()) - case "f": - if row == "" || m.opts.PoolFill == nil { + if pier.BakeRunning(r.Name) { + m.note("a bake of " + r.Name + " is already running · log " + tilde(pier.BakeLogPath(r.Name))) return m, nil } - if row != m.opts.CurrentRepo { - m.status, m.statusBad = "run pier from inside "+row+" to fill its pool — the fill needs the local checkout", false + q := "bake " + r.Name + "'s session image now? It runs in the background on a temporary instance." + root := m.opts.RepoRoot + return m.confirm(q, func() tea.Cmd { + return act(func() doneMsg { + log, err := m.be.SpawnBake(root) + return doneMsg{note: "baking " + r.Name + " · log " + tilde(log), err: err, refresh: true} + }) + }), nil + case "+", "=": + if !r.Current { + m.fail(errors.New("refills need the repo's checkout — run pier from inside " + r.Name)) return m, nil } - if m.poolSizes[row] == 0 { - m.status, m.statusBad = "pool size is 0 — press + then enter to warm one", false + return m.setReady(r, r.ReadyTarget+1) + case "-", "_": + if r.ReadyTarget == 0 { return m, nil } - fill := m.opts.PoolFill - m.loading = true - return m, tea.Batch(func() tea.Msg { - logPath, err := fill() - if err != nil { - return poolActMsg{err: err} - } - return poolActMsg{note: "filling in the background (log: " + ui.Tilde(logPath) + ")"} - }, tickCmd()) - case "d": - if row == "" || m.opts.PoolDrain == nil { + if !r.Current && r.ReadyTarget > 1 { + m.fail(errors.New("change " + r.Name + "'s count from inside the repo; x removes them from anywhere")) return m, nil } - st := FoldPool(m.members, row, m.opts.CurrentRepo, m.opts.CurrentGen, m.opts.PoolMaxAge, time.Now()) - if st.Ready+st.Filling+st.Stale == 0 { - m.status, m.statusBad = "no warm members to drain for "+row, false + return m.setReady(r, r.ReadyTarget-1) + case "x": + if r.Ready+r.Filling+r.Stale == 0 && r.ReadyTarget == 0 { + m.note(r.Name + " has no ready sessions") return m, nil } - m.poolConfirm = true - case "r": - m.loading = true - return m, tea.Batch(m.fetch, tickCmd()) + return m.confirm("stop keeping ready sessions for "+r.Name+" and remove the parked ones?", func() tea.Cmd { + root := r.Name + if r.Current { + root = m.opts.RepoRoot + } + return act(func() doneMsg { + _, err := m.be.SetReady(context.Background(), root, 0, nil) + return doneMsg{note: "no ready sessions for " + r.Name, err: err, refresh: true} + }) + }), nil } return m, nil } -// bumpPool nudges the pending size on the current repo's row; other repos -// can't be resized from here — a fill needs their local checkout. -func (m model) bumpPool(row string, delta int) (tea.Model, tea.Cmd) { - if row == "" { - return m, nil - } - if row != m.opts.CurrentRepo { - m.status, m.statusBad = "run pier from inside "+row+" to resize its pool", false +func (m model) setReady(r pier.Repo, n int) (tea.Model, tea.Cmd) { + if n > 8 { return m, nil } - if m.poolPend < 0 { - m.poolPend = m.poolSizes[row] - } - m.poolPend = min(8, max(0, m.poolPend+delta)) - return m, nil -} - -// poolView is the w page: one row per repo with its configured size, live -// member census, and the monthly cost of what's parked. Only the current -// repo's row is size-editable (the generation hashes the local setup script); -// any repo's members can be drained. -func (m model) poolView() string { - var b strings.Builder - head := " " + ui.Title.Render("⚓ pier pools") + - ui.Dim.Render(" parked sessions ready to claim — pier grabs one") - if m.loading { - head += " " + ui.Accent.Render(spinner[m.frame%len(spinner)]) - } - b.WriteString("\n" + head + "\n\n") - - if len(m.poolRepos) == 0 { - b.WriteString(ui.Dim.Render(" no pools — run pier from inside a repo and press + to warm one") + "\n") - } else { - repoW := 4 - for _, r := range m.poolRepos { - repoW = max(repoW, len(r)) - } - b.WriteString(ui.Dim.Render(fmt.Sprintf(" %-*s %-6s %s", repoW, "REPO", "SIZE", "WARM")) + "\n") - ready, filling := 0, 0 - now := time.Now() - rows := make([]string, 0, len(m.poolRepos)) - for i, repo := range m.poolRepos { - st := FoldPool(m.members, repo, m.opts.CurrentRepo, m.opts.CurrentGen, m.opts.PoolMaxAge, now) - ready += st.Ready - filling += st.Filling - size := fmt.Sprintf("%-6d", m.poolSizes[repo]) - if i == m.poolIdx && m.poolPend >= 0 && m.poolPend != m.poolSizes[repo] { - size = fmt.Sprintf("%-6s", fmt.Sprintf("%d→%d", m.poolSizes[repo], m.poolPend)) - } - marker, name := " ", fmt.Sprintf("%-*s", repoW, repo) - if i == m.poolIdx { - marker = " " + ui.Accent.Render("▸") + " " - name = ui.Bold.Render(name) - } - cell := poolCell(repo, m.poolSizes[repo], st, m.opts.CurrentRepo) - if repo == m.opts.CurrentRepo { - cell += ui.Dim.Render(" · this repo") - } - rows = append(rows, fmt.Sprintf("%s%s %s %s", marker, name, size, cell)) - } - // Chrome around the row window: title block (3), table header (1), - // cost (2) and the footer below (up to 4 lines). - for _, row := range m.windowBody(rows, m.poolIdx, 10) { - b.WriteString(row + "\n") - } - // Parked is the only state that costs disk money alone; a filling - // member is a running instance billing at full rate until it parks. - if ready > 0 || filling > 0 { - cost := fmt.Sprintf("cost: %d parked × ~$3-4/mo disk", ready) - if filling > 0 { - cost += fmt.Sprintf(" · %d filling at full instance rate until parked", filling) - } - b.WriteString("\n " + ui.Dim.Render(cost) + "\n") - } - } - b.WriteString("\n") - - if m.poolConfirm { - b.WriteString(" " + ui.Warn.Render(fmt.Sprintf("drain %s — destroy its warm members? (y/n)", m.poolRepos[m.poolIdx])) + "\n") - return b.String() - } - if m.status != "" { - if m.statusBad { - b.WriteString(" " + ui.Bad.Render("! "+m.status) + "\n") - } else { - b.WriteString(" " + ui.Accent.Render("▸ "+m.status) + "\n") - } - } - if m.opts.CurrentRepo == "" { - hint := "read-only: run pier from inside a repo to size its pool" - if m.opts.PoolNote != "" { - hint = "read-only: " + m.opts.PoolNote - } - b.WriteString(" " + ui.Dim.Render(hint) + "\n") - } - b.WriteString(" " + ui.Keys("+/-", "size", "enter", "apply", "f", "fill", "d", "drain", "r", "refresh", "esc", "back") + "\n") - return b.String() -} - -// poolCell describes one repo's members: ready with ages, filling, stale — -// or why an empty row exists at all. -func poolCell(repo string, size int, st PoolStats, curRepo string) string { - var parts []string - if st.Ready > 0 { - cell := fmt.Sprintf("● %d ready", st.Ready) - if len(st.Ages) > 0 { - cell += " (" + strings.Join(st.Ages, ", ") + ")" - } - parts = append(parts, ui.OK.Render(cell)) - } - if st.Filling > 0 { - parts = append(parts, ui.Warn.Render(fmt.Sprintf("◐ %d filling", st.Filling))) + root := r.Name + if r.Current { + root = m.opts.RepoRoot } - if st.Stale > 0 { - parts = append(parts, ui.Bad.Render(fmt.Sprintf("✗ %d stale", st.Stale))) - } - if len(parts) == 0 { - switch { - case size == 0: - return ui.Dim.Render("(off)") - case repo == curRepo: - return ui.Dim.Render("empty — f fills") - default: - return ui.Dim.Render("empty") + cost := m.be.DiskMonthlyUSD() + return m, act(func() doneMsg { + _, err := m.be.SetReady(context.Background(), root, n, nil) + note := "no ready sessions for " + r.Name + if n > 0 { + note = "keeping " + strconv.Itoa(n) + " ready for " + r.Name + " · each ~$" + strconv.Itoa(int(cost+0.5)) + "/mo parked · filling in the background" } - } - if size == 0 { - parts = append(parts, ui.Dim.Render("orphaned — pool is off; d drains")) - } - return strings.Join(parts, " ") + return doneMsg{note: note, err: err, refresh: true, watch: n > 0} + }) } -var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +// --- settings -------------------------------------------------------------------------- -func (m model) View() string { - if m.mode == modeSettings { - return m.settingsView() - } - if m.mode == modeLogs { - return m.logsView() - } - if m.mode == modePool { - return m.poolView() - } - var b strings.Builder - - head := " " + ui.Title.Render("⚓ pier") - var meta []string - if m.loaded { - meta = append(meta, fmt.Sprintf("%d session(s)", len(m.sessions))) - if n := len(m.members); n > 0 { - meta = append(meta, fmt.Sprintf("%d warm", n)) - } - } - if m.quota != "" { - meta = append(meta, m.quota) - } - if len(meta) > 0 { - head += ui.Dim.Render(" " + strings.Join(meta, " · ")) - } - if m.loading { - head += " " + ui.Accent.Render(spinner[m.frame%len(spinner)]) - } - b.WriteString("\n" + head + "\n\n") - - switch { - case m.authRequired: - b.WriteString(ui.Dim.Render(" session list unavailable — reauthenticate to continue") + "\n") - case !m.loaded: - b.WriteString(ui.Dim.Render(" fetching sessions…") + "\n") - case len(m.sessions) == 0: - b.WriteString(ui.Dim.Render(" no sessions — press n to start one") + "\n") - default: - b.WriteString(m.table()) - } - b.WriteString("\n") - - switch m.mode { - case modeNew: - b.WriteString(" " + ui.Dim.Render("new session branch") + "\n") - b.WriteString(ui.Box.Render(ui.Accent.Render("❯ ")+m.input+ui.Accent.Render("▌")) + "\n") - b.WriteString(" " + ui.Keys("enter", "create", "esc", "cancel") + "\n") - case modeConfirm: - if s := m.sessions[m.cursor]; s.State == driver.StateFailed { - // No disk, no instance — clearing a tombstone destroys nothing. - b.WriteString(" " + ui.Warn.Render(fmt.Sprintf("clear the failed create %q? (y/n)", s.Name)) + "\n") - } else { - b.WriteString(" " + ui.Warn.Render(fmt.Sprintf("destroy %q and its disk? (y/n)", s.Name)) + "\n") - } - case modeResize: - s := m.sessions[m.cursor] - b.WriteString(" " + ui.Dim.Render("resize "+s.Name+" — same-arch machines, billed on-demand while running") + "\n") - for i, mc := range m.machines { - marker := " " - row := fmt.Sprintf("%-12s %2s vCPU %3s GiB %s", mc.Type, mc.CPU, mc.Mem, mc.Cost) - if mc.Type == s.InstanceType { - row += " (current)" - } - if i == m.machIdx { - marker = " " + ui.Accent.Render("▸") + " " - row = ui.Bold.Render(row) - } - b.WriteString(marker + row + "\n") +func (m model) keySettings(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "up", "k": + m.setIdx = clamp(m.setIdx-1, len(m.settings)) + return m, nil + case "down", "j": + m.setIdx = clamp(m.setIdx+1, len(m.settings)) + return m, nil + case "enter": + if len(m.settings) == 0 { + return m, nil } - b.WriteString(" " + ui.Keys("enter", "resize", "esc", "cancel") + "\n") - default: - if m.status != "" { - if m.statusBad { - b.WriteString(" " + ui.Bad.Render("! "+m.status) + "\n") - } else { - b.WriteString(" " + ui.Accent.Render("▸ "+m.status) + "\n") + f := m.settings[m.setIdx] + switch f.Kind { + case config.KindChoice: + m.pickOpts = f.Options + case config.KindMachine: + m.pickOpts = nil + for _, mc := range m.be.MachineCatalog(f.Value) { + m.pickOpts = append(m.pickOpts, config.Option{ + Value: mc.Type, Desc: mc.CPU + " vCPU · " + mc.Mem + " GB · " + mc.Cost, + }) } - } - if m.authRequired { - b.WriteString(" " + ui.Keys("enter", "reauthenticate", "q", "quit") + "\n") - } else { - keys := []string{"enter", "attach", "n", "new", "d", "delete", "p", "pin", "m", "resize", "l", "logs"} - if m.opts.PoolSizes != nil { - keys = append(keys, "w", "pools") + if len(m.pickOpts) == 0 { + m.ov, m.input = ovEdit, f.Value + return m, nil } - keys = append(keys, "s", "settings", "r", "refresh", "q", "quit") - b.WriteString(" " + ui.Keys(keys...) + "\n") + default: + m.ov, m.input = ovEdit, f.Value + return m, nil } - } - return b.String() -} - -// settingsView is the s page: fields grouped session → aws → gcp, the -// inactive cloud dimmed but still editable, a detail footer for the selected -// field, and a read-only section for what pier setup and pier bake manage. -// Values save straight to config.toml and apply to new sessions. -func (m model) settingsView() string { - if m.picking { - return m.pickerView() - } - fields := config.Settings - - labelW, valW := 0, len("(default VPC)") - disp := make([]string, len(fields)) - for i, f := range fields { - disp[i] = f.Display(config.Get(*m.cfg, f.Key)) - labelW = max(labelW, len(f.Label)) - valW = max(valW, len(disp[i])) - } - - header := []string{ - "", - " " + ui.Title.Render("⚓ pier settings") + - ui.Dim.Render(" "+ui.Tilde(config.Path())+" · changes apply to new sessions"), - "", - } - - var body []string - cursorLine, lastGroup := 0, "" - for i, f := range fields { - if f.Group != lastGroup { - if lastGroup != "" { - body = append(body, "") + m.ov, m.pickIdx = ovPick, 0 + for i, o := range m.pickOpts { + if o.Value == f.Value { + m.pickIdx = i } - body = append(body, m.groupHeader(f.Group)) - lastGroup = f.Group } - if i == m.setIdx { - cursorLine = len(body) - } - body = append(body, m.settingRow(i, f, disp[i], labelW, valW)) - } - body = append(body, "") - body = append(body, m.readonlyLines()...) - - var footer []string - footer = append(footer, m.detailFooter(fields[m.setIdx])...) - if m.status != "" { - if m.statusBad { - footer = append(footer, " "+ui.Bad.Render("! "+m.status)) - } else { - footer = append(footer, " "+ui.Accent.Render("▸ "+m.status)) - } - } - if m.editing { - footer = append(footer, " "+ui.Keys("enter", "save", "esc", "cancel")) - } else { - verb := "edit" - if k := fields[m.setIdx].Kind; k == config.KindChoice || k == config.KindMachine { - verb = "choose" - } - footer = append(footer, " "+ui.Keys("↑↓", "move", "enter", verb, "esc", "back")) - } - - body = m.windowBody(body, cursorLine, len(header)+len(footer)) - return strings.Join(header, "\n") + "\n" + - strings.Join(body, "\n") + "\n" + - strings.Join(footer, "\n") + "\n" -} - -// groupHeader labels a group; a cloud group that isn't the active driver reads -// dim with a "used when…" note, matching its dimmed rows. -func (m model) groupHeader(group string) string { - if group == "session" { - return " " + ui.Bold.Render("session") - } - if m.inactiveGroup(group) { - return " " + ui.Dim.Render(group+" — used when cloud is "+cloudName(group)) - } - return " " + ui.Bold.Render(group) -} - -// settingRow renders one field: accent cursor + bold when selected, the whole -// line dimmed for the inactive cloud, the machine fields annotated with the -// current type's specs. Padded as plain text before styling so the %-*s width -// math survives (ANSI escapes would blow the column alignment). -func (m model) settingRow(i int, f config.Field, disp string, labelW, valW int) string { - label := fmt.Sprintf("%-*s", labelW, f.Label) - hint := f.Hint - if f.Kind == config.KindMachine { - if h := m.machineHint(f); h != "" { - hint = h - } - } - if i == m.setIdx { - val := fmt.Sprintf("%-*s", valW, disp) - if m.editing { - val = m.setInput + ui.Accent.Render("▌") - } - return " " + ui.Accent.Render("▸") + " " + ui.Bold.Render(label) + " " + val + " " + ui.Dim.Render(hint) - } - val := fmt.Sprintf("%-*s", valW, disp) - if m.inactiveGroup(f.Group) { - return " " + ui.Dim.Render(label+" "+val+" "+hint) - } - return " " + label + " " + val + " " + ui.Dim.Render(hint) -} - -// readonlyLines is the "managed elsewhere" section: what travels into sessions -// and what's baked, shown so the answer to "what's carried?" doesn't require -// opening config.toml. Strictly read-only — the write paths stay pier setup -// and pier bake. The cursor never enters it. -func (m model) readonlyLines() []string { - c := m.cfg - - secrets := ui.Dim.Render("(none)") - if n := len(c.Secrets.Manifest); n > 0 { - show, more := c.Secrets.Manifest, "" - if n > 3 { - show, more = show[:3], fmt.Sprintf(" +%d more", n-3) - } - secrets = strings.Join(show, " · ") + more - } - token := "not set" - if c.Secrets.ClaudeOAuthToken != "" { - token = "set" - } - imgs := c.AWS.BakedAMIs - if c.Driver == "gcp-gce" { - imgs = c.GCP.BakedImages - } - baked := ui.Dim.Render("(none)") - if len(imgs) > 0 { - pairs := make([]string, 0, len(imgs)) - for repo, img := range imgs { - pairs = append(pairs, repo+" → "+shorten(img, 22)) - } - sort.Strings(pairs) - more := "" - if len(pairs) > 2 { - pairs, more = pairs[:2], fmt.Sprintf(" +%d more", len(pairs)-2) - } - baked = strings.Join(pairs, " · ") + more - } - - // Pad by rune count, not bytes: "secrets → sessions" carries a multi-byte - // arrow, and %-*s would misalign the value column against the ASCII rows. - labelW := len([]rune("secrets → sessions")) - row := func(label, val, mgr string) string { - if pad := labelW - len([]rune(label)); pad > 0 { - label += strings.Repeat(" ", pad) - } - return " " + ui.Dim.Render(label+" ") + val + ui.Dim.Render(" ("+mgr+")") - } - pools := ui.Dim.Render("(none)") - if len(c.Pool.Sizes) > 0 { - pairs := make([]string, 0, len(c.Pool.Sizes)) - for repo, n := range c.Pool.Sizes { - pairs = append(pairs, fmt.Sprintf("%s × %d", repo, n)) - } - sort.Strings(pairs) - pools = strings.Join(pairs, " · ") - } - - return []string{ - " " + ui.Dim.Render("managed elsewhere"), - row("secrets → sessions", secrets, "pier setup"), - row("claude token", token, "claude setup-token"), - row("baked images", baked, "pier bake"), - row("warm pools", pools, "w / pier pool"), - } -} - -// detailFooter is the explanation panel for the selected field: a rule, the -// label + prose, the underlying config key and default, another rule. -func (m model) detailFooter(f config.Field) []string { - rule := ui.Dim.Render(strings.Repeat("─", m.ruleWidth())) - out := []string{rule} - lines := strings.Split(f.Detail, "\n") - out = append(out, " "+ui.Bold.Render(f.Label)+ui.Dim.Render(" — "+lines[0])) - for _, l := range lines[1:] { - out = append(out, " "+ui.Dim.Render(l)) - } - meta := "config: " + f.Key - if f.Default != "" { - meta += " · default: " + f.Default - } - return append(out, " "+ui.Dim.Render(meta), rule) -} - -// pickerView is the dropdown for a choice or machine field: the options with -// their annotations, the current value marked, and (unless the field is a -// closed enum) a custom… row that drops into the free-text editor. -func (m model) pickerView() string { - f := config.Settings[m.setIdx] - var b strings.Builder - b.WriteString("\n " + ui.Title.Render("⚓ pier settings") + ui.Dim.Render(" "+f.Label) + "\n\n") - b.WriteString(" " + ui.Dim.Render(f.Label+" — "+strings.SplitN(f.Detail, "\n", 2)[0]) + "\n\n") - - cur := config.Get(*m.cfg, f.Key) - leftW := 6 - labels := make([]string, len(m.pickOpts)) - for i, o := range m.pickOpts { - labels[i] = o.Label - if labels[i] == "" { - labels[i] = o.Value - } - leftW = max(leftW, len(labels[i])) - } - for i, o := range m.pickOpts { - row := fmt.Sprintf("%-*s", leftW, labels[i]) - if o.Desc != "" { - row += " " + o.Desc - } - if o.Value == cur { - row += " (current)" - } - b.WriteString(m.pickRow(i, row)) - } - if !f.NoCustom { - b.WriteString(m.pickRow(len(m.pickOpts), fmt.Sprintf("%-*s ", leftW, "custom…")+"type your own")) - } - b.WriteString("\n " + ui.Keys("↑↓", "move", "enter", "select", "esc", "cancel") + "\n") - return b.String() -} - -func (m model) pickRow(i int, row string) string { - if i == m.pickIdx { - return " " + ui.Accent.Render("▸") + " " + ui.Bold.Render(row) + "\n" - } - return " " + row + "\n" -} - -// windowBody trims the settings body to what the terminal can hold, centered -// on the selected row — same idea as the session table, so the header and -// footer never scroll off the alternate screen. height 0 (tests) renders all. -func (m model) windowBody(body []string, cursorLine, chrome int) []string { - if m.height == 0 { - return body - } - budget := max(3, m.height-chrome-1) - if len(body) <= budget { - return body - } - lo := min(max(0, cursorLine-budget/2), len(body)-budget) - return body[lo : lo+budget] -} - -func (m model) inactiveGroup(group string) bool { - if group != "aws" && group != "gcp" { - return false - } - active := m.cfg.Driver - if active == "" { - active = "aws-ec2" - } - return driverID(group) != active -} - -func (m model) machineHint(f config.Field) string { - if m.opts.SettingsMachines == nil { - return "" - } - cur := config.Get(*m.cfg, f.Key) - for _, mc := range m.opts.SettingsMachines(driverID(f.Group), cur) { - if mc.Type == cur { - return fmt.Sprintf("%s vCPU · %s GiB · %s", mc.CPU, mc.Mem, mc.Cost) - } - } - return "" -} - -func (m model) ruleWidth() int { - if m.width <= 0 { - return 58 - } - return min(58, max(20, m.width-2)) -} - -func cloudName(group string) string { - if group == "gcp" { - return "GCP" - } - return "AWS" -} - -func shorten(s string, n int) string { - if len(s) <= n { - return s + return m, nil } - return s[:n-1] + "…" + return m, nil } -// listRows is the table's row budget: terminal height minus every non-row -// line View emits in the current mode, so the footer never clips off the -// alternate screen (the renderer keeps the LAST height lines on overflow). -func (m model) listRows() int { - if m.height == 0 { // no WindowSizeMsg yet (tests) — render everything - return len(m.sessions) - } - chrome := 6 // top margin+title+blank, column header, blank after table, trailing newline - switch m.mode { - case modeNew: - chrome += 5 // label + 3-line box + keys - case modeConfirm: - chrome++ - case modeResize: - chrome += 2 + len(m.machines) // label + machine rows + keys - default: - chrome++ // keys - if m.status != "" { - chrome++ - } +func (m model) keyPick(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { + case "esc", "q": + m.ov = ovNone + case "up", "k": + m.pickIdx = clamp(m.pickIdx-1, len(m.pickOpts)) + case "down", "j": + m.pickIdx = clamp(m.pickIdx+1, len(m.pickOpts)) + case "enter": + m.ov = ovNone + return m.save(m.pickOpts[m.pickIdx].Value) } - return max(3, m.height-chrome) + return m, nil } -// table renders the session rows: dim column header, accent cursor, states -// color-coded. Cells are padded as plain text first, then styled — ANSI -// escapes would defeat %-*s width math. When the terminal is shorter than -// the list, a cursor-centered window of rows renders instead. -func (m model) table() string { - nameW, repoW, stateW := 4, 4, 5 - states := make([]string, len(m.sessions)) - for i, s := range m.sessions { - states[i] = stateCell(s) - nameW = max(nameW, len(s.Name)) - repoW = max(repoW, len(s.Repo)) - stateW = max(stateW, len([]rune(states[i]))) - } - - var b strings.Builder - b.WriteString(ui.Dim.Render(fmt.Sprintf(" %-*s %-*s %-*s %-4s %s", - nameW, "NAME", repoW, "REPO", stateW, "STATE", "AGE", "COST")) + "\n") - rows, lo := m.listRows(), 0 - if rows < len(m.sessions) { - lo = min(max(0, m.cursor-rows/2), len(m.sessions)-rows) - } - hi := min(len(m.sessions), lo+rows) - for i := lo; i < hi; i++ { - s := m.sessions[i] - marker := " " - name := fmt.Sprintf("%-*s", nameW, s.Name) - if i == m.cursor { - marker = " " + ui.Accent.Render("▸") + " " - name = ui.Bold.Render(name) +func (m model) keyEdit(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.Type { + case tea.KeyEsc: + m.ov = ovNone + case tea.KeyEnter: + return m.save(strings.TrimSpace(m.input)) + case tea.KeyBackspace: + if r := []rune(m.input); len(r) > 0 { + m.input = string(r[:len(r)-1]) } - pad := stateW - len([]rune(states[i])) - state := stateStyle(s).Render(states[i]) + strings.Repeat(" ", pad) - fmt.Fprintf(&b, "%s%s %s %s %-4s %s\n", - marker, name, - ui.Dim.Render(fmt.Sprintf("%-*s", repoW, s.Repo)), - state, age(s.Created), ui.Dim.Render(s.CostNote)) - } - return b.String() -} - -func stateCell(s driver.Session) string { - dots := map[driver.State]string{ - driver.StateCreating: "◐", - driver.StateRunning: "●", - driver.StateWorking: "●", - driver.StateIdle: "●", - driver.StateParked: "◌", - driver.StateDeleting: "◌", - driver.StateDead: "✗", - driver.StateFailed: "✗", - } - dot, ok := dots[s.State] - if !ok { - dot = "?" - } - // A tombstone reads "create failed", never a bare "failed" — the row above - // it may say "running (setup failed)", and those are not the same thing. - if s.State == driver.StateFailed { - return dot + " create failed" - } - cell := dot + " " + string(s.State) - if s.Strained { - cell += " ▲ strained" - } - switch s.Setup { - case "running": - cell += " (setting up)" - case "failed": - cell += " (setup failed)" - } - return cell -} - -func stateStyle(s driver.Session) lipgloss.Style { - if s.Setup == "failed" || s.State == driver.StateFailed { - return ui.Bad - } - if s.Strained { - return ui.Strain - } - switch s.State { - case driver.StateRunning: - return ui.OK - case driver.StateWorking: - return ui.Accent - case driver.StateCreating, driver.StateIdle: - return ui.Warn - case driver.StateDead: - return ui.Bad - default: // parked, deleting - return ui.Dim + case tea.KeyRunes, tea.KeySpace: + m.input += string(k.Runes) } + return m, nil } -func age(t time.Time) string { - if t.IsZero() { - return "-" +// save writes one setting. A rejected value keeps the editor open with the +// reason; an accepted one reloads the rows (a profile change moves two +// others) and, when it changes where cloud calls go, rebuilds the backend. +func (m model) save(val string) (tea.Model, tea.Cmd) { + f := m.settings[m.setIdx] + if err := m.be.Set(f.Key, val); err != nil { + m.fail(err) + return m, nil } - d := time.Since(t) + m.ov = ovNone + m.settings = m.be.Settings() + m.setIdx = clamp(m.setIdx, len(m.settings)) + m.note(f.Label + " saved — applies to new sessions") switch { - case d < time.Minute: - return "now" - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) + case f.Key == "driver", strings.HasPrefix(f.Key, "aws."), strings.HasPrefix(f.Key, "gcp."): + return m, func() tea.Msg { + be, err := m.opts.Open() + return reopenedMsg{be, err} + } + case strings.HasPrefix(f.Key, "speed."): + // Ready-session targets derive from these: rebuild the Repos rows. + m.repos = m.be.Repos(m.all, m.opts.RepoRoot) } + return m, nil } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6079c04..e614239 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,744 +1,374 @@ package tui import ( + "context" "errors" - "fmt" + "os" "os/exec" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/usepier/pier/internal/config" - "github.com/usepier/pier/internal/driver" - "github.com/usepier/pier/internal/driver/awsec2" + "github.com/usepier/pier/pkg/pier" ) -// View smoke tests: the load-flash bug was View rendering "0 sessions" -// before the first fetch landed, so pin the three distinct states. -func TestViewStates(t *testing.T) { - m := model{loading: true} - if v := m.View(); !strings.Contains(v, "fetching") { - t.Errorf("pre-fetch view must show a loading line, not a session count:\n%s", v) - } - if v := m.View(); strings.Contains(v, "0 session") || strings.Contains(v, "no sessions") { - t.Errorf("pre-fetch view leaked an empty-state:\n%s", v) - } - - m.loaded = true - if v := m.View(); !strings.Contains(v, "no sessions") { - t.Errorf("loaded-empty view must say no sessions:\n%s", v) - } - - m.sessions = []driver.Session{ - {Name: "fix-auth", Repo: "myapp", State: driver.StateWorking, Created: time.Now()}, - {Name: "big-build", Repo: "myapp", State: driver.StateRunning, Strained: true}, - {Name: "bad-deps", Repo: "myapp", State: driver.StateIdle, Setup: "failed"}, - } - v := m.View() - for _, want := range []string{"fix-auth", "big-build", "working", "strained", "(setup failed)", "NAME"} { - if !strings.Contains(v, want) { - t.Errorf("list view missing %q:\n%s", want, v) - } - } +// fake is a Backend with canned data that records what the app asked for. +type fake struct { + sessions []pier.Session + repos []pier.Repo + cfg config.Config + removed []string + kept []string + spawned []string + ready map[string]int + setErr error } -func TestEnterReauthenticatesExpiredCloudSession(t *testing.T) { - expired := errors.New("aws sts get-caller-identity: your session has expired; use aws login") - logins, fetches := 0, 0 - m := model{opts: Options{ - AuthExpired: func(err error) bool { return strings.Contains(err.Error(), "session has expired") }, - Reauthenticate: func() *exec.Cmd { - logins++ - return exec.Command("true") +func newFake() *fake { + now := time.Now() + return &fake{ + cfg: config.Default(), + ready: map[string]int{}, + sessions: []pier.Session{ + {ID: "i-1", Name: "checkout-flow", Repo: "shop", Branch: "checkout-flow", State: pier.StateWorking, InstanceType: "t4g.medium", CostNote: "~$0.04/h", Created: now.Add(-2 * time.Hour)}, + {ID: "i-2", Name: "fix-login", Repo: "flb-estimation", Branch: "fix-login", State: pier.StateParked, InstanceType: "t4g.xlarge", CostNote: "~$7/mo", Created: now.Add(-26 * time.Hour)}, + {ID: "i-3", Name: "perf-test", Repo: "flb-estimation", State: pier.StateIdle, Setup: "running", InstanceType: "t4g.xlarge", CostNote: "~$0.13/h", Created: now.Add(-3 * time.Minute)}, + {Name: "broken", Repo: "shop", State: pier.StateFailed, FailReason: "scp: Connection closed", Created: now.Add(-time.Hour)}, + {ID: "i-4", Name: "pool-flb-1", Repo: "flb-estimation", State: pier.StateParked, PoolGen: "abc", Created: now.Add(-time.Hour)}, }, - Fetch: func() ([]driver.Session, error) { - fetches++ - return []driver.Session{{Name: "fix-auth", State: driver.StateRunning}}, nil + repos: []pier.Repo{ + {Name: "flb-estimation", Current: true, Image: "ami-1", Baked: &config.ImageInfo{BakedAt: now.Add(-62 * 24 * time.Hour), RepoIncluded: true}, + ReadyTarget: 1, Ready: 1, Sessions: 2, MonthlyUSD: 9.6, + Reminders: []pier.Reminder{{Message: "flb-estimation's session image is 62 days old. Rebake so new sessions start with current dependencies.", Action: "pier bake"}}}, + {Name: "shop", Sessions: 1}, }, - }} - - got, _ := m.Update(sessionsMsg{err: expired}) - m = got.(model) - view := m.View() - if !m.authRequired || !strings.Contains(view, "enter") || !strings.Contains(view, "reauthenticate") || strings.Contains(view, "no sessions") { - t.Fatalf("expired auth must offer enter-to-reauthenticate:\n%s", view) - } - - got, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if cmd == nil || logins != 1 || !m.reauthenticating { - t.Fatalf("enter must build the foreground login, got cmd=%v logins=%d running=%v", cmd, logins, m.reauthenticating) - } - - got, cmd = m.Update(reauthenticatedMsg{}) - m = got.(model) - if cmd == nil || m.authRequired || !m.loading || !strings.Contains(m.status, "refreshing") { - t.Fatalf("successful login must start a refresh, got auth=%v loading=%v status=%q", m.authRequired, m.loading, m.status) - } - if batch, ok := cmd().(tea.BatchMsg); ok { - for _, c := range batch { - if c != nil { - c() - } - } - } - if fetches != 1 { - t.Errorf("successful login fetched %d times, want 1", fetches) } } -func TestFailedReauthenticationCanRetry(t *testing.T) { - m := model{authRequired: true, reauthenticating: true, - opts: Options{Reauthenticate: func() *exec.Cmd { return exec.Command("true") }}} - got, _ := m.Update(reauthenticatedMsg{err: errors.New("exit status 1")}) - m = got.(model) - if !m.authRequired || m.reauthenticating || !m.statusBad || !strings.Contains(m.View(), "reauthenticate") { - t.Fatalf("failed login must preserve the retry action, got auth=%v running=%v status=%q", m.authRequired, m.reauthenticating, m.status) - } +func (f *fake) Cloud() string { return "AWS eu-central-1" } +func (f *fake) Sessions(context.Context) ([]pier.Session, error) { + return f.sessions, nil } - -// The settings page groups fields under human labels, shows the current -// values, surfaces the read-only "managed elsewhere" section, and explains -// the selected field in the detail footer. -func TestSettingsPage(t *testing.T) { - cfg := config.Default() - m := model{mode: modeSettings, cfg: &cfg} - - v := m.View() - for _, want := range []string{ - "pier settings", // title - "session", "aws", "gcp", // group headers - "auto-park", "machine", "connection", // human labels - "t4g.medium", // a current value - "managed elsewhere", // read-only section - "claude token", // a read-only row - "config: driver", // detail footer for the selected (first) field - } { - if !strings.Contains(v, want) { - t.Errorf("settings view missing %q:\n%s", want, v) +func (f *fake) Repos([]pier.Session, string) []pier.Repo { return f.repos } +func (f *fake) Headroom(context.Context) (pier.Quota, error) { + return pier.Quota{Detail: "10/32 vCPU"}, nil +} +func (f *fake) Remove(_ context.Context, s pier.Session) error { + f.removed = append(f.removed, s.Name) + return nil +} +func (f *fake) Keep(_ context.Context, s pier.Session) error { + f.kept = append(f.kept, s.Name) + return nil +} +func (f *fake) Resume(context.Context, pier.Session) error { return nil } +func (f *fake) Resize(context.Context, pier.Session, string) error { return nil } +func (f *fake) Machines(pier.Session) []pier.Machine { + return []pier.Machine{{Type: "t4g.medium", CPU: "2", Mem: "4", Cost: "~$0.03/h"}, {Type: "t4g.xlarge", CPU: "4", Mem: "16", Cost: "~$0.13/h"}} +} +func (f *fake) AttachCommand(context.Context, pier.Session) (*exec.Cmd, error) { + return exec.Command("true"), nil +} +func (f *fake) WaitReachable(context.Context, pier.Session, time.Duration) error { return nil } +func (f *fake) SetupLog(context.Context, pier.Session, int) (string, error) { + return "\x1b[32m==> installing\x1b[0m\nprogress 10%\rprogress 100%\npier setup: done\n", nil +} +func (f *fake) SpawnCreate(root, branch string) (string, error) { + f.spawned = append(f.spawned, branch) + return "/tmp/create-" + branch + ".log", nil +} +func (f *fake) SpawnBake(string) (string, error) { return "/tmp/bake.log", nil } +func (f *fake) SetReady(_ context.Context, root string, n int, _ pier.Progress) (string, error) { + f.ready[root] = n + return "", nil +} +func (f *fake) DrainReady(context.Context, string, pier.Progress) (int, error) { return 0, nil } +func (f *fake) Settings() []pier.SettingValue { + var out []pier.SettingValue + for _, fl := range config.Settings { + if !f.cfg.Visible(fl) { + continue } + v := config.Get(f.cfg, fl.Key) + out = append(out, pier.SettingValue{Field: fl, Value: v, Display: fl.Display(v)}) } - // The raw TOML keys are gone from the rows — only the footer names them. - if strings.Contains(v, "idle_timeout ") { - t.Errorf("row still shows a raw config key instead of a label:\n%s", v) - } + return out } - -// A free-text field (gcp.project) opens the editor on enter and, on a bad -// value, stays in edit with an error without touching disk. -func TestSettingsTextValidation(t *testing.T) { - cfg := config.Default() - m := model{mode: modeSettings, cfg: &cfg, setIdx: fieldIndex(t, "gcp.project")} - - got, _ := m.updateSettings(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if !m.editing { - t.Fatalf("enter on a text field must open the editor, got editing=%v picking=%v", m.editing, m.picking) - } - m.setInput = "Bad_Project" - got, _ = m.updateSettings(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if !m.editing || !m.statusBad { - t.Errorf("a bad project id must stay in edit with an error, got editing=%v status=%q", m.editing, m.status) - } - if cfg.GCP.Project != "" { - t.Errorf("a rejected value leaked into config: %q", cfg.GCP.Project) +func (f *fake) Set(key, val string) error { + if f.setErr != nil { + return f.setErr } + return config.Set(&f.cfg, key, val) } +func (f *fake) MachineCatalog(string) []pier.Machine { return f.Machines(pier.Session{}) } +func (f *fake) ReauthCommand() *exec.Cmd { return exec.Command("true") } +func (f *fake) DiskMonthlyUSD() float64 { return 7.6 } -// A choice field (auto-park / idle_timeout) opens a picker preselected on the -// current value; selecting a different option saves it. -func TestSettingsChoicePicker(t *testing.T) { - cfg := config.Default() // idle_timeout defaults to 30m - m := model{mode: modeSettings, cfg: &cfg, setIdx: fieldIndex(t, "idle_timeout")} - - got, _ := m.updateSettings(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if !m.picking { - t.Fatalf("enter on a choice field must open the picker, got picking=%v editing=%v", m.picking, m.editing) - } - if m.pickOpts[m.pickIdx].Value != "30m" { - t.Errorf("picker must preselect the current value, got %q", m.pickOpts[m.pickIdx].Value) - } - if v := m.View(); !strings.Contains(v, "(current)") || !strings.Contains(v, "custom…") { - t.Errorf("picker must mark the current option and offer custom…:\n%s", v) - } - // Move to a different option and select it. - got, _ = m.updateSettings(tea.KeyMsg{Type: tea.KeyDown}) - m = got.(model) - want := m.pickOpts[m.pickIdx].Value - got, _ = m.updateSettings(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if m.picking || cfg.IdleTimeout != want { - t.Errorf("selecting an option must save it and close the picker, got picking=%v idle=%q want=%q", m.picking, cfg.IdleTimeout, want) - } +// loaded returns a model that has received its first list at w×h. +func loaded(t *testing.T, f *fake, w, h int) model { + t.Helper() + m := newModel(Options{Open: func() (Backend, error) { return f, nil }, RepoRoot: "/code/flb-estimation", Version: "test"}, f) + next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) + m = next.(model) + all, _ := f.Sessions(context.Background()) + next, _ = m.Update(sessionsMsg{all: all}) + return next.(model) } -// The machine field builds its picker from the injected catalog and converts -// each machine into an annotated option. -func TestSettingsMachinePicker(t *testing.T) { - cfg := config.Default() - m := model{mode: modeSettings, cfg: &cfg, setIdx: fieldIndex(t, "aws.instance_type"), - opts: Options{SettingsMachines: func(driverID, cur string) []driver.Machine { - return awsec2.Machines(cur) - }}} - got, _ := m.updateSettings(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if !m.picking || len(m.pickOpts) == 0 { - t.Fatalf("enter on the machine field must open a populated picker, got picking=%v opts=%d", m.picking, len(m.pickOpts)) - } - if m.pickOpts[m.pickIdx].Value != "t4g.medium" { - t.Errorf("picker must preselect the configured type, got %q", m.pickOpts[m.pickIdx].Value) - } - if v := m.View(); !strings.Contains(v, "vCPU") || !strings.Contains(v, "GiB") { - t.Errorf("machine picker must show specs:\n%s", v) +func press(t *testing.T, m model, keys ...string) (model, tea.Cmd) { + t.Helper() + var cmd tea.Cmd + for _, k := range keys { + var msg tea.KeyMsg + switch k { + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + msg = tea.KeyMsg{Type: tea.KeyEsc} + case "tab": + msg = tea.KeyMsg{Type: tea.KeyTab} + case "down": + msg = tea.KeyMsg{Type: tea.KeyDown} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} + } + next, c := m.Update(msg) + m, cmd = next.(model), c } + return m, cmd } -// fieldIndex is the position of key in config.Settings, failing the test if -// the key was renamed out from under it. -func fieldIndex(t *testing.T, key string) int { +// run executes a command chain until it yields a message the test cares +// about (doneMsg), feeding it back through Update. +func settle(t *testing.T, m model, cmd tea.Cmd) model { t.Helper() - for i, f := range config.Settings { - if f.Key == key { - return i + for i := 0; cmd != nil && i < 5; i++ { + msg := cmd() + if _, ok := msg.(doneMsg); !ok { + return m } + next, c := m.Update(msg) + m, cmd = next.(model), c } - t.Fatalf("no settable field %q", key) - return 0 + return m } -// The m key opens the resize picker preselected on the session's current -// type, and enter on that same type is a no-op notice, not a resize call. -func TestResizePicker(t *testing.T) { - resized := "" - m := model{loaded: true, - sessions: []driver.Session{{Name: "fix-auth", Repo: "myapp", State: driver.StateRunning, InstanceType: "t4g.medium"}}, - opts: Options{ - Fetch: func() ([]driver.Session, error) { return nil, nil }, - Resize: func(s driver.Session, itype string) error { resized = itype; return nil }, - Machines: func(s driver.Session) []driver.Machine { return awsec2.Machines(s.InstanceType) }, - }} - got, _ := m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")}) - m = got.(model) - if m.mode != modeResize { - t.Fatalf("m must open the picker, got mode=%v status=%q", m.mode, m.status) - } - if cur := m.machines[m.machIdx].Type; cur != "t4g.medium" { - t.Errorf("picker must preselect the current type, got %q", cur) - } +// Ready sessions are inventory, not work: they never sit in the session +// table, but the header counts them. +func TestReadySessionsStayOutOfTheTable(t *testing.T) { + m := loaded(t, newFake(), 140, 32) v := m.View() - for _, want := range []string{"resize fix-auth", "t4g.large", "(current)", "vCPU", "GiB"} { - if !strings.Contains(v, want) { - t.Errorf("picker view missing %q:\n%s", want, v) - } - } - - // Enter on the current type: notice, no resize. - got, cmd := m.updateResize(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if cmd != nil || resized != "" { - t.Errorf("enter on the current type must not resize, got resized=%q", resized) + if strings.Contains(v, "pool-flb-1") { + t.Error("a ready session must not render as a session row") } - if m.mode != modeList || !strings.Contains(m.status, "already") { - t.Errorf("want an already-that-size notice back on the list, got mode=%v status=%q", m.mode, m.status) + if !strings.Contains(v, "1 ready") || !strings.Contains(v, "checkout-flow") { + t.Errorf("header must count ready sessions and the table must list real ones:\n%s", v) } +} - // Reopen, move down one, confirm: the resize command fires. - got, _ = m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")}) - m = got.(model) - got, _ = m.updateResize(tea.KeyMsg{Type: tea.KeyDown}) - m = got.(model) - want := m.machines[m.machIdx].Type - got, cmd = m.updateResize(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if cmd == nil { - t.Fatal("enter on a different type must return the resize command") - } - if batch, ok := cmd().(tea.BatchMsg); ok { // Batch wraps, doesn't run - for _, c := range batch { - c() - } +func TestNewSessionSpawnsInTheBackground(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 30) + m, _ = press(t, m, "n", "f", "e", "a", "t") + if m.ov != ovNew || m.input != "feat" { + t.Fatalf("n must open the branch prompt, got ov=%v input=%q", m.ov, m.input) } - if resized != want { - t.Errorf("resize called with %q, want %q", resized, want) + m, cmd := press(t, m, "enter") + m = settle(t, m, cmd) + if len(f.spawned) != 1 || f.spawned[0] != "feat" { + t.Fatalf("enter must spawn a background create, got %v", f.spawned) } - if !m.loading || !strings.Contains(m.status, "resizing fix-auth") { - t.Errorf("want spinner + resizing status, got loading=%v status=%q", m.loading, m.status) + if !strings.Contains(m.status, "creating feat") || m.watch == 0 { + t.Errorf("the app must say where the create went and poll until it shows, status=%q watch=%d", m.status, m.watch) } } -// Enter on a still-creating session must not spawn ssh — mid-create attaches -// used to dump a raw TargetNotConnected. It shows a notice and stays put -// instead. -func TestEnterOnCreatingSession(t *testing.T) { - m := model{loaded: true, - opts: Options{Attach: func(driver.Session) (*exec.Cmd, error) { - t.Fatal("attach spawned for a creating session") - return nil, nil - }}, - sessions: []driver.Session{ - {Name: "half-built", Repo: "myapp", State: driver.StateCreating}, - }} - got, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyEnter}) - if cmd != nil { - t.Fatal("enter on a creating session must not attach") - } - gm := got.(model) - if gm.attachSess.Name != "" { - t.Error("attach in flight for a creating session") - } - if !strings.Contains(gm.status, "still setting up") || gm.statusBad { - t.Errorf("want a friendly notice, got status=%q bad=%v", gm.status, gm.statusBad) +func TestNewSessionNeedsARepo(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 30) + m.opts.RepoRoot = "" + m, _ = press(t, m, "n") + if m.ov == ovNew || !m.statusBad { + t.Errorf("outside a repo, n must explain instead of prompting") } } -func TestLogsKey(t *testing.T) { - m := model{loaded: true, - opts: Options{FetchLog: func(driver.Session) (string, error) { return "pier setup: done", nil }}, - sessions: []driver.Session{ - {Name: "half-built", Repo: "myapp", State: driver.StateCreating}, - }} - got, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - if cmd != nil { - t.Fatal("l on a creating session must do nothing — there is no log yet") - } - gm := got.(model) - if !strings.Contains(gm.status, "still setting up") || gm.statusBad { - t.Errorf("want a friendly notice, got status=%q bad=%v", gm.status, gm.statusBad) +func TestDeleteAsksFirst(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 30) + m, _ = press(t, m, "d") + if m.ov != ovConfirm || !strings.Contains(m.confirmQ, "checkout-flow") { + t.Fatalf("d must ask before destroying, got ov=%v q=%q", m.ov, m.confirmQ) } - - // A parked session must not resume as a side effect of opening logs. - m.sessions[0].State = driver.StateParked - got, cmd = m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - if cmd != nil { - t.Fatal("l on a parked session must not fire a fetch — that would resume the VM") + m, cmd := press(t, m, "n") + if cmd != nil || len(f.removed) != 0 { + t.Fatal("n must cancel") } - gm = got.(model) - if !strings.Contains(gm.status, "pier logs half-built") { - t.Errorf("want the notice to point at the CLI, got %q", gm.status) - } - - m.sessions[0].State = driver.StateRunning - got, cmd = m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - if cmd == nil { - t.Fatal("l on a running session must fire the log fetch") - } - gm = got.(model) - if gm.mode != modeLogs || gm.logSess.Name != "half-built" { - t.Errorf("want modeLogs on half-built, got mode=%v session=%q", gm.mode, gm.logSess.Name) - } - if !gm.logStick || !gm.logLoading { - t.Errorf("the viewer must open following the tail while it fetches, got stick=%v loading=%v", gm.logStick, gm.logLoading) - } - - // esc goes back to the list with the sessions intact. - got, _ = gm.updateLogs(tea.KeyMsg{Type: tea.KeyEsc}) - gm = got.(model) - if gm.mode != modeList || len(gm.sessions) != 1 { - t.Errorf("esc must return to the list, got mode=%v sessions=%d", gm.mode, len(gm.sessions)) + m, _ = press(t, m, "d") + m, cmd = press(t, m, "y") + settle(t, m, cmd) + if len(f.removed) != 1 || f.removed[0] != "checkout-flow" { + t.Errorf("y must remove the selected session, got %v", f.removed) } } -// Enter on a running session hands the terminal to ssh via ExecProcess — -// the TUI must survive the attach (no tea.Quit), so a detach lands back on -// the list. -func TestEnterAttachesWithoutQuitting(t *testing.T) { - attached := 0 - m := model{loaded: true, - opts: Options{Attach: func(driver.Session) (*exec.Cmd, error) { - attached++ - return exec.Command("true"), nil - }}, - sessions: []driver.Session{ - {Name: "fix-auth", Repo: "myapp", State: driver.StateRunning}, - }} - got, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyEnter}) - gm := got.(model) - if attached != 1 || cmd == nil { - t.Fatalf("enter must build and run the attach command, got attached=%d cmd=%v", attached, cmd) - } - if _, quit := cmd().(tea.QuitMsg); quit { - t.Error("enter must not quit the TUI to attach") - } - if gm.attachSess.Name != "fix-auth" { - t.Errorf("attachSess must mark the attach in flight, got %q", gm.attachSess.Name) - } - if !strings.Contains(gm.status, "C-b d") { - t.Errorf("the detach hint must show in the TUI, got status=%q", gm.status) - } - - // A second enter while the attach is in flight is a no-op. - if _, cmd := gm.updateList(tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil || attached != 1 { - t.Errorf("enter during an attach must do nothing, got attached=%d", attached) +func TestFailedCreateClearsInsteadOfDestroying(t *testing.T) { + m := loaded(t, newFake(), 120, 30) + m, _ = press(t, m, "down", "down", "down", "d") + if !strings.Contains(m.confirmQ, "clear the failed create broken") { + t.Errorf("a failed create has nothing to destroy; got %q", m.confirmQ) } } -// Enter on a parked session resumes it inside the TUI first, then attaches; -// a failed resume clears the in-flight state with an error status. -func TestEnterOnParkedResumesFirst(t *testing.T) { - resumed, attached := 0, 0 - m := model{loaded: true, - opts: Options{ - Resume: func(driver.Session) error { resumed++; return nil }, - Attach: func(driver.Session) (*exec.Cmd, error) { - attached++ - return exec.Command("true"), nil - }, - }, - sessions: []driver.Session{ - {Name: "fix-auth", Repo: "myapp", State: driver.StateParked}, - }} - got, cmd := m.updateList(tea.KeyMsg{Type: tea.KeyEnter}) - gm := got.(model) - if attached != 0 || !strings.Contains(gm.status, "resuming") || !gm.loading { - t.Fatalf("enter on parked must resume before attaching, got attached=%d status=%q", attached, gm.status) - } - var rm tea.Msg - if batch, ok := cmd().(tea.BatchMsg); ok { // Batch wraps, doesn't run - for _, c := range batch { - if msg, ok := c().(resumedMsg); ok { - rm = msg - } - } - } - if resumed != 1 || rm == nil { - t.Fatalf("the batch must run the resume, got resumed=%d msg=%v", resumed, rm) - } - got, cmd = gm.Update(rm) - gm = got.(model) - if attached != 1 || cmd == nil || gm.attachSess.Name != "fix-auth" { - t.Errorf("a finished resume must attach, got attached=%d cmd=%v sess=%q", attached, cmd, gm.attachSess.Name) - } - - // A failed resume surfaces the error and clears the in-flight state. - got, _ = gm.Update(resumedMsg{gm.attachSess, errors.New("quota exceeded")}) - gm = got.(model) - if !gm.statusBad || gm.attachSess.Name != "" { - t.Errorf("a failed resume must error and clear the attach, got status=%q sess=%q", gm.status, gm.attachSess.Name) +func TestAttachRefusesWhatIsntReady(t *testing.T) { + f := newFake() + f.sessions[0].State = pier.StateCreating + m := loaded(t, f, 120, 30) + m, cmd := press(t, m, "enter") + if cmd != nil || !m.statusBad || !strings.Contains(m.status, "still setting up") { + t.Errorf("attaching mid-create must refuse cleanly, got status %q", m.status) } } -// A fast transport failure gets exactly one wait-for-reachability and retry, -// mirroring the CLI attach loop; a second failure lands an error on the list. -func TestAttachRetriesOnceThenFails(t *testing.T) { - waited, attached := 0, 0 - s := driver.Session{Name: "fix-auth", Repo: "myapp", State: driver.StateRunning} - m := model{loaded: true, sessions: []driver.Session{s}, - attachSess: s, attachStart: time.Now(), - opts: Options{ - Fetch: func() ([]driver.Session, error) { return nil, nil }, - RetryAttach: func(error, time.Duration) bool { return true }, - WaitReachable: func(driver.Session) error { waited++; return nil }, - Attach: func(driver.Session) (*exec.Cmd, error) { - attached++ - return exec.Command("true"), nil - }, - }} - got, cmd := m.Update(attachDoneMsg{s, errors.New("exit status 255")}) - gm := got.(model) - if !gm.attachRetried || !strings.Contains(gm.status, "not reachable yet") { - t.Fatalf("a retryable failure must wait for reachability, got status=%q", gm.status) - } - var rm tea.Msg - if batch, ok := cmd().(tea.BatchMsg); ok { - for _, c := range batch { - if msg, ok := c().(reachableMsg); ok { - rm = msg - } - } - } - if waited != 1 || rm == nil { - t.Fatalf("the batch must run the reachability wait, got waited=%d msg=%v", waited, rm) +func TestParkedAttachResumesFirst(t *testing.T) { + m := loaded(t, newFake(), 120, 30) + m, cmd := press(t, m, "down", "enter") + if m.attaching != "fix-login" || cmd == nil { + t.Fatal("enter on a parked session must start the resume") } - got, cmd = gm.Update(rm) - gm = got.(model) - if attached != 1 || cmd == nil { - t.Fatalf("a reachable session must attach again, got attached=%d cmd=%v", attached, cmd) - } - - // The second failure is final: error status, no more waits. - got, _ = gm.Update(attachDoneMsg{s, errors.New("exit status 255")}) - gm = got.(model) - if !gm.statusBad || gm.attachSess.Name != "" || waited != 1 { - t.Errorf("a second failure must give up, got status=%q sess=%q waited=%d", gm.status, gm.attachSess.Name, waited) + if _, ok := cmd().(resumedMsg); !ok { + t.Error("a parked attach must resume before handing over the terminal") } } -// Detaching (ssh exiting 0) lands back on the list with a notice and a -// fresh session fetch — never at the shell. -func TestDetachReturnsToList(t *testing.T) { - s := driver.Session{Name: "fix-auth", Repo: "myapp", State: driver.StateRunning} - m := model{loaded: true, sessions: []driver.Session{s}, attachSess: s, - opts: Options{Fetch: func() ([]driver.Session, error) { return []driver.Session{s}, nil }}} - got, cmd := m.Update(attachDoneMsg{s, nil}) - gm := got.(model) - if !strings.Contains(gm.status, "detached from fix-auth") || gm.statusBad { - t.Errorf("want a detach notice, got status=%q bad=%v", gm.status, gm.statusBad) - } - if gm.attachSess.Name != "" { - t.Errorf("the attach must no longer be in flight, got %q", gm.attachSess.Name) - } - fetched := false - if batch, ok := cmd().(tea.BatchMsg); ok { - for _, c := range batch { - if _, ok := c().(sessionsMsg); ok { - fetched = true - } - } +func TestTabsAndRemindersBadge(t *testing.T) { + m := loaded(t, newFake(), 140, 32) + if !strings.Contains(m.header(), "•") { + t.Error("a pending rebake reminder must badge the Repos tab") } - if !fetched { - t.Error("a detach must refresh the session list") + m, _ = press(t, m, "tab") + if m.tab != tabRepos { + t.Fatal("tab must move to Repos") } -} - -// On the alternate screen the renderer keeps the LAST height lines when the -// view overflows, which would scroll the header off — the table must window -// its rows around the cursor instead. -func TestTableWindowFollowsCursor(t *testing.T) { - m := model{loaded: true, height: 15} - for i := range 40 { - m.sessions = append(m.sessions, driver.Session{ - Name: fmt.Sprintf("s%d", i), Repo: "myapp", State: driver.StateRunning, - }) - } - v := m.View() - if !strings.Contains(v, "s0") || strings.Contains(v, "s39") { - t.Errorf("cursor at the top must show the first rows:\n%s", v) - } - if c := strings.Count(v, "\n"); c > m.height { - t.Errorf("view is %d lines for a %d-line terminal:\n%s", c, m.height, v) - } - if !strings.Contains(v, "NAME") || !strings.Contains(v, "quit") { - t.Errorf("header and footer must survive the windowing:\n%s", v) - } - - m.cursor = 39 - v = m.View() - if !strings.Contains(v, "s39") || strings.Contains(v, "s0 ") { - t.Errorf("the window must follow the cursor to the bottom:\n%s", v) - } - - // No WindowSizeMsg yet (height 0): render everything, as inline tests do. - m.height, m.cursor = 0, 0 - v = m.View() - if !strings.Contains(v, "s0") || !strings.Contains(v, "s39") { - t.Errorf("zero height must render all rows:\n%s", v) + for _, want := range []string{"flb-estimation", "prebuilt", "62 days old", "1/1"} { + if !strings.Contains(v, want) { + t.Errorf("Repos tab missing %q:\n%s", want, v) + } } } -// FoldPool classifies members the way reconcile would; the generation check -// applies only to the repo whose local checkout produced curGen — other -// repos' gens are unknowable here. -func TestFoldPool(t *testing.T) { - now := time.Now() - ss := []driver.Session{ - {Repo: "shop", PoolGen: "aaa", State: driver.StateParked, Created: now.Add(-time.Hour)}, - {Repo: "shop", PoolGen: "bbb", State: driver.StateParked, Created: now}, // wrong gen - {Repo: "shop", PoolGen: "aaa", State: driver.StateCreating, Created: now}, - {Repo: "shop", PoolGen: "aaa", State: driver.StateDead, Created: now}, - {Repo: "shop", PoolGen: "aaa", State: driver.StateDeleting, Created: now}, - {Repo: "shop", State: driver.StateRunning, Created: now}, // a real session - {Repo: "tools", PoolGen: "zzz", State: driver.StateParked, Created: now}, - } - if st := FoldPool(ss, "shop", "shop", "aaa", 0, now); st.Ready != 1 || st.Filling != 1 || st.Stale != 2 { - t.Errorf("shop = %+v, want 1 ready, 1 filling, 2 stale", st) - } - if st := FoldPool(ss, "tools", "shop", "aaa", 0, now); st.Ready != 1 || st.Stale != 0 { - t.Errorf("tools = %+v, want its member ready (gen unknowable from here)", st) - } - - // Age judgments: a parked member past max_age is stale even for repos - // whose gen is unknowable, and an unparked member past the fill grace is - // a corpse, not "filling" — its fill died hours ago. - old := []driver.Session{ - {Repo: "tools", PoolGen: "zzz", State: driver.StateParked, Created: now.Add(-30 * 24 * time.Hour)}, - {Repo: "tools", PoolGen: "zzz", State: driver.StateCreating, Created: now.Add(-3 * time.Hour)}, +func TestReadyCountChangesOnlyFromTheRepo(t *testing.T) { + f := newFake() + m := loaded(t, f, 140, 32) + m, _ = press(t, m, "tab") + m, cmd := press(t, m, "+") + settle(t, m, cmd) + if f.ready["/code/flb-estimation"] != 2 { + t.Errorf("+ on this repo must raise its ready count, got %v", f.ready) } - if st := FoldPool(old, "tools", "", "", 14*24*time.Hour, now); st.Ready != 0 || st.Filling != 0 || st.Stale != 2 { - t.Errorf("old members = %+v, want both stale (over-age + grace-blown)", st) - } - if st := FoldPool(old[:1], "tools", "", "", 0, now); st.Ready != 1 { - t.Errorf("maxAge 0 = %+v, want the age check skipped", st) + m, _ = press(t, m, "down", "+") + if !m.statusBad { + t.Error("+ on another repo must explain that refills need its checkout") } } -// The w page is the primary pool surface: members split out of the session -// list (never as table rows), the header counts them, and the page shows -// per-repo warmth with what it costs. -func TestPoolPage(t *testing.T) { - if got, _ := (model{loaded: true}).updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}); got.(model).mode != modeList { - t.Error("w without pool hooks wired must be a no-op") - } - - now := time.Now() - m := model{loaded: true, opts: Options{ - PoolSizes: func() map[string]int { return map[string]int{"shop": 2} }, - CurrentRepo: "shop", - CurrentGen: "aaaaaaaaaaaa", - }} - got, _ := m.Update(sessionsMsg{sessions: []driver.Session{ - {Name: "fix-auth", Repo: "shop", State: driver.StateRunning, Created: now}, - {Name: "pool-shop-a1b2", Repo: "shop", State: driver.StateParked, PoolGen: "aaaaaaaaaaaa", Created: now.Add(-time.Hour)}, - {Name: "pool-shop-c3d4", Repo: "shop", State: driver.StateCreating, PoolGen: "aaaaaaaaaaaa", Created: now}, - {Name: "pool-tools-e5f6", Repo: "tools", State: driver.StateParked, PoolGen: "bbbbbbbbbbbb", Created: now}, - }}) - m = got.(model) - +func TestSettingsGroupedAndSavable(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 40) + m, _ = press(t, m, "s") v := m.View() - if strings.Contains(v, "pool-shop-a1b2") { - t.Errorf("pool members must never render as session rows:\n%s", v) - } - if !strings.Contains(v, "1 session(s)") || !strings.Contains(v, "3 warm") { - t.Errorf("header must count sessions and warm members separately:\n%s", v) - } - - got, _ = m.updateList(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) - m = got.(model) - if m.mode != modePool { - t.Fatalf("w must open the pool page, got mode=%v", m.mode) - } - if m.poolRepos[m.poolIdx] != "shop" { - t.Errorf("cursor must land on the current repo, got %q", m.poolRepos[m.poolIdx]) - } - v = m.View() - for _, want := range []string{"pier pools", "shop", "1 ready", "1 filling", "tools", "$3-4/mo", "this repo"} { + for _, want := range []string{"Cloud", "New sessions", "Idle & cost", "Speed", "park after", "ready sessions"} { if !strings.Contains(v, want) { - t.Errorf("pool page missing %q:\n%s", want, v) + t.Errorf("settings missing %q:\n%s", want, v) } } - // tools has a member but no configured pool — that's money leaking. - if !strings.Contains(v, "orphaned") { - t.Errorf("members without a configured pool must show as orphaned:\n%s", v) + if strings.Contains(v, "gcp") || strings.Contains(v, "zone") { + t.Error("only the active cloud's fields may show") } -} - -// Sizing works only on the current repo's row, stages with +/- (rendered as -// from→to), applies on enter through PoolSet, and esc cancels the staging -// before it leaves the page. -func TestPoolSizeEditing(t *testing.T) { - applied := -1 - sizes := map[string]int{"shop": 1} - m := model{loaded: true, mode: modePool, - poolSizes: sizes, poolRepos: []string{"other", "shop"}, poolIdx: 1, poolPend: -1, - members: []driver.Session{{Name: "pool-other-x", Repo: "other", State: driver.StateParked, PoolGen: "g", Created: time.Now()}}, - opts: Options{ - CurrentRepo: "shop", - PoolSizes: func() map[string]int { return sizes }, - Fetch: func() ([]driver.Session, error) { return nil, nil }, - PoolSet: func(size int) (string, error) { applied = size; return "/tmp/pool-shop.log", nil }, - }} - - for range 2 { - got, _ := m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("+")}) - m = got.(model) - } - if m.poolPend != 3 || applied != -1 { - t.Fatalf("+ must stage, not apply: pend=%d applied=%d", m.poolPend, applied) + // Pick the speed profile row and choose Lean. + for i, s := range m.settings { + if s.Key == "speed.profile" { + m.setIdx = i + } } - if v := m.View(); !strings.Contains(v, "1→3") { - t.Errorf("a staged size must render as from→to:\n%s", v) + m, _ = press(t, m, "enter") + if m.ov != ovPick { + t.Fatal("enter on a choice must open the picker") } - - got, _ := m.updatePool(tea.KeyMsg{Type: tea.KeyEsc}) - m = got.(model) - if m.poolPend != -1 || m.mode != modePool { - t.Fatalf("first esc must only cancel the staged size, got pend=%d mode=%v", m.poolPend, m.mode) + m, _ = press(t, m, "down", "enter") + if f.cfg.Profile() != "lean" || f.cfg.Speed.ReadySessions != 0 { + t.Errorf("choosing Lean must apply the preset, got %s ready=%d", f.cfg.Profile(), f.cfg.Speed.ReadySessions) } +} - got, _ = m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("+")}) - m = got.(model) - got, cmd := m.updatePool(tea.KeyMsg{Type: tea.KeyEnter}) - m = got.(model) - if cmd == nil { - t.Fatal("enter on a staged size must return the apply command") - } - var act tea.Msg - if batch, ok := cmd().(tea.BatchMsg); ok { // Batch wraps, doesn't run - for _, c := range batch { - if msg, ok := c().(poolActMsg); ok { - act = msg - } +func TestRejectedSettingKeepsTheEditorOpen(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 40) + m, _ = press(t, m, "s") + for i, s := range m.settings { + if s.Key == "aws.subnet" { + m.setIdx = i } } - if applied != 2 || act == nil { - t.Fatalf("apply must call PoolSet with the staged size, got applied=%d msg=%v", applied, act) - } - got, _ = m.Update(act) - m = got.(model) - if !strings.Contains(m.status, "pool size 2") || m.statusBad { - t.Errorf("a landed apply must confirm with the log path, got status=%q", m.status) + m, _ = press(t, m, "enter", "x", "y", "z", "enter") + if m.ov != ovEdit || !m.statusBad { + t.Errorf("a bad value must keep the editor open with the reason, ov=%v status=%q", m.ov, m.status) } +} - // Another repo's row can't be sized from here — its checkout is elsewhere. - m.poolIdx, m.poolPend = 0, -1 - got, _ = m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("+")}) - m = got.(model) - if m.poolPend != -1 || !strings.Contains(m.status, "inside other") { - t.Errorf("sizing another repo must be refused with a pointer, got pend=%d status=%q", m.poolPend, m.status) +func TestLogViewerSanitizesAndFollows(t *testing.T) { + m := loaded(t, newFake(), 120, 30) + m, _ = press(t, m, "down", "down", "l") + if m.ov != ovLogs { + t.Fatal("l must open the log viewer") + } + next, _ := m.Update(m.fetchLog()()) + m = next.(model) + v := m.View() + if !strings.Contains(v, "progress 100%") || strings.Contains(v, "progress 10%") || !strings.Contains(v, "following") { + t.Errorf("the viewer must show each line's final state and follow the tail:\n%s", v) } } -// Draining destroys money-costing VMs, so d asks y/n first; it works for any -// repo (no checkout needed) but refuses politely when there's nothing there. -func TestPoolDrainConfirm(t *testing.T) { - drained := "" - m := model{loaded: true, mode: modePool, - poolRepos: []string{"old"}, poolPend: -1, - members: []driver.Session{{Name: "pool-old-x", Repo: "old", State: driver.StateParked, PoolGen: "g", Created: time.Now()}}, - opts: Options{ - PoolSizes: func() map[string]int { return nil }, - Fetch: func() ([]driver.Session, error) { return nil, nil }, - PoolDrain: func(repo string) (int, error) { drained = repo; return 1, nil }, - }} - got, _ := m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) - m = got.(model) - if !m.poolConfirm { - t.Fatal("d on a repo with members must ask for confirmation") - } - if v := m.View(); !strings.Contains(v, "drain old") || !strings.Contains(v, "(y/n)") { - t.Errorf("the confirm prompt must name the repo:\n%s", v) - } - got, _ = m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) - m = got.(model) - if m.poolConfirm || drained != "" { - t.Fatalf("n must cancel the drain, got confirm=%v drained=%q", m.poolConfirm, drained) +func TestAuthExpiryOffersSignIn(t *testing.T) { + m := loaded(t, newFake(), 120, 30) + next, _ := m.Update(sessionsMsg{err: errors.New("aws sts get-caller-identity: Your session has expired. Please reauthenticate using 'aws login'.")}) + m = next.(model) + if !m.authRequired || !strings.Contains(m.View(), "sign in") { + t.Error("an expired login must offer to sign in again") } +} - got, _ = m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) - m = got.(model) - got, cmd := m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) - m = got.(model) - if cmd == nil { - t.Fatal("y must fire the drain") - } - var act tea.Msg - if batch, ok := cmd().(tea.BatchMsg); ok { - for _, c := range batch { - if msg, ok := c().(poolActMsg); ok { - act = msg +// Screens render inside the terminal at common sizes: no line wider than +// the window, header on top, keys at the bottom. +func TestScreensFitTheWindow(t *testing.T) { + for _, size := range [][2]int{{160, 40}, {100, 30}, {80, 24}} { + for _, tb := range []tab{tabSessions, tabRepos, tabSettings} { + m := loaded(t, newFake(), size[0], size[1]) + m.tab = tb + v := m.View() + lines := strings.Split(v, "\n") + if len(lines) > size[1] { + t.Errorf("%dx%d %s: %d lines, taller than the window", size[0], size[1], tabNames[tb], len(lines)) + } + for i, l := range lines { + if w := len([]rune(stripANSI(l))); w > size[0] { + t.Errorf("%dx%d %s line %d is %d wide", size[0], size[1], tabNames[tb], i, w) + } + } + if os.Getenv("PIER_TUI_DUMP") != "" { + t.Logf("\n%s", v) } } } - if drained != "old" || act == nil { - t.Fatalf("drain must target the selected repo, got %q", drained) - } - got, _ = m.Update(act) - m = got.(model) - if !strings.Contains(m.status, "drained 1 member") { - t.Errorf("a landed drain must report the count, got %q", m.status) - } - - m.members, m.poolConfirm, m.status = nil, false, "" - got, _ = m.updatePool(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) - m = got.(model) - if m.poolConfirm || !strings.Contains(m.status, "no warm members") { - t.Errorf("d on an empty pool must notice, not prompt, got confirm=%v status=%q", m.poolConfirm, m.status) - } } +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + func TestSanitizeLog(t *testing.T) { // pnpm/docker progress spam: color codes plus \r-redrawn meters. Only // each line's final state should survive. diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..72e681e --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,642 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/tombstone" + "github.com/usepier/pier/pkg/pier" +) + +// Screen: a one-line header (brand, tabs, live cost), the tab's panels, then +// a status line and the key hints. Wide terminals put the detail panel +// beside the list; narrow ones stack it underneath. + +func (m model) View() string { + if m.width == 0 { + return "" // first frame, before the size arrives + } + var body string + switch { + case m.ov == ovLogs: + body = m.logsView() + case m.ov == ovHelp: + body = m.helpView() + case m.ov == ovResize: + body = m.resizeView() + case m.ov == ovPick: + body = m.pickView() + default: + switch m.tab { + case tabSessions: + body = m.sessionsView() + case tabRepos: + body = m.reposView() + default: + body = m.settingsView() + } + } + return m.header() + "\n" + body + "\n" + m.sheet() + m.footer() +} + +// bodyHeight is what the panels get: the screen minus header, status and +// keys, and minus any bottom sheet. +func (m model) bodyHeight() int { + h := m.height - 3 + if m.ov == ovNew || m.ov == ovConfirm || m.ov == ovEdit { + h -= 3 + } + return max(h, 6) +} + +func (m model) header() string { + left := " " + sBrand.Render("⚓ pier") + " " + for i, name := range tabNames { + label := name + if tab(i) == tabRepos { + if n := m.reminderCount(); n > 0 { + label += " " + sWarn.Render("•") + } + } + if tab(i) == m.tab { + left += sTabOn.Render(name) + if label != name { + left += sWarn.Render("•") + } + } else { + left += sTabOff.Render(label) + } + } + + var meta []string + if m.loading { + meta = append(meta, sAccent.Render(spinFrames[m.frame%len(spinFrames)])) + } + meta = append(meta, m.be.Cloud()) + if m.loaded { + running, parked, hourly := 0, 0, 0.0 + for _, s := range m.sessions { + switch s.State { + case pier.StateRunning, pier.StateWorking, pier.StateIdle, pier.StateCreating: + running++ + hourly += pier.HourlyUSD(s) + case pier.StateParked: + parked++ + } + } + meta = append(meta, fmt.Sprintf("%d running", running)) + if parked > 0 { + meta = append(meta, fmt.Sprintf("%d parked", parked)) + } + if len(m.ready) > 0 { + meta = append(meta, fmt.Sprintf("%d ready", len(m.ready))) + } + if hourly > 0 { + meta = append(meta, fmt.Sprintf("~$%.2f/h", hourly)) + } + } + if m.quota != "" { + meta = append(meta, m.quota) + } + // Narrow windows drop the least important facts first (the list ends + // with them) rather than losing the whole right side. + for len(meta) > 0 { + right := sDim.Render(strings.Join(meta, " · ")) + " " + if gap := m.width - lipgloss.Width(left) - lipgloss.Width(right); gap >= 2 { + return left + strings.Repeat(" ", gap) + right + } + meta = meta[:len(meta)-1] + } + return fit(left, m.width) +} + +func (m model) reminderCount() int { + n := 0 + for _, r := range m.repos { + n += len(r.Reminders) + } + return n +} + +// split lays a list and a detail panel side by side on wide screens and +// stacked on narrow ones. +func (m model) split(list func(w, h int) string, detail func(w, h int) string) string { + h := m.bodyHeight() + if m.width >= 110 { + lw := m.width * 58 / 100 + return lipgloss.JoinHorizontal(lipgloss.Top, list(lw, h), detail(m.width-lw, h)) + } + dh := min(10, h/2) + return list(m.width, h-dh) + "\n" + detail(m.width, dh) +} + +// --- sessions --------------------------------------------------------------------- + +func (m model) sessionsView() string { + return m.split(m.sessionList, m.sessionDetail) +} + +func (m model) sessionList(w, h int) string { + inner := w - 4 + note := "" + if m.loaded { + note = strconv.Itoa(len(m.sessions)) + } + var lines []string + switch { + case m.authRequired: + lines = []string{"", sWarn.Render("your cloud login expired"), sDim.Render("press enter to sign in again")} + case !m.loaded: + lines = []string{"", sDim.Render("fetching sessions…")} + case len(m.sessions) == 0: + lines = emptySessions(m.repo) + default: + nameW, repoW := 4, 4 + for _, s := range m.sessions { + nameW = max(nameW, lipgloss.Width(s.Name)) + repoW = max(repoW, lipgloss.Width(s.Repo)) + } + nameW, repoW = min(nameW, max(inner/3, 12)), min(repoW, max(inner/5, 8)) + lines = append(lines, sDim.Render(fit(" NAME", nameW+2)+" "+fit("REPO", repoW)+" "+fit("AGE", 5)+" STATE")) + rows := h - 3 + start := scrollStart(m.sessIdx, len(m.sessions), rows) + now := time.Now() + for i := start; i < min(start+rows, len(m.sessions)); i++ { + s := m.sessions[i] + dot, word := badge(s, m.frame) + row := dot + " " + fit(s.Name, nameW) + " " + sFaint.Render(fit(s.Repo, repoW)) + " " + + sDim.Render(fit(pier.Age(s.Created, now), 5)) + " " + word + if i == m.sessIdx { + row = selected(row, inner) + } + lines = append(lines, row) + } + } + return panel("Sessions", note, lines, w, h, m.ov == ovNone) +} + +func emptySessions(repo string) []string { + lines := []string{"", sBold.Render("No sessions yet."), ""} + if repo != "" { + lines = append(lines, + "Press "+sKey.Render("n")+" to start one from "+sAccent.Render(repo)+".", + sDim.Render("It gets its own VM, your branch, secrets and agents,"), + sDim.Render("and parks itself when you're away.")) + } else { + lines = append(lines, + "Run "+sAccent.Render("pier ")+" inside a repo,", + sDim.Render("or open pier from a repo and press n.")) + } + return lines +} + +// scrollStart keeps the cursor row visible in a window of rows. +func scrollStart(cursor, n, rows int) int { + if rows <= 0 || n <= rows { + return 0 + } + start := cursor - rows/2 + return max(0, min(start, n-rows)) +} + +func (m model) sessionDetail(w, h int) string { + s, ok := m.current() + if !ok || m.authRequired { + return panel("Details", "", nil, w, h, false) + } + _, word := badge(s, m.frame) + lines := []string{field("state", word+stateNote(s))} + if s.Branch != "" && s.Branch != s.Name { + lines = append(lines, field("repo", s.Repo+sDim.Render(" · ")+s.Branch)) + } else { + lines = append(lines, field("repo", s.Repo)) + } + if s.State != pier.StateFailed { + machine := s.InstanceType + if s.CostNote != "" { + machine += sDim.Render(" · " + s.CostNote) + } + lines = append(lines, field("machine", machine)) + } + lines = append(lines, field("created", ago(s.Created))) + switch s.Setup { + case "running": + lines = append(lines, field("setup", sAccent.Render("running")+sDim.Render(" — l follows it"))) + case "failed": + lines = append(lines, field("setup", sBad.Render("failed")+sDim.Render(" — l shows why"))) + } + lines = append(lines, "") + for _, l := range wrap(sessionHint(s), w-4) { + lines = append(lines, sDim.Render(l)) + } + return panel(s.Name, "", lines, w, h, false) +} + +func stateNote(s pier.Session) string { + switch s.State { + case pier.StateWorking: + return sDim.Render(" · agent busy, you're detached") + case pier.StateIdle: + return sDim.Render(" · parks when the idle timer runs out") + case pier.StateParked: + return sDim.Render(" · disk only, nothing running") + } + return "" +} + +func sessionHint(s pier.Session) string { + switch s.State { + case pier.StateParked: + return "enter resumes it in about 20-60s, with your tmux windows and agent conversations back where you left them." + case pier.StateCreating: + return "Still setting up. The row flips when it's ready to attach." + case pier.StateFailed: + return "The create failed and rolled back — nothing is running or billing. " + + tombstone.Summarize(s.FailReason) + " · l shows the create log, d clears the row." + case pier.StateDeleting: + return "Being deleted." + } + if s.Strained { + return "Under sustained CPU or memory pressure — m resizes it in about a minute, disk intact." + } + return "enter attaches · detach with C-b d and it keeps running." +} + +// --- repos ------------------------------------------------------------------------ + +func (m model) reposView() string { + return m.split(m.repoList, m.repoDetail) +} + +func (m model) repoList(w, h int) string { + inner := w - 4 + var lines []string + switch { + case !m.loaded: + lines = []string{"", sDim.Render("fetching…")} + case len(m.repos) == 0: + lines = []string{"", sBold.Render("No repos yet."), "", sDim.Render("Repos appear here once you start a session from one.")} + default: + nameW := 4 + for _, r := range m.repos { + nameW = max(nameW, lipgloss.Width(r.Name)) + } + nameW = min(nameW, max(inner/3, 12)) + lines = append(lines, sDim.Render(fit(" REPO", nameW+2)+" "+fit("IMAGE", 18)+" "+fit("READY", 7)+" IDLE COST")) + for i, r := range m.repos { + mark := " " + if len(r.Reminders) > 0 { + mark = sWarn.Render("•") + } + if r.Current { + mark = sAccent.Render("›") + if len(r.Reminders) > 0 { + mark = sWarn.Render("›") + } + } + row := mark + " " + fit(r.Name, nameW) + " " + fit(imageCell(r), 18) + " " + fit(readyCell(r), 7) + " " + sFaint.Render(money(r.MonthlyUSD)) + if i == m.repoIdx { + row = selected(row, inner) + } + lines = append(lines, row) + } + } + return panel("Repos", "› this repo", lines, w, h, m.ov == ovNone) +} + +func imageCell(r pier.Repo) string { + switch { + case pier.BakeRunning(r.Name): + return sAccent.Render("baking…") + case r.Image == "": + return sDim.Render("none") + case r.Baked == nil: + return "baked" + } + kind := "prebuilt" + if !r.Baked.RepoIncluded { + kind = "toolchains" + } + return kind + sDim.Render(" · "+pier.Age(r.Baked.BakedAt, time.Now())) +} + +func readyCell(r pier.Repo) string { + if r.ReadyTarget == 0 && r.Ready+r.Filling == 0 { + return sDim.Render("off") + } + c := fmt.Sprintf("%d/%d", r.Ready, r.ReadyTarget) + if r.Filling > 0 { + c += sAccent.Render("+") + } + return c +} + +func money(usd float64) string { + if usd < 0.5 { + return "$0" + } + return fmt.Sprintf("~$%.0f/mo", usd) +} + +func (m model) repoDetail(w, h int) string { + r, ok := m.currentRepo() + if !ok { + return panel("Details", "", nil, w, h, false) + } + iw := w - 4 + var lines []string + switch { + case pier.BakeRunning(r.Name): + lines = append(lines, field("image", sAccent.Render("baking")+sDim.Render(" · log "+tilde(pier.BakeLogPath(r.Name))))) + case r.Image == "": + lines = append(lines, field("image", sDim.Render("none — sessions run the full setup"))) + case r.Baked == nil: + lines = append(lines, field("image", r.Image)) + default: + what := "toolchains + repo prebuilt" + if !r.Baked.RepoIncluded { + what = "toolchains only" + } + lines = append(lines, field("image", what+sDim.Render(" · baked "+ago(r.Baked.BakedAt)))) + } + ready := fmt.Sprintf("keep %d", r.ReadyTarget) + if r.ReadyTarget == 0 { + ready = "off" + } + var parts []string + if r.Ready > 0 { + parts = append(parts, fmt.Sprintf("%d parked", r.Ready)) + } + if r.Filling > 0 { + parts = append(parts, fmt.Sprintf("%d filling", r.Filling)) + } + if r.Stale > 0 { + parts = append(parts, fmt.Sprintf("%d recycling", r.Stale)) + } + if len(parts) > 0 { + ready += sDim.Render(" · " + strings.Join(parts, ", ")) + } + lines = append(lines, field("ready", ready)) + lines = append(lines, field("sessions", strconv.Itoa(r.Sessions))) + lines = append(lines, field("idle cost", money(r.MonthlyUSD)+sDim.Render(" while nothing runs"))) + lines = append(lines, "") + for _, rem := range r.Reminders { + for i, l := range wrap(rem.Message, iw-2) { + prefix := " " + if i == 0 { + prefix = sWarn.Render("! ") + } + lines = append(lines, prefix+l) + } + } + if len(r.Reminders) > 0 { + lines = append(lines, "") + } + var help string + if r.Current { + help = "b bakes the image (background). +/- sets ready sessions: parked, set-up copies that make the next session start in ~25s, each " + + money(m.be.DiskMonthlyUSD()) + " while waiting. x removes them." + } else { + help = "Bake and refill from inside this repo. x removes its ready sessions from anywhere." + } + for _, l := range wrap(help, iw) { + lines = append(lines, sDim.Render(l)) + } + return panel(r.Name, "", lines, w, h, false) +} + +// --- settings --------------------------------------------------------------------- + +func (m model) settingsView() string { + h := m.bodyHeight() + detailH := min(8, h/3) + listH := h - detailH + inner := m.width - 4 + + labelW, valW := 0, 0 + for _, f := range m.settings { + labelW = max(labelW, lipgloss.Width(f.Label)) + valW = max(valW, lipgloss.Width(m.displayOf(f))) + } + valW = min(valW, 28) + + var lines []string + cursorLine := 0 + for _, g := range config.Groups { + first := true + for i, f := range m.settings { + if f.Group != g.Key { + continue + } + if first { + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, sBrand.Render(g.Title)) + first = false + } + if i == m.setIdx { + cursorLine = len(lines) + } + row := " " + fit(f.Label, labelW) + " " + fit(m.displayOf(f), valW) + " " + sDim.Render(f.Hint) + if i == m.setIdx { + row = selected(row, inner) + } + lines = append(lines, row) + } + } + rows := listH - 2 + if len(lines) > rows { + start := scrollStart(cursorLine, len(lines), rows) + lines = lines[start:] + } + list := panel("Settings", "apply to new sessions", lines, m.width, listH, m.ov == ovNone) + + var about []string + if len(m.settings) > 0 { + f := m.settings[m.setIdx] + for _, l := range wrap(f.Detail, inner) { + about = append(about, sFaint.Render(l)) + } + if f.Default != "" { + about = append(about, sDim.Render("default: "+f.Default)) + } + } + title := "About" + if len(m.settings) > 0 { + title = m.settings[m.setIdx].Label + } + return list + "\n" + panel(title, "", about, m.width, detailH, false) +} + +// displayOf renders a setting's value; machine rows add their specs. +func (m model) displayOf(f pier.SettingValue) string { + if f.Key == "speed.profile" && f.Value == "custom" { + return "Custom" + } + return f.Display +} + +// --- overlays --------------------------------------------------------------------- + +// sheet is the bottom sheet for quick prompts: new session, confirm, edit. +func (m model) sheet() string { + var title string + var line string + switch m.ov { + case ovNew: + title = "New session from " + m.repo + line = sAccent.Render("branch ❯ ") + m.input + sAccent.Render("▌") + case ovConfirm: + title = "Confirm" + line = sWarn.Render(m.confirmQ) + " " + keys("y", "yes", "n", "no") + case ovEdit: + f := m.settings[m.setIdx] + title = f.Label + line = sAccent.Render("❯ ") + m.input + sAccent.Render("▌") + default: + return "" + } + return panel(title, "", []string{line}, m.width, 3, true) + "\n" +} + +func (m model) footer() string { + status := "" + if m.status != "" { + if m.statusBad { + status = " " + sBad.Render("! "+m.status) + } else { + status = " " + sAccent.Render("▸ ") + m.status + } + } + var k []string + switch { + case m.ov == ovNew || m.ov == ovEdit: + k = []string{"enter", "save", "esc", "cancel"} + case m.ov == ovConfirm: + case m.ov == ovLogs: + k = []string{"↑↓", "scroll", "space", "page", "G", "follow", "esc", "back"} + case m.ov == ovResize || m.ov == ovPick: + k = []string{"↑↓", "choose", "enter", "select", "esc", "cancel"} + case m.ov == ovHelp: + k = []string{"any key", "close"} + case m.authRequired: + k = []string{"enter", "sign in", "q", "quit"} + case m.tab == tabSessions: + k = []string{"enter", "attach", "n", "new", "l", "logs", "m", "resize", "p", "keep", "d", "delete", "tab", "repos"} + case m.tab == tabRepos: + k = []string{"b", "bake", "+/-", "ready", "x", "remove ready", "tab", "settings"} + default: + k = []string{"enter", "change", "↑↓", "move", "tab", "sessions"} + } + if m.ov == ovNone && !m.authRequired { + k = append(k, "?", "help", "q", "quit") + } + return fit(status, m.width) + "\n" + fitKeys(k, m.width-1) +} + +// fitKeys renders as many hint pairs as fit in w, always keeping the last +// two (help and quit) so a narrow window still says how to get out. +func fitKeys(pairs []string, w int) string { + if len(pairs) == 0 { + return "" + } + tail := pairs[max(len(pairs)-4, 0):] + head := pairs[:max(len(pairs)-4, 0)] + for { + all := append(append([]string{}, head...), tail...) + if line := " " + keys(all...); lipgloss.Width(line) <= w || len(head) == 0 { + return fit(line, w+1) + } + head = head[:len(head)-2] + } +} + +// centered draws a picker panel in the middle of the body area. +func (m model) centered(title, note string, lines []string) string { + w := min(max(m.width*2/3, 50), m.width) + h := min(len(lines)+2, m.bodyHeight()) + box := panel(title, note, lines, w, h, true) + return lipgloss.Place(m.width, m.bodyHeight(), lipgloss.Center, lipgloss.Center, box) +} + +func (m model) resizeView() string { + s, _ := m.current() + var lines []string + for i, mc := range m.machines { + row := fmt.Sprintf("%-14s %3s vCPU %4s GB %s", mc.Type, mc.CPU, mc.Mem, mc.Cost) + if mc.Type == s.InstanceType { + row += sDim.Render(" current") + } + if i == m.pickIdx { + row = selected(row, min(max(m.width*2/3, 50), m.width)-4) + } + lines = append(lines, row) + } + return m.centered("Resize "+s.Name, "same CPU arch · on-demand while running", lines) +} + +func (m model) pickView() string { + f := m.settings[m.setIdx] + w := min(max(m.width*2/3, 50), m.width) - 4 + labelW := 0 + for _, o := range m.pickOpts { + l := o.Label + if l == "" { + l = o.Value + } + labelW = max(labelW, lipgloss.Width(l)) + } + var lines []string + for i, o := range m.pickOpts { + l := o.Label + if l == "" { + l = o.Value + } + row := fit(l, labelW) + " " + sDim.Render(o.Desc) + if o.Value == f.Value { + row = fit(l, labelW) + " " + sDim.Render(o.Desc) + sAccent.Render(" ✓") + } + if i == m.pickIdx { + row = selected(row, w) + } + lines = append(lines, row) + } + return m.centered(f.Label, f.Hint, lines) +} + +func (m model) helpView() string { + sec := func(t string) string { return sBrand.Render(t) } + row := func(k, d string) string { return " " + sKey.Render(fit(k, 10)) + d } + lines := []string{ + sec("Everywhere"), + row("tab ← →", "switch tabs (or 1 2 3)"), + row("r", "refresh"), + row("s", "settings"), + row("q", "quit"), + "", + sec("Sessions"), + row("enter", "attach — a parked session resumes with tmux restored"), + row("n", "new session from this repo (runs in the background)"), + row("l", "setup log, live"), + row("m", "resize the VM"), + row("p", "keep: never park when idle"), + row("d", "destroy (or clear a failed create)"), + "", + sec("Repos"), + row("b", "bake this repo's session image"), + row("+ -", "ready sessions: parked, set-up copies for ~25s starts"), + row("x", "remove a repo's ready sessions"), + "", + sDim.Render(" Sessions park themselves when you're away: only the disk costs money."), + sDim.Render(" The CLI does all of this too — `pier help`."), + } + return m.centered("Help", "pier "+m.opts.Version, lines) +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e98bc72..328b998 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,10 +5,8 @@ package ui import ( - "fmt" "os" "strings" - "time" "github.com/charmbracelet/lipgloss" ) @@ -38,15 +36,6 @@ func Step(s string) string { return Accent.Render(" ▸") + " " + s } -// TimedSteps prints progress lines as Step does, each suffixed with the time -// since start — so a slow create or fill says which step ate the wait, in the -// terminal and in the background logs alike. -func TimedSteps(start time.Time) func(string) { - return func(s string) { - fmt.Println(Step(s) + Dim.Render(fmt.Sprintf(" +%s", time.Since(start).Round(time.Second)))) - } -} - // Mark renders a green ✓ or red ✗. func Mark(ok bool) string { if ok { diff --git a/internal/wizard/skills.go b/internal/wizard/skills.go index 45d7eb2..3ed457d 100644 --- a/internal/wizard/skills.go +++ b/internal/wizard/skills.go @@ -36,7 +36,7 @@ func AgentDirs(home string) []string { // user skills directory. Claude Code and Codex read the same SKILL.md // layout, so one tree serves both. Existing copies are refreshed in place, // so a pier upgrade propagates skill fixes. Runs from the wizard's skills -// step and standalone as `pier skills`; returns print-ready progress lines +// step and standalone as `pier setup --skills`; returns print-ready progress lines // and any installation error. func InstallSkills(home string, agents []string) ([]string, error) { var notes []string @@ -73,9 +73,16 @@ func offerSkills(in *bufio.Reader, cfg *config.Config, home string) error { } } if len(chosen) == 0 { - fmt.Println(ui.Dim.Render(" (skipped — `pier skills` installs them anytime)")) + fmt.Println(ui.Dim.Render(" (skipped — `pier setup --skills` installs them anytime)")) return nil } + return installChosen(cfg, home, chosen) +} + +// installChosen installs the bundled skills for the chosen agents and, when +// the user copies agent config into sessions at all, adds each installed +// skills dir to the session manifest so the skill travels too. +func installChosen(cfg *config.Config, home string, chosen []string) error { saved := false for _, agent := range chosen { notes, installErr := InstallSkills(home, []string{agent}) diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index f102752..a81558e 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -1,8 +1,7 @@ -// Package wizard implements `pier setup`: detect → ask (plain prompts, ≤6 -// questions) → apply groundwork → doctor → write config → offer skills → -// offer bake. -// Everything detected becomes a prefilled default, so a second dev on a -// prepared account just presses enter a few times. +// Package wizard implements `pier setup`: cloud and account → every default +// shown for confirm or edit → groundwork and checks → the offer to bake the +// current repo. Everything detected becomes a prefilled default, so a second +// dev on a prepared account just presses enter a few times. package wizard import ( @@ -17,12 +16,15 @@ import ( "os/signal" "path/filepath" "slices" + "strconv" "strings" "syscall" + "time" "github.com/usepier/pier/internal/config" "github.com/usepier/pier/internal/driver" "github.com/usepier/pier/internal/ui" + "github.com/usepier/pier/pkg/pier" ) // adminDoc is for devs without IAM rights: the exact groundwork their admin @@ -77,7 +79,12 @@ gcloud compute firewall-rules create pier-deny-ingress \ # by instanceAdmin). ` -func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bool) error { +// Run is `pier setup`: cloud and account, then every default shown in one +// block for the user to confirm or edit (nothing is applied silently), the +// account groundwork, checks, and — inside a repo — the offer to bake its +// session image. It ends by pointing at the settings page, where all of it +// can change later. +func Run(opts pier.Options, printAdminOnly bool) error { if printAdminOnly { // The doc matches the configured cloud; without a config, AWS. if cfg, err := config.Load(); err == nil && cfg.Driver == "gcp-gce" { @@ -90,7 +97,6 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo in := bufio.NewReader(os.Stdin) ctx := context.Background() - // 1. detect fmt.Println("\n " + ui.Title.Render("⚓ pier setup") + ui.Dim.Render(" sessions run on your own cloud account; nothing leaves it")) fmt.Println() @@ -108,7 +114,8 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo cfg = existing // re-running keeps previous answers as defaults } - // 2. ask — the cloud first, then that cloud's own few questions. + // 1. cloud and account — the only questions without a default answer. + fmt.Println(" " + ui.Accent.Render("1 · cloud")) cloudDef := "aws" if cfg.Driver == "gcp-gce" { cloudDef = "gcp" @@ -130,41 +137,40 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo default: return fmt.Errorf("unknown cloud — aws or gcp") } - cfg.IdleTimeout = ask(in, "self-park after idling for (e.g. 30m, never)", cfg.IdleTimeout) + drv, err := pier.NewDriver(cfg, opts) + if err != nil { + return err + } + // 2. defaults — shown together, confirmed or edited, never assumed. if len(cfg.Secrets.Manifest) == 0 { cfg.Secrets.Manifest = detectManifest() } - if len(cfg.Secrets.Manifest) > 0 { - fmt.Println(" found agent config to copy into sessions:") - for _, m := range cfg.Secrets.Manifest { - fmt.Println(ui.Dim.Render(" ~/" + m)) - } - if !yes(in, "copy these into every session?", true) { - cfg.Secrets.Manifest = nil - } + home, _ := os.UserHomeDir() + agents := AgentDirs(home) + skills := len(agents) > 0 + fmt.Println("\n " + ui.Accent.Render("2 · defaults") + ui.Dim.Render(" for new sessions — all of it changes later in settings")) + printDefaults(cfg, drv, agents, skills) + switch strings.ToLower(ask(in, "use these? [Y]es · [e]dit", "")) { + case "e", "edit": + editDefaults(in, &cfg, drv) + skills = false // edited: the per-agent prompts below decide instead } - if cfg.Secrets.ClaudeOAuthToken == "" { if p := claudeSelfContained(); p != "" && slices.Contains(cfg.Secrets.Manifest, ".claude/settings.json") { fmt.Printf(" %s claude auth: %s in ~/.claude/settings.json %s\n", - ui.Mark(true), p, ui.Dim.Render("— travels with the manifest, no token needed")) - } else { + ui.Mark(true), p, ui.Dim.Render("— travels with the copied config, no token needed")) + } else if slices.ContainsFunc(cfg.Secrets.Manifest, func(m string) bool { return strings.HasPrefix(m, ".claude") }) { fmt.Println(ui.Dim.Render(" Claude subscription auth lives in the macOS Keychain and can't be copied.")) - fmt.Println(ui.Dim.Render(" Run `claude setup-token` in another terminal to mint a session token.")) + fmt.Println(ui.Dim.Render(" Run `claude setup-token` in another terminal to mint a token for sessions.")) if tok := ask(in, "paste token (enter to skip)", ""); tok != "" { cfg.Secrets.ClaudeOAuthToken = tok } } } - // 3. apply - drv, err := newDriver(cfg) - if err != nil { - return err - } - fmt.Println("\n " + ui.Accent.Render("creating groundwork") + - ui.Dim.Render(" "+groundwork)) + // 3. groundwork + fmt.Println("\n " + ui.Accent.Render("3 · account") + ui.Dim.Render(" "+groundwork)) rep, err := drv.SetupOnce(ctx) if err != nil && strings.Contains(err.Error(), "no default VPC") { // Enterprise accounts routinely delete the default VPC. Without this @@ -173,7 +179,7 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo fmt.Println(" "+ui.Mark(false), err.Error()) if subnet := ask(in, "subnet for sessions (subnet-...)", ""); subnet != "" { cfg.AWS.Subnet = subnet - if drv, err = newDriver(cfg); err != nil { + if drv, err = pier.NewDriver(cfg, opts); err != nil { return err } rep, err = drv.SetupOnce(ctx) @@ -199,27 +205,25 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo fmt.Println(ui.Dim.Render(" = found " + e)) } - // Write only what the wizard actually asked about. The prompts above can - // take minutes, and cfg was read before them — saving it wholesale would + // Write only what the wizard asked about. The prompts above can take + // minutes, and cfg was read before them — saving it wholesale would // revert anything written meanwhile, including a `pier bake` finishing in - // another terminal. Untouched settings (disk size, connection mode, the - // baked-image maps) come from disk, not from this stale copy. + // another terminal. Untouched settings (connection mode, the image maps) + // come from disk, not from this stale copy. if _, err := config.Update(func(c *config.Config) error { c.Driver = cfg.Driver - c.IdleTimeout = cfg.IdleTimeout + c.IdleTimeout, c.UnattendedCap = cfg.IdleTimeout, cfg.UnattendedCap c.Secrets = cfg.Secrets + c.Speed = cfg.Speed c.AWS.Profile, c.AWS.Region = cfg.AWS.Profile, cfg.AWS.Region - c.AWS.InstanceType, c.AWS.Subnet = cfg.AWS.InstanceType, cfg.AWS.Subnet + c.AWS.InstanceType, c.AWS.DiskGiB, c.AWS.Subnet = cfg.AWS.InstanceType, cfg.AWS.DiskGiB, cfg.AWS.Subnet c.GCP.Project, c.GCP.Zone = cfg.GCP.Project, cfg.GCP.Zone - c.GCP.MachineType = cfg.GCP.MachineType + c.GCP.MachineType, c.GCP.DiskGiB = cfg.GCP.MachineType, cfg.GCP.DiskGiB return nil }); err != nil { return err } fmt.Println(ui.Dim.Render(" wrote " + config.Path())) - - // 4. doctor - fmt.Println("\n " + ui.Accent.Render("checks")) for _, c := range drv.Doctor(ctx) { line := " " + ui.Mark(c.OK) + " " + c.Name if c.Detail != "" { @@ -227,47 +231,248 @@ func Run(newDriver func(config.Config) (driver.Driver, error), printAdminOnly bo } fmt.Println(line) } - - // 5. offer the bundled skills — per-agent confirms, before bake so no - // question hides behind the build. - if home, err := os.UserHomeDir(); err == nil { + if skills { + if err := installChosen(&cfg, home, agents); err != nil { + return err + } + } else if len(agents) > 0 { if err := offerSkills(in, &cfg, home); err != nil { return err } } - // 6. offer bake — images are repo-specific, so only when the wizard runs - // inside a repo; otherwise point at `pier bake` from one. - fmt.Println() - if repo := gitToplevel(); repo == "" { - fmt.Println(ui.Dim.Render(" (images bake per repo: cd && pier bake — harnesses plus one full .pier/setup.sh run, so creates skip both)")) - } else if name := filepath.Base(repo); yes(in, "bake the session image for "+name+" now? (harnesses ~5 min plus one full .pier/setup.sh run; creates then skip both)", true) { - // ctrl-c mid-bake must cancel the ctx so Bake's deferred cleanup can - // terminate the temporary instance — it has no supervisor, so a - // leaked one never parks itself. - bctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - img, err := drv.Bake(bctx, driver.BakeSpec{ - RepoName: name, RepoRoot: repo, HookPath: driver.BakeHook(repo), - Replaces: cfg.BakedReplaces(name), - }) - stop() - if err != nil { + // 4. this repo's session image — offered, never assumed. + if repo := gitToplevel(); repo != "" { + if err := offerBake(ctx, in, opts, repo); err != nil { return err } - // Update, not Save: the bake just took minutes, and cfg predates it. - if _, err := config.Update(func(c *config.Config) error { - c.RecordBake(name, img) - return nil - }); err != nil { - return err + } + + fmt.Println("\n " + ui.OK.Render("✓ pier is ready")) + fmt.Println(" start a session " + ui.Accent.Render("cd && pier ")) + fmt.Println(" change anything " + ui.Accent.Render("run `pier`, press s for settings")) + fmt.Println() + return nil +} + +// printDefaults shows every default a new session will use, with what it +// costs, so the confirm that follows is informed. +func printDefaults(cfg config.Config, drv driver.Driver, agents []string, skills bool) { + row := func(label, value, note string) { + fmt.Printf(" %-14s %s %s\n", label, value, ui.Dim.Render(note)) + } + machine, disk := cfg.AWS.InstanceType, cfg.AWS.DiskGiB + if cfg.Driver == "gcp-gce" { + machine, disk = cfg.GCP.MachineType, cfg.GCP.DiskGiB + } + row("machine", machine, machineNote(drv, machine)) + row("disk", fmt.Sprintf("%d GiB", disk), fmt.Sprintf("the part that survives parking, ~$%.0f/mo while parked", diskMonthly(cfg, disk))) + row("park after", cfg.IdleTimeout+" idle", "detached and quiet this long → the VM stops, the disk stays") + row("runaway cap", cfg.UnattendedCap, "parks even a busy agent once you've been away this long") + row("speed", profileLabel(cfg.Profile()), profileNote(cfg, cfg.Profile(), disk)) + row("copied in", manifestSummary(cfg.Secrets.Manifest), "agent config each session starts with") + if len(agents) > 0 { + names := make([]string, len(agents)) + for i, a := range agents { + names[i] = strings.TrimPrefix(a, ".") + } + state := "install for " + strings.Join(names, ", ") + if !skills { + state = "skip" } - fmt.Println(" "+ui.Mark(true), "baked", img, "for", name) + row("agent skill", state, "pier-onboard teaches your agent to set a repo up for pier") + } + fmt.Println() +} + +// editDefaults walks each default with its current value preselected; enter +// keeps it. +func editDefaults(in *bufio.Reader, cfg *config.Config, drv driver.Driver) { + machine := &cfg.AWS.InstanceType + disk := &cfg.AWS.DiskGiB + if cfg.Driver == "gcp-gce" { + machine, disk = &cfg.GCP.MachineType, &cfg.GCP.DiskGiB + } + if ms := drv.Machines(*machine); len(ms) > 0 { + fmt.Println(ui.Dim.Render(" machines:")) + for i, m := range ms { + fmt.Printf(" %s %-14s %s vCPU · %s GB · %s\n", ui.Dim.Render(fmt.Sprintf("%d", i+1)), m.Type, m.CPU, m.Mem, m.Cost) + } + pick := ask(in, "machine (number or type)", *machine) + if n, err := strconv.Atoi(pick); err == nil && n >= 1 && n <= len(ms) { + pick = ms[n-1].Type + } + setOr(cfg, keyFor(cfg, "machine"), pick) } else { - fmt.Println(ui.Dim.Render(" (you can run `pier bake` in any repo, anytime)")) + setOr(cfg, keyFor(cfg, "machine"), ask(in, "machine", *machine)) + } + setOr(cfg, keyFor(cfg, "disk"), ask(in, "disk GiB (20 / 40 / 80 / 160)", strconv.Itoa(*disk))) + setOr(cfg, "idle_timeout", ask(in, "park after idle (15m / 30m / 1h / 2h / never)", cfg.IdleTimeout)) + setOr(cfg, "unattended_cap", ask(in, "runaway cap (4h / 8h / 24h / never)", cfg.UnattendedCap)) + fmt.Println(ui.Dim.Render(" speed profiles:")) + for _, p := range []string{"fast", "lean", "minimal"} { + fmt.Printf(" %-8s %s\n", profileLabel(p), ui.Dim.Render(profileNote(*cfg, p, *disk))) + } + def := cfg.Profile() + if def == "custom" { + def = "" + } + if p := ask(in, "speed (fast / lean / minimal)", def); p != "" { + if err := cfg.ApplyProfile(strings.ToLower(p)); err != nil { + fmt.Println(" " + ui.Warn.Render("!") + " " + err.Error() + ui.Dim.Render(" — kept "+cfg.Profile())) + } + } + if len(cfg.Secrets.Manifest) > 0 { + fmt.Println(ui.Dim.Render(" copied into every session:")) + for i, m := range cfg.Secrets.Manifest { + fmt.Printf(" %s ~/%s\n", ui.Dim.Render(fmt.Sprintf("%d", i+1)), m) + } + if drop := ask(in, "numbers to leave out (enter keeps all)", ""); drop != "" { + skip := map[int]bool{} + for _, f := range strings.Fields(strings.ReplaceAll(drop, ",", " ")) { + if n, err := strconv.Atoi(f); err == nil { + skip[n] = true + } + } + var kept []string + for i, m := range cfg.Secrets.Manifest { + if !skip[i+1] { + kept = append(kept, m) + } + } + cfg.Secrets.Manifest = kept + } } +} + +// setOr applies a value through config.Set, keeping the old one (and saying +// so) when it doesn't validate. +func setOr(cfg *config.Config, key, val string) { + if err := config.Set(cfg, key, val); err != nil { + fmt.Println(" " + ui.Warn.Render("!") + " " + err.Error() + ui.Dim.Render(" — kept "+config.Get(*cfg, key))) + } +} + +func keyFor(cfg *config.Config, what string) string { + prefix := "aws." + if cfg.Driver == "gcp-gce" { + prefix = "gcp." + } + switch what { + case "machine": + if prefix == "gcp." { + return "gcp.machine_type" + } + return "aws.instance_type" + default: + return prefix + "disk_gib" + } +} + +func machineNote(drv driver.Driver, machine string) string { + for _, m := range drv.Machines(machine) { + if m.Type == machine { + return fmt.Sprintf("%s vCPU · %s GB · %s — resize any session later", m.CPU, m.Mem, m.Cost) + } + } + return "resize any session later" +} + +func diskMonthly(cfg config.Config, gib int) float64 { + if cfg.Driver == "gcp-gce" { + return float64(gib) * 0.10 + } + return float64(gib) * 0.095 +} + +func profileLabel(p string) string { + switch p { + case "fast": + return "Fast" + case "lean": + return "Lean" + case "minimal": + return "Minimal" + } + return "Custom" +} - fmt.Println("\n " + ui.OK.Render("done") + " — try: " + - ui.Accent.Render("cd && pier my-branch")) +// profileNote says what a speed profile does and costs while idle. +func profileNote(cfg config.Config, p string, disk int) string { + switch p { + case "fast": + return fmt.Sprintf("image + 1 ready session per repo · new sessions in ~25s · ~$%.0f/mo per repo idle", diskMonthly(cfg, disk)+2) + case "lean": + return "image per repo · new sessions in ~1-2 min · ~$2/mo per repo idle" + case "minimal": + return "nothing stored · every session runs the full setup · $0 idle" + } + return fmt.Sprintf("%d ready session(s) per repo, bake reminders %s", cfg.Speed.ReadySessions, onOff(cfg.Speed.BakeReminders)) +} + +func onOff(b bool) string { + if b { + return "on" + } + return "off" +} + +// manifestSummary names what's copied in, briefly: "Claude, Codex, tmux". +func manifestSummary(m []string) string { + if len(m) == 0 { + return "nothing" + } + var parts []string + seen := map[string]bool{} + for _, e := range m { + name := e + switch { + case strings.HasPrefix(e, ".claude"): + name = "Claude" + case strings.HasPrefix(e, ".codex"): + name = "Codex" + case e == ".tmux.conf": + name = "tmux" + } + if !seen[name] { + seen[name] = true + parts = append(parts, name) + } + } + return strings.Join(parts, ", ") + " config" +} + +// offerBake asks to bake the repo's session image now. A no is fine: the +// same suggestion comes back as a reminder after creates. +func offerBake(ctx context.Context, in *bufio.Reader, opts pier.Options, repo string) error { + name := filepath.Base(repo) + c, err := pier.Open(opts) + if err != nil { + return err + } + if c.Config().BakedImage(name) != "" { + return nil + } + fmt.Println("\n " + ui.Accent.Render("4 · "+name)) + if _, err := os.Stat(filepath.Join(repo, ".pier", "setup.sh")); err != nil { + fmt.Println(ui.Dim.Render(" no .pier/setup.sh yet — ask your agent to \"set this repo up for pier\" so sessions install its dependencies")) + } + if !yes(in, "No session image for "+name+" yet. Bake one now? New sessions then start in a minute or two instead of running the full setup", true) { + fmt.Println(ui.Dim.Render(" later: `pier bake` in the repo")) + return nil + } + // ctrl-c mid-bake must cancel the ctx so the bake's cleanup can + // terminate the temporary instance — it has no supervisor, so a leaked + // one never parks itself. + bctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + res, err := c.Bake(bctx, pier.BakeRequest{RepoRoot: repo, Progress: func(e pier.Event) { + fmt.Println(ui.Step(e.Message) + ui.Dim.Render(fmt.Sprintf(" +%s", e.Elapsed.Round(time.Second)))) + }}) + if err != nil { + return err + } + fmt.Println(" "+ui.Mark(true), "baked", res.Image, "for", name) return nil } @@ -298,7 +503,6 @@ func askAWS(in *bufio.Reader, cfg *config.Config) error { cfg.AWS.Region = strings.TrimSpace(string(out)) } cfg.AWS.Region = ask(in, "region", or(cfg.AWS.Region, "eu-central-1")) - cfg.AWS.InstanceType = ask(in, "instance type", cfg.AWS.InstanceType) return nil } @@ -340,7 +544,6 @@ func askGCP(in *bufio.Reader, cfg *config.Config) error { return fmt.Errorf("cannot access project %q: %s", cfg.GCP.Project, strings.TrimSpace(string(out))) } cfg.GCP.Zone = ask(in, "zone", cfg.GCP.Zone) - cfg.GCP.MachineType = ask(in, "machine type", cfg.GCP.MachineType) return nil } diff --git a/pkg/pier/account.go b/pkg/pier/account.go new file mode 100644 index 0000000..eede10a --- /dev/null +++ b/pkg/pier/account.go @@ -0,0 +1,125 @@ +package pier + +import ( + "context" + "fmt" + "time" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/pool" +) + +// Doctor runs the cloud driver's environment and account checks, plus +// pier's own: ready sessions nothing will claim, and ones stuck filling. +func (c *Client) Doctor(ctx context.Context) []Check { + checks := c.drv.Doctor(ctx) + sessions, err := c.drv.List(ctx) + if err != nil { + return checks + } + // Orphans are parked money nothing will ever claim: repos whose ready + // sessions were turned off (or another machine's config). Members + // unparked past the fill grace are worse — a refill died before its + // member parked, and it bills at full rate until the next refill + // reconciles it away. + orphans, stuck := map[string]int{}, map[string]int{} + for _, s := range sessions { + switch { + case s.PoolGen == "" || s.State == driver.StateDeleting: + case s.State != driver.StateParked && s.State != driver.StateDead && + time.Since(s.Created) >= pool.FillGrace: + stuck[s.Repo]++ + case c.cfg.PoolSize(s.Repo) == 0: + orphans[s.Repo]++ + } + } + for repo, n := range orphans { + checks = append(checks, Check{ + Name: "ready sessions " + repo, + Detail: fmt.Sprintf("%d ready session(s) but none configured — `pier ready 0` in %s removes them", n, repo), + }) + } + for repo, n := range stuck { + checks = append(checks, Check{ + Name: "ready sessions " + repo, + Detail: fmt.Sprintf("%d stuck filling for 2h+ — running at full price; `pier ready 0` in %s removes them", n, repo), + }) + } + return checks +} + +// Teardown destroys every ready session, then all pier groundwork and +// images in the account, and forgets the images and ready-session settings. +// Live sessions block it: the driver refuses while instances exist. +func (c *Client) Teardown(ctx context.Context, progress Progress) error { + start := time.Now() + sessions, err := c.drv.List(ctx) + if err != nil { + return err + } + repos := map[string]bool{} + for _, s := range sessions { + if s.PoolGen != "" { + repos[s.Repo] = true + } + } + for repo := range repos { + if _, err := pool.Drain(ctx, c.drv, sessions, repo, progress.stepper(start)); err != nil { + return err + } + } + if err := c.drv.Teardown(ctx); err != nil { + return err + } + next, err := config.Update(func(cfg *config.Config) error { + cfg.ClearBakes() + cfg.Pool.Sizes = nil + return nil + }) + if err == nil { + c.cfg = next + } + return err +} + +// Settings returns the settings schema with current values: every field +// that applies to the active cloud, in page order. +func (c *Client) Settings() []SettingValue { + var out []SettingValue + for _, f := range config.Settings { + if !c.cfg.Visible(f) { + continue + } + v := config.Get(c.cfg, f.Key) + out = append(out, SettingValue{Field: f, Value: v, Display: f.Display(v)}) + } + return out +} + +// SettingValue is one settings row: the field's schema and its value. +type SettingValue struct { + config.Field + Value string // stored value + Display string // human rendering +} + +// Set validates and saves one setting. The client's view refreshes, but +// the driver doesn't: build a new Client for changes to take effect on +// cloud calls. +func (c *Client) Set(key, value string) error { + next, err := config.Update(func(cfg *config.Config) error { + return config.Set(cfg, key, value) + }) + if err != nil { + return err + } + c.cfg = next + return nil +} + +// MachineCatalog lists the machine types the settings picker offers for the +// active cloud, around currentType. +func (c *Client) MachineCatalog(currentType string) []Machine { + return c.drv.Machines(currentType) +} diff --git a/pkg/pier/client.go b/pkg/pier/client.go new file mode 100644 index 0000000..0d41e0c --- /dev/null +++ b/pkg/pier/client.go @@ -0,0 +1,203 @@ +// Package pier is the one API every pier frontend uses: the CLI, the TUI, +// and (through `pier api`) the Mac app. It owns every capability — sessions, +// repos and their images, ready sessions, settings, setup checks — and never +// prints, exits, or reads stdin. Frontends render what it returns; long +// operations report progress as Events through a callback. +// +// Interactive steps that need a terminal (attach, MCP logins, log follows, +// port forwards) come back as prepared *exec.Cmd values, so each frontend +// runs them its own way: the CLI in the foreground, the TUI via +// tea.ExecProcess, the Mac app in a terminal view. +package pier + +import ( + "fmt" + "io" + "os/exec" + "strings" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/driver/awsec2" + "github.com/usepier/pier/internal/driver/gcpgce" + "github.com/usepier/pier/internal/driver/payload" +) + +// Re-exported so frontends name pier's types without importing internals. +type ( + Session = driver.Session + State = driver.State + Machine = driver.Machine + Check = driver.Check + Quota = driver.Quota +) + +const ( + StateCreating = driver.StateCreating + StateRunning = driver.StateRunning + StateWorking = driver.StateWorking + StateIdle = driver.StateIdle + StateParked = driver.StateParked + StateDeleting = driver.StateDeleting + StateDead = driver.StateDead + StateFailed = driver.StateFailed +) + +// Options carries what only the binary knows. +type Options struct { + // SupervisorBin returns the embedded in-VM supervisor for an arch + // ("arm64"/"amd64"). The binary embeds it at build time; tests and + // read-only frontends may leave it nil. + SupervisorBin func(arch string) ([]byte, error) + // Version is the pier release. + Version string + // Out receives transfer meters and streamed remote output (the bake + // hook's log). A terminal frontend passes os.Stdout; nil discards, which + // is what a frontend speaking a protocol on stdout must choose. + Out io.Writer + // Notify receives one-off transport notices ("using the ssm tunnel"). + Notify func(string) +} + +// Client is a configured pier: the user's config plus the cloud driver it +// selects. Cheap to build; build a fresh one after settings change. +type Client struct { + cfg config.Config + drv driver.Driver + opts Options +} + +// Open loads the user's config and returns a client for its cloud. +func Open(opts Options) (*Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, err + } + return New(cfg, opts) +} + +// New returns a client for an explicit config (the setup wizard builds one +// before any config is saved). +func New(cfg config.Config, opts Options) (*Client, error) { + drv, err := NewDriver(cfg, opts) + if err != nil { + return nil, err + } + return &Client{cfg: cfg, drv: drv, opts: opts}, nil +} + +// NewDriver builds the cloud driver a config selects. +func NewDriver(cfg config.Config, opts Options) (driver.Driver, error) { + sup := opts.SupervisorBin + if sup == nil { + sup = func(string) ([]byte, error) { + return nil, fmt.Errorf("this build carries no session supervisor — build pier with `make`") + } + } + switch cfg.Driver { + case "", "aws-ec2": + return &awsec2.Driver{ + Profile: cfg.AWS.Profile, + Region: cfg.AWS.Region, + InstanceType: cfg.AWS.InstanceType, + DiskGiB: cfg.AWS.DiskGiB, + Subnet: cfg.AWS.Subnet, + Direct: cfg.AWS.Direct, + StateDir: config.Dir(), + Manifest: cfg.Secrets.Manifest, + SessionEnv: SessionEnv(cfg), + SupervisorBin: sup, + Out: opts.Out, + Notify: opts.Notify, + }, nil + case "gcp-gce": + // An empty project would fall through to the operator's active gcloud + // config — pier must never create resources in whatever project + // happens to be active. + if cfg.GCP.Project == "" { + return nil, fmt.Errorf("gcp.project is not set — run `pier setup`") + } + return &gcpgce.Driver{ + Project: cfg.GCP.Project, + Zone: cfg.GCP.Zone, + MachineType: cfg.GCP.MachineType, + DiskGiB: cfg.GCP.DiskGiB, + StateDir: config.Dir(), + Manifest: cfg.Secrets.Manifest, + SessionEnv: SessionEnv(cfg), + SupervisorBin: sup, + Out: opts.Out, + Notify: opts.Notify, + }, nil + default: + return nil, fmt.Errorf("unknown driver %q", cfg.Driver) + } +} + +// SessionEnv builds ~/.config/pier/env for new sessions: a GitHub credential +// from wherever the laptop already has one (gh login or git's credential +// helper), the Claude token from config (macOS keychain escape hatch). +func SessionEnv(cfg config.Config) map[string]string { + env := map[string]string{} + if t := cfg.Secrets.ClaudeOAuthToken; t != "" { + env["CLAUDE_CODE_OAUTH_TOKEN"] = t + } + if t := payload.GitHubToken(); t != "" { + env["GH_TOKEN"] = t + } + return env +} + +// Config is the configuration this client was built from. +func (c *Client) Config() config.Config { return c.cfg } + +// Driver exposes the cloud driver for the few features that manage their +// own processes against it (pier proxy). +func (c *Client) Driver() driver.Driver { return c.drv } + +// Cloud names the active cloud for display: "AWS eu-central-1". +func (c *Client) Cloud() string { + if c.cfg.Driver == "gcp-gce" { + return "GCP " + c.cfg.GCP.Zone + } + return "AWS " + c.cfg.AWS.Region +} + +// MachineType is the configured default machine for new sessions. +func (c *Client) MachineType() string { + if c.cfg.Driver == "gcp-gce" { + return c.cfg.GCP.MachineType + } + return c.cfg.AWS.InstanceType +} + +// DiskGiB is the configured per-session disk. +func (c *Client) DiskGiB() int { + if c.cfg.Driver == "gcp-gce" { + return c.cfg.GCP.DiskGiB + } + return c.cfg.AWS.DiskGiB +} + +// IsAuthExpired recognizes a cloud login expiry an interactive login fixes. +func IsAuthExpired(err error) bool { return awsec2.LoginExpired(err) } + +// ReauthCommand is the foreground login that fixes IsAuthExpired errors. +func (c *Client) ReauthCommand() *exec.Cmd { + args := []string{"login"} + if c.cfg.AWS.Profile != "" { + args = append(args, "--profile", c.cfg.AWS.Profile) + } + return exec.Command("aws", args...) +} + +// RepoRoot returns the git toplevel containing dir ("" = the process cwd). +func RepoRoot(dir string) (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", ErrNotInRepo + } + return strings.TrimSpace(string(out)), nil +} diff --git a/pkg/pier/create.go b/pkg/pier/create.go new file mode 100644 index 0000000..d561fc9 --- /dev/null +++ b/pkg/pier/create.go @@ -0,0 +1,167 @@ +package pier + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/pool" + "github.com/usepier/pier/internal/tombstone" +) + +// CreateRequest describes a new session. +type CreateRequest struct { + RepoRoot string // local checkout the session is made from + Branch string // new branch (and session name) + Base string // base ref; "" = HEAD + // Idle and Cap override the configured park timeouts for this session + // (nil = config; 0 = never). + Idle, Cap *time.Duration + // NoReady skips claiming one of the repo's ready sessions. + NoReady bool + Progress Progress +} + +// CreateResult is a finished create. +type CreateResult struct { + Session Session + Claimed bool // came from a ready session rather than a fresh launch + // RefillLog is set when a background refill of the repo's ready + // sessions started; RefillErr when one should have but couldn't. + RefillLog string + RefillErr error +} + +// Create makes a session: it claims one of the repo's ready sessions when +// there is one (resume, branch, secrets, setup re-run — seconds), and +// otherwise launches fresh from the repo's image. Either way the repo's +// ready sessions refill in the background. A create that fails leaves a +// failed row behind (its instance is already gone), so it never vanishes +// without trace. +func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, error) { + start := time.Now() + step := req.Progress.stepper(start) + base := req.Base + if base == "" { + base = "HEAD" + } + sessions, err := c.drv.List(ctx) + if err != nil { + return CreateResult{}, err + } + for _, s := range sessions { + if s.PoolGen == "" && s.Name == req.Branch { + return CreateResult{}, fmt.Errorf("session %q already exists — `pier attach %s`", req.Branch, req.Branch) + } + } + idle, err := c.duration(req.Idle, c.cfg.IdleTimeout) + if err != nil { + return CreateResult{}, fmt.Errorf("idle timeout: %w", err) + } + cap_, err := c.duration(req.Cap, c.cfg.UnattendedCap) + if err != nil { + return CreateResult{}, fmt.Errorf("runaway cap: %w", err) + } + repo := filepath.Base(req.RepoRoot) + image := c.cfg.BakedImage(repo) + // A repo with a bake hook has declared that stock isn't enough for it. + // Launching stock anyway is legal but almost never what was wanted: the + // create succeeds, then .pier/setup.sh dies minutes later on a missing + // toolchain. Say it now, while it's one sentence. + if image == "" && driver.BakeHook(req.RepoRoot) != "" { + req.Progress.emit(start, EventWarn, "no session image for "+repo+ + " — .pier/bake.sh toolchains will be missing and .pier/setup.sh may fail; `pier bake` fixes it") + } + + poolable := c.cfg.PoolSize(repo) > 0 && !req.NoReady + var res CreateResult + if poolable { + pp, err := c.poolParams(req.RepoRoot, req.Progress, start) + if err != nil { + return CreateResult{}, err + } + pp.IdleTimeout, pp.UnattendedCap = idle, cap_ + sess, err := pool.Claim(ctx, pp, sessions, req.Branch, req.Branch, base) + if err != nil { + // A ready session accelerates, never gates: whatever broke the + // claim, the fresh create below gives the same answer or a session. + req.Progress.emit(start, EventWarn, "claim failed: "+err.Error()) + } + if sess != nil { + res.Session, res.Claimed = *sess, true + } else { + step("no ready session to claim — creating fresh") + } + } + if !res.Claimed { + sess, err := c.drv.Create(ctx, driver.CreateSpec{ + Name: req.Branch, Repo: req.RepoRoot, Branch: req.Branch, BaseRef: base, Image: image, + IdleTimeout: idle, UnattendedCap: cap_, + Progress: step, + }) + if err != nil { + c.bury(req, err) + return CreateResult{}, err + } + res.Session = *sess + } + if poolable { + // Top the ready sessions back up — after a claim (one was consumed) + // and after a fallback create (there were too few) alike. + res.RefillLog, res.RefillErr = c.SpawnRefill(req.RepoRoot) + } + return res, nil +} + +func (c *Client) duration(override *time.Duration, configured string) (time.Duration, error) { + if override != nil { + return *override, nil + } + return config.ParkDuration(configured) +} + +// CreateLogPath is where a detached create's output lands. SpawnCreate +// writes it and a failed create points its record at it. It shares the +// record store's escaping, so feat/login and feat-login get their own logs. +func CreateLogPath(branch string) string { + return filepath.Join(config.Dir(), "logs", "create-"+tombstone.FileStem(branch)+".log") +} + +// SpawnCreate runs a create for branch in the background (a detached +// `pier --detach` working in repoRoot) and returns its log path. +// It survives the frontend closing; the session lists as creating until +// it's ready. +func (c *Client) SpawnCreate(repoRoot, branch string) (string, error) { + if repoRoot == "" { + return "", ErrNotInRepo + } + logPath := CreateLogPath(branch) + return logPath, spawnDetached(repoRoot, logPath, false, branch, "--detach") +} + +// bury records a failed create. Best-effort: the create already failed and +// its own error is what the user acts on — a record write that fails must +// not mask it. +func (c *Client) bury(req CreateRequest, cause error) { + drv := c.cfg.Driver + if drv == "" { + drv = "aws-ec2" + } + rec := tombstone.Record{ + Name: req.Branch, Repo: filepath.Base(req.RepoRoot), Branch: req.Branch, + Driver: drv, Reason: cause.Error(), When: time.Now(), + } + if p := CreateLogPath(req.Branch); fileExists(p) { + rec.LogPath = p + } + _ = tombstone.Write(config.Dir(), rec) +} + +func fileExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} diff --git a/pkg/pier/events.go b/pkg/pier/events.go new file mode 100644 index 0000000..7485ab6 --- /dev/null +++ b/pkg/pier/events.go @@ -0,0 +1,52 @@ +package pier + +import ( + "errors" + "time" +) + +// EventKind classifies a progress event so frontends style it without +// parsing its text. +type EventKind string + +const ( + EventStep EventKind = "step" // a phase of a long operation began + EventNote EventKind = "note" // advice worth showing, not a failure + EventWarn EventKind = "warn" // something degraded but the operation goes on +) + +// Event is one line of progress from a long operation (create, bake, fill). +// Elapsed is measured from the operation's start, so every frontend can show +// which step ate the wait. +type Event struct { + Kind EventKind `json:"kind"` + Message string `json:"message"` + Elapsed time.Duration `json:"elapsed"` +} + +// Progress receives events. A nil Progress discards them. +type Progress func(Event) + +// stepper adapts a Progress to the func(string) the drivers and pool report +// through, stamping elapsed time from start. +func (p Progress) stepper(start time.Time) func(string) { + return func(msg string) { p.emit(start, EventStep, msg) } +} + +func (p Progress) emit(start time.Time, kind EventKind, msg string) { + if p != nil { + p(Event{Kind: kind, Message: msg, Elapsed: time.Since(start)}) + } +} + +// Errors frontends branch on. Wrapped errors carry the detail; errors.Is +// recognizes the kind. +var ( + ErrNotInRepo = errors.New("not inside a git repository") + ErrNotFound = errors.New("no such session") + ErrAmbiguous = errors.New("session name is ambiguous") + ErrStillCreating = errors.New("session is still setting up") + ErrCreateFailed = errors.New("session never finished creating") + ErrDeleting = errors.New("session is being deleted") + ErrParked = errors.New("session is parked") +) diff --git a/pkg/pier/repos.go b/pkg/pier/repos.go new file mode 100644 index 0000000..e509a38 --- /dev/null +++ b/pkg/pier/repos.go @@ -0,0 +1,488 @@ +package pier + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/pool" +) + +// Repo is one repo's row on the Repos page: its session image, its ready +// sessions, what they cost while waiting, and any rebake reminders. +type Repo struct { + Name string + Current bool // the repo this process runs from (editable, reminders computed) + Image string // session image id; "" = none yet + // Baked is what the image was baked from; nil for images baked before + // pier recorded it (or no image). + Baked *config.ImageInfo + // ReadyTarget is how many ready sessions pier keeps; Ready are parked and + // claimable, Filling are being set up, Stale will recycle. + ReadyTarget, Ready, Filling, Stale int + ReadyAges []string + Sessions int // live sessions of this repo + MonthlyUSD float64 // idle cost estimate: ready disks + image storage + Reminders []Reminder +} + +// Reminder is one piece of rebake guidance. pier never acts on it. +type Reminder struct { + Message string `json:"message"` + Action string `json:"action"` // the command that addresses it +} + +// Repos returns every repo pier knows about — baked, with ready sessions or +// ready-session settings, with live sessions, or the one this process runs +// from — sorted by name. sessions is a Sessions() result, so the page and +// the census never disagree. +func (c *Client) Repos(sessions []Session, curRoot string) []Repo { + names := map[string]bool{} + for _, r := range c.cfg.PoolRepos() { + names[r] = true + } + for r := range c.cfg.Pool.Sizes { + names[r] = true + } + for r := range c.cfg.Images { + names[r] = true + } + for _, s := range sessions { + if s.Repo != "" { + names[s.Repo] = true + } + } + cur := "" + if curRoot != "" { + cur = filepath.Base(curRoot) + names[cur] = true + } + curGen := "" + if cur != "" { + curGen = c.poolGen(curRoot) + } + maxAge, _ := c.cfg.PoolMaxAge() // unparsable → 0, which skips the age check + now := time.Now() + var out []Repo + for name := range names { + st := FoldReady(sessions, name, cur, curGen, maxAge, now) + r := Repo{ + Name: name, Current: name == cur, + Image: c.cfg.BakedImage(name), + ReadyTarget: c.cfg.PoolSize(name), + Ready: st.Ready, Filling: st.Filling, Stale: st.Stale, ReadyAges: st.Ages, + } + if info, ok := c.cfg.Images[name]; ok && r.Image != "" { + r.Baked = &info + } + for _, s := range sessions { + if s.Repo == name && s.PoolGen == "" && s.State != driver.StateFailed { + r.Sessions++ + } + } + r.MonthlyUSD = float64(st.Ready+st.Filling)*c.DiskMonthlyUSD() + imageMonthlyUSD(r.Image) + root := "" + if r.Current { + root = curRoot + } + r.Reminders = c.reminders(name, root) + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// DiskMonthlyUSD estimates what one parked session's disk costs a month +// (gp3 / pd-balanced list prices, rounded up to cover the priciest regions). +func (c *Client) DiskMonthlyUSD() float64 { + perGiB := 0.095 + if c.cfg.Driver == "gcp-gce" { + perGiB = 0.10 + } + return float64(c.DiskGiB()) * perGiB +} + +// imageMonthlyUSD is a flat estimate for one image's snapshot storage: the +// real figure depends on how much of the disk the bake filled. +func imageMonthlyUSD(image string) float64 { + if image == "" { + return 0 + } + return 2 +} + +// ReadyStats is one repo's ready-session census. +type ReadyStats struct { + Ready, Filling, Stale int + Ages []string // ready sessions' ages, as listed +} + +// FoldReady folds one repo's unclaimed ready sessions out of a session list, +// judging them the way the pool's reconcile would: dead, wrong-generation, +// over the recycle age, or unparked past the fill grace all count stale — a +// member whose fill died hours ago must not read "filling" forever. curGen +// gates the generation check: only the repo the process runs from can +// compute its current generation. maxAge 0 skips the age check. +func FoldReady(sessions []Session, repo, curRepo, curGen string, maxAge time.Duration, now time.Time) ReadyStats { + var st ReadyStats + for _, s := range sessions { + if s.PoolGen == "" || s.Repo != repo { + continue + } + lived := now.Sub(s.Created) + switch { + case s.State == driver.StateDeleting: + case s.State == driver.StateDead, + repo == curRepo && s.PoolGen != curGen, + maxAge > 0 && lived >= maxAge: + st.Stale++ + case s.State == driver.StateParked: + st.Ready++ + st.Ages = append(st.Ages, Age(s.Created, now)) + case lived >= pool.FillGrace: + st.Stale++ + default: + st.Filling++ + } + } + return st +} + +// Age renders a compact age: now, 5m, 3h, 2d. +func Age(t, now time.Time) string { + if t.IsZero() { + return "-" + } + d := now.Sub(t) + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// Reminders is the rebake guidance for the repo at repoRoot. Facts only — +// no image yet, an old image, .pier scripts edited since the bake — and +// nothing at all when bake reminders are off. +func (c *Client) Reminders(repoRoot string) []Reminder { + return c.reminders(filepath.Base(repoRoot), repoRoot) +} + +func (c *Client) reminders(repo, root string) []Reminder { + if !c.cfg.Speed.BakeReminders { + return nil + } + const bake = "pier bake" + image := c.cfg.BakedImage(repo) + if image == "" { + // Only the repo we stand in: a stranger's repo row without an image + // is not something this machine can bake. + if root == "" { + return nil + } + return []Reminder{{ + Message: "No session image for " + repo + " yet. A bake gets new sessions going in a minute or two instead of running the full setup.", + Action: bake, + }} + } + info, ok := c.cfg.Images[repo] + if !ok { + return nil // baked before pier recorded bake details: nothing honest to say + } + var out []Reminder + if root != "" { + if info.BakeSHA != fileSHA(filepath.Join(root, ".pier", "bake.sh")) { + out = append(out, Reminder{ + Message: ".pier/bake.sh changed since " + repo + "'s last bake. New sessions won't have its toolchains until you rebake.", + Action: bake, + }) + } + if info.SetupSHA != fileSHA(filepath.Join(root, ".pier", "setup.sh")) { + out = append(out, Reminder{ + Message: ".pier/setup.sh changed since " + repo + "'s last bake. Rebake so new sessions don't redo that work.", + Action: bake, + }) + } + } + if maxAge := c.cfg.ReminderAge(); maxAge > 0 && time.Since(info.BakedAt) >= maxAge { + out = append(out, Reminder{ + Message: fmt.Sprintf("%s's session image is %d days old. Rebake so new sessions start with current dependencies.", + repo, int(time.Since(info.BakedAt).Hours()/24)), + Action: bake, + }) + } + return out +} + +// fileSHA is the hex sha256 of a file, "" when it doesn't exist. +func fileSHA(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// BakeRequest describes one `pier bake`. +type BakeRequest struct { + RepoRoot string + // IncludeRepo overrides the speed.image_repo setting for this bake: + // true prebuilds the checkout into the image, false bakes toolchains only. + IncludeRepo *bool + Progress Progress +} + +// BakeResult is a finished bake. +type BakeResult struct { + Image string + RepoIncluded bool + // ReadyStale is set when the repo's ready sessions were built on the + // previous image; they recycle on the next claim or refill. + ReadyStale bool +} + +// Bake builds (or rebuilds) the repo's session image and records it. +func (c *Client) Bake(ctx context.Context, req BakeRequest) (BakeResult, error) { + start := time.Now() + name := filepath.Base(req.RepoRoot) + include := c.cfg.Speed.ImageRepo + if req.IncludeRepo != nil { + include = *req.IncludeRepo + } + spec := driver.BakeSpec{ + RepoName: name, + HookPath: driver.BakeHook(req.RepoRoot), + // This bake supersedes the repo's previous image (and on aws-ec2, + // once per config, the legacy shared one). + Replaces: c.cfg.BakedReplaces(name), + Progress: req.Progress.stepper(start), + } + if include { + spec.RepoRoot = req.RepoRoot + } + img, err := c.drv.Bake(ctx, spec) + if err != nil { + return BakeResult{}, err + } + info := config.ImageInfo{ + BakedAt: time.Now().UTC(), + SetupSHA: fileSHA(filepath.Join(req.RepoRoot, ".pier", "setup.sh")), + BakeSHA: fileSHA(filepath.Join(req.RepoRoot, ".pier", "bake.sh")), + RepoIncluded: include, + } + // Update, not Save: a bake takes minutes, and c.cfg was read before it + // started. Saving it wholesale would revert anything written meanwhile — + // including another repo's bake. + next, err := config.Update(func(cfg *config.Config) error { + cfg.RecordBake(name, img) + cfg.RecordImageInfo(name, info) + return nil + }) + if err != nil { + return BakeResult{}, err + } + c.cfg = next + return BakeResult{Image: img, RepoIncluded: include, ReadyStale: next.PoolSize(name) > 0}, nil +} + +// poolParams assembles everything internal/pool needs, including the +// generation fingerprint — which is why it must run from inside the repo +// (the local setup script feeds the hash). +func (c *Client) poolParams(repoRoot string, progress Progress, start time.Time) (pool.Params, error) { + maxAge, err := c.cfg.PoolMaxAge() + if err != nil { + return pool.Params{}, err + } + idle, err := config.ParkDuration(c.cfg.IdleTimeout) + if err != nil { + return pool.Params{}, err + } + cap_, err := config.ParkDuration(c.cfg.UnattendedCap) + if err != nil { + return pool.Params{}, err + } + repo := filepath.Base(repoRoot) + image := c.cfg.BakedImage(repo) + return pool.Params{ + Driver: c.drv, + RepoRoot: repoRoot, + Gen: pool.Generation(c.drv.Name(), image, c.MachineType(), c.DiskGiB(), pool.SetupScript(repoRoot)), + Size: c.cfg.PoolSize(repo), + MaxAge: maxAge, + Image: image, + IdleTimeout: idle, UnattendedCap: cap_, + Manifest: c.cfg.Secrets.Manifest, SessionEnv: SessionEnv(c.cfg), + Progress: progress.stepper(start), + Out: c.opts.Out, + }, nil +} + +func (c *Client) poolGen(repoRoot string) string { + pp, err := c.poolParams(repoRoot, nil, time.Now()) + if err != nil { + return "" + } + return pp.Gen +} + +// SetReady saves how many ready sessions to keep for the repo at repoRoot, +// then reconciles: 0 destroys its ready sessions now, more starts a +// background refill. Returns the refill log path ("" for 0). +func (c *Client) SetReady(ctx context.Context, repoRoot string, n int, progress Progress) (string, error) { + repo := filepath.Base(repoRoot) + next, err := config.Update(func(cfg *config.Config) error { + cfg.SetPoolSize(repo, n) + return nil + }) + if err != nil { + return "", err + } + c.cfg = next + if n == 0 { + _, err := c.DrainReady(ctx, repo, progress) + return "", err + } + return c.SpawnRefill(repoRoot) +} + +// DrainReady destroys a repo's unclaimed ready sessions (any repo; no local +// checkout needed) and returns how many went down. +func (c *Client) DrainReady(ctx context.Context, repo string, progress Progress) (int, error) { + sessions, err := c.drv.List(ctx) + if err != nil { + return 0, err + } + return pool.Drain(ctx, c.drv, sessions, repo, progress.stepper(time.Now())) +} + +// Refill tops the repo's ready sessions up to target, in the foreground. +func (c *Client) Refill(ctx context.Context, repoRoot string, progress Progress) error { + pp, err := c.poolParams(repoRoot, progress, time.Now()) + if err != nil { + return err + } + return pool.Fill(ctx, pp) +} + +// RefillLogPath is where a background refill of repo writes. +func RefillLogPath(repo string) string { + return filepath.Join(config.Dir(), "logs", "ready-"+repo+".log") +} + +// SpawnRefill re-execs the binary's hidden refill command as a detached +// child working in repoRoot, so a refill survives the frontend exiting. +func (c *Client) SpawnRefill(repoRoot string) (string, error) { + logPath := RefillLogPath(filepath.Base(repoRoot)) + // Append, never truncate: a claim can spawn a refill while the previous + // one is still writing here, and clobbering a live writer's log turns + // both runs into confetti. Refills are rare enough that growth is noise. + return logPath, spawnDetached(repoRoot, logPath, true, RefillCommand) +} + +// RefillCommand is the hidden subcommand SpawnRefill re-execs. +const RefillCommand = "__refill" + +// BakeLogPath is where a background bake of repo writes. +func BakeLogPath(repo string) string { + return filepath.Join(config.Dir(), "logs", "bake-"+repo+".log") +} + +// SpawnBake runs `pier bake` for the repo at repoRoot in the background and +// returns its log path. BakeRunning reports on it while it runs. +func (c *Client) SpawnBake(repoRoot string) (string, error) { + repo := filepath.Base(repoRoot) + if BakeRunning(repo) { + return BakeLogPath(repo), fmt.Errorf("a bake of %s is already running", repo) + } + logPath := BakeLogPath(repo) + pid, err := spawnDetachedPID(repoRoot, logPath, false, "bake") + if err != nil { + return "", err + } + _ = os.WriteFile(bakePIDPath(repo), []byte(strconv.Itoa(pid)), 0o600) + return logPath, nil +} + +// BakeRunning reports whether a background bake of repo started from this +// machine is still running (its process is alive). +func BakeRunning(repo string) bool { + b, err := os.ReadFile(bakePIDPath(repo)) + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil || pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +func bakePIDPath(repo string) string { + return filepath.Join(config.Dir(), "logs", "bake-"+repo+".pid") +} + +// spawnDetached starts the pier binary with args in its own session, +// output to logPath, working in dir. +func spawnDetached(dir, logPath string, appendLog bool, args ...string) error { + _, err := spawnDetachedPID(dir, logPath, appendLog, args...) + return err +} + +func spawnDetachedPID(dir, logPath string, appendLog bool, args ...string) (int, error) { + exe, err := os.Executable() + if err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil { + return 0, err + } + flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC + if appendLog { + flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND + } + f, err := os.OpenFile(logPath, flags, 0o600) + if err != nil { + return 0, err + } + defer f.Close() + cmd := exec.Command(exe, args...) + cmd.Dir = dir + cmd.Stdout, cmd.Stderr = f, f + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return 0, err + } + go cmd.Wait() // reap if this process outlives the child + return cmd.Process.Pid, nil +} + +var hourlyRe = regexp.MustCompile(`\$([0-9]+(?:\.[0-9]+)?)/h`) + +// HourlyUSD reads the hourly rate out of a session's cost note ("~$0.04/h"); +// 0 for parked or unknown. +func HourlyUSD(s Session) float64 { + m := hourlyRe.FindStringSubmatch(s.CostNote) + if m == nil { + return 0 + } + f, _ := strconv.ParseFloat(m[1], 64) + return f +} diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go new file mode 100644 index 0000000..a62fbd9 --- /dev/null +++ b/pkg/pier/sessions.go @@ -0,0 +1,398 @@ +package pier + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/driver/payload" + "github.com/usepier/pier/internal/tombstone" +) + +// Sessions is the one list every surface shows: the cloud's sessions with +// their supervisor beacons read (working/idle, strain, setup state), plus a +// failed row per create that died before becoming a session. Unclaimed +// ready sessions are included — SplitReady separates them. +func (c *Client) Sessions(ctx context.Context) ([]Session, error) { + sessions, err := c.drv.List(ctx) + if err != nil { + return nil, err + } + c.enrich(ctx, sessions) + merged, revived := mergeTombstones(sessions, tombstone.List(config.Dir())) + for _, name := range revived { + tombstone.Dismiss(config.Dir(), name) + } + return merged, nil +} + +// SplitReady separates real sessions from unclaimed ready sessions (warm +// pool members): those are inventory, not work, and never sit in the +// session table. +func SplitReady(all []Session) (sessions, ready []Session) { + for _, s := range all { + if s.PoolGen != "" { + ready = append(ready, s) + } else { + sessions = append(sessions, s) + } + } + return sessions, ready +} + +// enrich upgrades StateRunning to working/idle by reading each running +// session's supervisor beacon in parallel. +func (c *Client) enrich(ctx context.Context, sessions []Session) { + var wg sync.WaitGroup + for i := range sessions { + // Ready sessions run headless between create and park; their beacon + // detail is nobody's business until they're claimed. + if sessions[i].State != driver.StateRunning || sessions[i].PoolGen != "" { + continue + } + wg.Add(1) + go func(s *Session) { + defer wg.Done() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + out, err := c.drv.Exec(ctx, s.ID, "cat /run/pier/status.json 2>/dev/null || echo absent") + if err != nil { + return // unreachable (ssm blip) — keep plain "running" + } + if strings.TrimSpace(out) == "absent" { + // Only ready-tagged sessions get here, so no beacon isn't + // mid-create anymore: /run is tmpfs, so right after a resume + // the supervisor hasn't written its first beacon yet. Keep + // plain "running". + return + } + var st struct { + State string `json:"state"` + Bootstrapping bool `json:"bootstrapping"` + Strained bool `json:"strained"` + Setup string `json:"setup"` + } + if json.Unmarshal([]byte(out), &st) != nil { + return + } + // Supervisor up but bootstrap not done: the repo is still on its + // way (or the create died) — either way, not attachable yet. + if st.Bootstrapping { + s.State = driver.StateCreating + return + } + switch st.State { + case "working": + s.State = driver.StateWorking + case "idle": + s.State = driver.StateIdle + } + s.Strained = st.Strained + s.Setup = st.Setup + }(&sessions[i]) + } + wg.Wait() +} + +// mergeTombstones appends a failed row per tombstone and reports the ones the +// cloud has since answered for. A name that is live again means the create +// was retried and worked, so its gravestone is stale — the user shouldn't +// have to clear a row that the obvious next action already resolved. +func mergeTombstones(sessions []Session, recs []tombstone.Record) (merged []Session, revived []string) { + live := make(map[string]bool, len(sessions)) + for _, s := range sessions { + live[s.Name] = true + } + for _, r := range recs { + if live[r.Name] { + revived = append(revived, r.Name) + continue + } + sessions = append(sessions, Session{ + Name: r.Name, Repo: r.Repo, Branch: r.Branch, Driver: r.Driver, + State: driver.StateFailed, Created: r.When, + FailReason: r.Reason, LogPath: r.LogPath, + CostNote: "—", // nothing is running; nothing is being charged + }) + } + return sessions, revived +} + +// Match resolves a user-typed session name: an exact name wins, otherwise a +// unique substring. Ready sessions are never matched by name. +func (c *Client) Match(ctx context.Context, query string) (Session, error) { + sessions, err := c.Sessions(ctx) + if err != nil { + return Session{}, err + } + return match(sessions, query) +} + +func match(sessions []Session, query string) (Session, error) { + var hits []Session + for _, s := range sessions { + if s.PoolGen != "" { + continue + } + if s.Name == query { + return s, nil + } + if strings.Contains(s.Name, query) { + hits = append(hits, s) + } + } + switch len(hits) { + case 1: + return hits[0], nil + case 0: + return Session{}, fmt.Errorf("%w matching %q", ErrNotFound, query) + default: + var names []string + for _, h := range hits { + names = append(names, h.Name) + } + return Session{}, fmt.Errorf("%w: %q matches %s", ErrAmbiguous, query, strings.Join(names, ", ")) + } +} + +// CheckReady refuses a still-creating, failed, or deleting session cleanly, +// before any ssh is spawned, so the user never sees a raw transport error +// from the window between cloud-running and actually-attachable. +func CheckReady(s Session) error { + switch s.State { + case driver.StateFailed: + hint := "`pier " + s.Name + "` retries it" + if s.LogPath != "" { + hint += ", " + s.LogPath + " has the create log" + } + return fmt.Errorf("%w: %s (%s) — nothing is running; %s", + ErrCreateFailed, s.Name, tombstone.Summarize(s.FailReason), hint) + case driver.StateCreating: + return fmt.Errorf("%w: %s — try again when it shows running", ErrStillCreating, s.Name) + case driver.StateDeleting: + return fmt.Errorf("%w: %s", ErrDeleting, s.Name) + } + return nil +} + +// Resume unparks a session and waits until its transport answers. A no-op +// for sessions that aren't parked. +func (c *Client) Resume(ctx context.Context, s Session) error { + if s.State != driver.StateParked { + return nil + } + return c.drv.Resume(ctx, s.ID) +} + +// Remove destroys a session and its disk; for a failed create it forgets +// the record (there is no instance). +func (c *Client) Remove(ctx context.Context, s Session) error { + if s.State == driver.StateFailed { + return tombstone.Dismiss(config.Dir(), s.Name) + } + return c.drv.Destroy(ctx, s.ID) +} + +// Keep disables idle self-park; the supervisor re-reads its conf every +// tick, so this applies live. +func (c *Client) Keep(ctx context.Context, s Session) error { + if s.State == driver.StateParked { + return fmt.Errorf("%w: %s — attach first", ErrParked, s.Name) + } + _, err := c.drv.Exec(ctx, s.ID, + "sudo sed -i 's/^idle_timeout=.*/idle_timeout=never/' /etc/pier/supervisor.conf") + return err +} + +// Resize changes a session's machine type (park → modify → resume when +// running; a parked session stays parked). +func (c *Client) Resize(ctx context.Context, s Session, machineType string) error { + if err := CheckReady(s); err != nil { + return err // resizing mid-create would stop the instance under its bootstrap + } + return c.drv.Resize(ctx, s.ID, machineType) +} + +// Machines is the resize catalog for a session: same-arch types with shape +// and rough cost. +func (c *Client) Machines(s Session) []Machine { return c.drv.Machines(s.InstanceType) } + +// Headroom reports the account's vCPU quota use. +func (c *Client) Headroom(ctx context.Context) (Quota, error) { return c.drv.Headroom(ctx) } + +// AttachCommand prepares one attach attempt (ssh + tmux) for the caller to +// run in the foreground. +func (c *Client) AttachCommand(ctx context.Context, s Session) (*exec.Cmd, error) { + return c.drv.AttachCommand(ctx, s.ID) +} + +// RetryAttach reports whether a finished attach attempt deserves one +// bounded wait-for-reachability and retry: a fresh or just-resumed VM +// reports cloud-running before its transport answers, so ssh exits 255 +// within seconds. Other quick failures came from the remote command, and +// retrying would only hide them. +func RetryAttach(err error, elapsed time.Duration) bool { + var exitErr *exec.ExitError + return elapsed <= 15*time.Second && errors.As(err, &exitErr) && exitErr.ExitCode() == 255 +} + +// WaitReachable polls a no-op exec until the session's ssh transport +// answers. +func (c *Client) WaitReachable(ctx context.Context, s Session, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + _, err := c.drv.Exec(cctx, s.ID, "true") + cancel() + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("session not reachable after %s — `pier ls` to check on it", timeout) +} + +const noSetupLog = `echo "no setup log — this session ran no setup script"` + +// SetupLog returns the tail of a session's setup log (at most lines lines; +// 0 = all). A failed create has no VM, so its local create log is served +// instead: "what broke" is the same question either way. +func (c *Client) SetupLog(ctx context.Context, s Session, lines int) (string, error) { + if s.State == driver.StateFailed { + if s.LogPath != "" { + if b, err := os.ReadFile(s.LogPath); err == nil { + return string(b), nil + } + } + return s.FailReason, nil + } + if err := CheckReady(s); err != nil { + return "", err + } + read := "cat ~/.pier-setup.log" + if lines > 0 { + read = fmt.Sprintf("tail -n %d ~/.pier-setup.log", lines) + } + out, err := c.drv.Exec(ctx, s.ID, `[ -f ~/.pier-setup.log ] && `+read+` || `+noSetupLog) + if err == nil && lines > 0 && strings.Count(out, "\n") >= lines-1 { + out = "… older lines trimmed — pier logs " + s.Name + " prints everything\n" + out + } + return out, err +} + +// FollowLogCommand prepares a raw `tail -f` of the setup log — pipeable, +// with the terminal interpreting progress-meter escapes natively. +func (c *Client) FollowLogCommand(ctx context.Context, s Session) (*exec.Cmd, error) { + opts, dest, err := c.drv.SSHTarget(ctx, s.ID) + if err != nil { + return nil, err + } + remote := `[ -f ~/.pier-setup.log ] && exec tail -n +1 -f ~/.pier-setup.log || ` + noSetupLog + return exec.Command("ssh", append(opts, dest, remote)...), nil +} + +var mcpServerName = regexp.MustCompile(`^[A-Za-z0-9._:@-]+$`) + +// PendingMCP asks the session which OAuth-backed MCP servers still lack a +// token (seeded ~/.claude.json minus ~/.claude/.credentials.json). +func (c *Client) PendingMCP(ctx context.Context, s Session) ([]string, error) { + out, err := c.drv.Exec(ctx, s.ID, + "cat ~/.claude.json 2>/dev/null; printf '\\n---PIER-SPLIT---\\n'; cat ~/.claude/.credentials.json 2>/dev/null") + if err != nil { + return nil, err + } + cfgRaw, credRaw, _ := strings.Cut(out, "---PIER-SPLIT---") + var pending []string + for _, n := range payload.OAuthRemoteNames([]byte(cfgRaw), payload.Workspace+"/"+s.Repo) { + if !mcpAuthed(credRaw, n) { + pending = append(pending, n) + } + } + return pending, nil +} + +// OAuthMCPServers names the laptop's OAuth-backed MCP servers that a new +// session of repoRoot would need one browser approval for. +func OAuthMCPServers(repoRoot string) []string { + home, _ := os.UserHomeDir() + return payload.OAuthRemotes(home, repoRoot) +} + +// MCPLoginCommand prepares the browser OAuth flow for one MCP server, with +// the callback port tunneled into the session. +func (c *Client) MCPLoginCommand(ctx context.Context, s Session, server string) (*exec.Cmd, error) { + if !mcpServerName.MatchString(server) { + return nil, fmt.Errorf("implausible server name %q", server) + } + port, err := freePort() + if err != nil { + return nil, err + } + return c.drv.MCPLoginCommand(ctx, s.ID, server, port) +} + +// mcpAuthed checks the session's credential store for a token under this +// server. Claude keys mcpOAuth by server name (possibly suffixed); an +// unrecognized format fails open — worst case one redundant approval. +func mcpAuthed(credJSON, server string) bool { + var c struct { + McpOAuth map[string]json.RawMessage `json:"mcpOAuth"` + } + if json.Unmarshal([]byte(credJSON), &c) != nil { + return false + } + for k := range c.McpOAuth { + if k == server || strings.HasPrefix(k, server+"|") || strings.HasPrefix(k, server+":") { + return true + } + } + return false +} + +// PortForwardCommand prepares ssh -L forwards held open until interrupted. +// pairs are {local, remote}. +func (c *Client) PortForwardCommand(ctx context.Context, s Session, pairs [][2]int) (*exec.Cmd, error) { + return c.drv.PortForwardCommand(ctx, s.ID, pairs) +} + +// ParsePortPair reads "3000" or "local:session" like "8080:3000". +func ParsePortPair(s string) ([2]int, error) { + local, remote, split := strings.Cut(s, ":") + if !split { + remote = local + } + var p [2]int + for i, v := range []string{local, remote} { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 65535 { + return p, fmt.Errorf("bad port %q — want 3000 or local:session like 8080:3000", s) + } + p[i] = n + } + return p, nil +} + +// freePort grabs an OS-assigned port. Local and remote must match: the OAuth +// redirect URL embeds the one port claude registers inside the VM. +func freePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} diff --git a/pkg/pier/sessions_test.go b/pkg/pier/sessions_test.go new file mode 100644 index 0000000..d818fa3 --- /dev/null +++ b/pkg/pier/sessions_test.go @@ -0,0 +1,95 @@ +package pier + +import ( + "os/exec" + "testing" + "time" + + "github.com/usepier/pier/internal/driver" + "github.com/usepier/pier/internal/tombstone" +) + +// A failed create used to vanish without trace: the instance rolled itself +// back and nothing was left to say a session had been asked for at all. The +// tombstone rides the list so the failure is visible — but only until the +// cloud can answer for that name itself. +func TestMergeTombstones(t *testing.T) { + live := []driver.Session{{Name: "kur-3814", State: driver.StateIdle}} + recs := []tombstone.Record{ + {Name: "kur-3817", Repo: "flb", Reason: "scp: Connection closed", LogPath: "/tmp/c.log"}, + {Name: "kur-3814", Reason: "an earlier attempt that has since worked"}, + } + + merged, revived := mergeTombstones(live, recs) + if len(merged) != 2 { + t.Fatalf("want the live session plus one tombstone, got %+v", merged) + } + if merged[0].Name != "kur-3814" || merged[0].State != driver.StateIdle { + t.Errorf("a live session must survive the merge untouched, got %+v", merged[0]) + } + got := merged[1] + if got.Name != "kur-3817" || got.State != driver.StateFailed { + t.Errorf("want kur-3817 as a failed row, got %+v", got) + } + if got.FailReason == "" || got.LogPath != "/tmp/c.log" { + t.Errorf("a tombstone must carry why it died and where to read more, got %+v", got) + } + if got.CostNote != "—" { + t.Errorf("nothing is running, so nothing is billing; got CostNote %q", got.CostNote) + } + // The obvious next action — re-create it — must clear the row by itself. + if len(revived) != 1 || revived[0] != "kur-3814" { + t.Errorf("a tombstone whose name came back live must be reported stale, got %v", revived) + } +} + +func TestRetryAttachOnlyForQuickSSHTransportFailure(t *testing.T) { + exit := func(code string) error { + return exec.Command("sh", "-c", "exit "+code).Run() + } + if !RetryAttach(exit("255"), time.Second) { + t.Error("quick ssh transport failure should retry") + } + if RetryAttach(exit("1"), time.Second) { + t.Error("remote command failure should not retry") + } + if RetryAttach(exit("255"), 16*time.Second) { + t.Error("slow transport failure should not retry") + } +} + +// FoldReady classifies members the way reconcile would; the generation check +// applies only to the repo whose local checkout produced curGen — other +// repos' gens are unknowable here. +func TestFoldReady(t *testing.T) { + now := time.Now() + ss := []Session{ + {Repo: "shop", PoolGen: "aaa", State: StateParked, Created: now.Add(-time.Hour)}, + {Repo: "shop", PoolGen: "bbb", State: StateParked, Created: now}, // wrong gen + {Repo: "shop", PoolGen: "aaa", State: StateCreating, Created: now}, + {Repo: "shop", PoolGen: "aaa", State: StateDead, Created: now}, + {Repo: "shop", PoolGen: "aaa", State: StateDeleting, Created: now}, + {Repo: "shop", State: StateRunning, Created: now}, // a real session + {Repo: "tools", PoolGen: "zzz", State: StateParked, Created: now}, + } + if st := FoldReady(ss, "shop", "shop", "aaa", 0, now); st.Ready != 1 || st.Filling != 1 || st.Stale != 2 { + t.Errorf("shop = %+v, want 1 ready, 1 filling, 2 stale", st) + } + if st := FoldReady(ss, "tools", "shop", "aaa", 0, now); st.Ready != 1 || st.Stale != 0 { + t.Errorf("tools = %+v, want its member ready (gen unknowable from here)", st) + } + + // Age judgments: a parked member past max_age is stale even for repos + // whose gen is unknowable, and an unparked member past the fill grace is + // a corpse, not "filling" — its fill died hours ago. + old := []Session{ + {Repo: "tools", PoolGen: "zzz", State: StateParked, Created: now.Add(-30 * 24 * time.Hour)}, + {Repo: "tools", PoolGen: "zzz", State: StateCreating, Created: now.Add(-3 * time.Hour)}, + } + if st := FoldReady(old, "tools", "", "", 14*24*time.Hour, now); st.Ready != 0 || st.Filling != 0 || st.Stale != 2 { + t.Errorf("old members = %+v, want both stale (over-age + grace-blown)", st) + } + if st := FoldReady(old[:1], "tools", "", "", 0, now); st.Ready != 1 { + t.Errorf("maxAge 0 = %+v, want the age check skipped", st) + } +} From 2b01f0c198681adcc26ef9074ad30ca0b26f874d Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 14:01:30 +0200 Subject: [PATCH 03/24] feat(supervisor): restore the tmux layout when a parked session wakes Parking is a shutdown, so a wake used to land in a fresh tmux server with an empty shell. The supervisor now snapshots the agent user's tmux every ~30s and right before it parks (~/.pier/tmux: state.json plus per-pane scrollback), and a new pier-restore.service runs `pier-supervisor restore` once per boot to rebuild it: sessions, windows, layouts, cwds, replayed scrollback, claude relaunched with --resume or --continue, codex with `codex resume --last`, other commands typed at the prompt but not run, and setup windows as a plain shell. Attach waits briefly for its marker. Pool claims and the prebuild scrub drop the saved layout. Deviations from the design: - Agents start from a one-shot PROMPT_COMMAND in the restored shell, not by exec'ing the agent from the pane command. Startup files run before bash enables job control, so a program started there shares the shell's process group: tmux reports "bash" as the pane command and Ctrl-Z can't reach the agent. Quitting the agent also leaves a shell, not a closed pane. - The restored shell reads a generated rcfile instead of being a login shell (it sources /etc/profile and ~/.profile itself). pier's ~/.bashrc cd's into the repo, which would override the pane's saved cwd. - Typed-back commands wait until the pane's tty leaves canonical mode (readline is reading). Keys sent earlier are echoed by the tty and then again by readline. - The list-panes separator is printable ("|pier|"): tmux 3.2 (Ubuntu 22.04) rewrites tabs in format output as '_'. - Freshen waits (bounded) for the restore marker before killing the server, so a restore still in flight can't start a server after the kill. - Both units set RuntimeDirectoryPreserve=yes so a supervisor restart does not delete the /run/pier/restored marker they share. - A few claude flags (--dangerously-skip-permissions, --model, --permission-mode) carry over to the relaunch, so an unattended agent isn't left waiting on permission prompts. A positional prompt never carries over. --- README.md | 20 +- cmd/pier-supervisor/main.go | 17 + cmd/pier-supervisor/tmux.go | 766 +++++++++++++++++++++++ cmd/pier-supervisor/tmux_test.go | 342 ++++++++++ docs/SPEC.md | 35 ++ internal/driver/awsec2/direct_test.go | 9 + internal/driver/awsec2/driver.go | 9 + internal/driver/gcpgce/driver.go | 11 +- internal/driver/gcpgce/driver_test.go | 9 + internal/driver/payload/freshen.go | 16 +- internal/driver/payload/payload_test.go | 24 + internal/driver/payload/prebuild.go | 6 +- internal/driver/payload/prebuild_test.go | 4 +- internal/driver/payload/scripts.go | 35 +- 14 files changed, 1292 insertions(+), 11 deletions(-) create mode 100644 cmd/pier-supervisor/tmux.go create mode 100644 cmd/pier-supervisor/tmux_test.go diff --git a/README.md b/README.md index e5a3791..4367983 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ inside it, with: Detach and forget it. An in-VM supervisor parks the VM once the agent goes quiet: the instance stops, the disk persists. Attach again and it resumes in about 20 seconds on AWS and about a minute on GCP, with files, branches, and -credentials exactly as you left them. If the session outgrows its hardware, +credentials exactly as you left them, and your tmux windows back with the +agents reopened on their conversations. If the session outgrows its hardware, `pier resize` swaps the machine type in one park and resume cycle, disk intact. And to see what the agent built, `pier proxy` turns every running session into a hostname: @@ -485,8 +486,11 @@ The cloud says "running" long before a session is usable, so pier doesn't: minutes, once per create). The create output says which transfer mode you got and why. - **Parking loses processes.** Files, git state, and installed tools - survive. The tmux server and an in-flight agent run don't. Hibernate - (park with RAM) is on the roadmap. + survive, and on wake your tmux layout comes back: windows, panes, cwds, + scrollback, and claude/codex relaunched on their conversations. What was + running doesn't survive. An agent mid-turn stops at its last saved message, + and a dev server comes back as its command typed at the prompt, one Enter + away. Hibernate (park with RAM) is on the roadmap. - **OAuth MCPs need a browser approval per session.** Keychain-held tokens can't be copied safely. This floor is real. - **ssh-key-only GitHub auth pushes while attached.** The forwarded agent @@ -539,6 +543,16 @@ to stop rather than terminate. The supervisor holds no credentials and calls no APIs. It beacons state to `/run/pier/status.json`, which `ls`, the TUI, and `pier proxy` read. +A shutdown ends every process, so every 30 seconds, and once more right +before it parks, the supervisor also snapshots the tmux layout to +`~/.pier/tmux`: sessions, windows, pane layouts and cwds, each pane's last +2000 lines of scrollback, and what each pane was running. On the next boot +`pier-restore.service` rebuilds it before you attach. Scrollback is replayed, +claude comes back with `--resume ` (or `--continue`) and codex with +`codex resume --last`, and any other command is typed at the prompt but not +run. It is the same on every cloud: no hibernation, no cloud APIs. Pool +claims and baked images start clean and never inherit a layout. + Full design, including the settled trade-offs and measured spike numbers: [docs/SPEC.md](docs/SPEC.md). diff --git a/cmd/pier-supervisor/main.go b/cmd/pier-supervisor/main.go index 400f3f4..e9355f9 100644 --- a/cmd/pier-supervisor/main.go +++ b/cmd/pier-supervisor/main.go @@ -20,6 +20,10 @@ // runaway brake). Config is re-read every tick so `pier keep` edits apply // live. State beacon: /run/pier/status.json (read by ls/TUI via ssh) — also // carries the listening TCP ports so `pier proxy` knows what to mirror. +// +// Every ~30s, and once more right before parking, it also snapshots the tmux +// layout to ~/.pier/tmux; `pier-supervisor restore` rebuilds it on the next +// boot (tmux.go). package main import ( @@ -67,9 +71,14 @@ type status struct { } func main() { + if len(os.Args) > 1 && os.Args[1] == "restore" { + os.Exit(restoreMain()) + } + idleSince := time.Now() detachedBusySince := time.Time{} last := status{} + lastSnap := time.Time{} for { idleTimeout, cap_ := readConf() @@ -105,6 +114,11 @@ func main() { last.Setup = setup writeStatus(last) + if now.Sub(lastSnap) >= snapEvery && !restoring() { + snapshotNow() + lastSnap = now + } + if idleTimeout > 0 && !attached && !busy && now.Sub(idleSince) > idleTimeout { park("idle for " + idleTimeout.String()) } @@ -333,6 +347,9 @@ func writeStatus(s status) { func park(reason string) { writeStatus(status{State: "parking", Since: time.Now(), Reason: reason}) + // The last word on the layout: up to 30s of window and scrollback changes + // since the previous snapshot would otherwise not come back on wake. + snapshotNow() fmt.Println("pier-supervisor: parking:", reason) _ = exec.Command("sudo", "shutdown", "-h", "now").Run() os.Exit(0) diff --git a/cmd/pier-supervisor/tmux.go b/cmd/pier-supervisor/tmux.go new file mode 100644 index 0000000..f8a812a --- /dev/null +++ b/cmd/pier-supervisor/tmux.go @@ -0,0 +1,766 @@ +package main + +// Restore-on-wake. Parking is a shutdown, so every process dies with it: the +// tmux server, the user's windows, an agent mid-conversation. The +// conversations themselves live on disk (claude and codex keep transcripts +// under $HOME) and survive; what a park loses is the arrangement around them. +// So the supervisor snapshots that arrangement while the session runs, and +// once more right before it parks, and `pier-supervisor restore` (run once +// per boot by pier-restore.service, before anyone attaches) rebuilds it: the +// same sessions, windows, layouts and cwds, each pane's scrollback replayed, +// agents relaunched on their conversation, anything else typed back at the +// prompt one Enter away. In-flight work (a running agent turn, a dev +// server's process) is gone for good: this brings back the desk, not the RAM. + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + snapEvery = 30 * time.Second + scrollbackLines = 2000 + restoredMarker = "/run/pier/restored" + restoreUnitPath = "/etc/systemd/system/pier-restore.service" +) + +// Pane kinds: what restore does with a pane. +const ( + kindClaude = "agent-claude" // relaunched on its conversation + kindCodex = "agent-codex" // relaunched on its most recent conversation + kindSetup = "setup" // plain shell with scrollback; setup never re-runs + kindShell = "shell" // login shell in the saved cwd + kindOther = "other" // shell with the old command typed, not run +) + +type tmuxState struct { + Saved time.Time `json:"saved"` + Sessions []savedSession `json:"sessions"` +} + +type savedSession struct { + Name string `json:"name"` + Windows []savedWindow `json:"windows"` +} + +type savedWindow struct { + Index int `json:"index"` + Name string `json:"name"` + // AutoName is tmux's automatic-rename: restore leaves such a window + // unnamed so it keeps following its program instead of pinning whatever + // the name happened to be at snapshot time. + AutoName bool `json:"auto_name,omitempty"` + Active bool `json:"active,omitempty"` + Layout string `json:"layout"` + Panes []savedPane `json:"panes"` +} + +type savedPane struct { + Index int `json:"index"` + Active bool `json:"active,omitempty"` + Path string `json:"path"` + // Argv is the pane's foreground process as /proc recorded it. + Argv []string `json:"argv,omitempty"` + Kind string `json:"kind"` + // Resume is the claude conversation id when the command line named one. + Resume string `json:"resume,omitempty"` + // Command is what restore types back into an "other" pane. + Command string `json:"command,omitempty"` + // Scrollback names the pane's captured history file in the state dir. + Scrollback string `json:"scrollback,omitempty"` + + id string // tmux pane id (%N), only meaningful to the live server + pid int +} + +func stateDir(home string) string { return filepath.Join(home, ".pier", "tmux") } + +// tmuxRunner runs one tmux command. The supervisor's talks to the agent's +// default server; the integration test points one at an isolated socket. +type tmuxRunner func(args ...string) ([]byte, error) + +func defaultTmux(args ...string) ([]byte, error) { + out, err := exec.Command("tmux", args...).Output() + var ee *exec.ExitError + if errors.As(err, &ee) { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr))) + } + return out, err +} + +// --- snapshot ------------------------------------------------------------------- + +// paneFormat asks for every pane of every session in one list-panes call. +// The separator is printable on purpose: tmux 3.2 (Ubuntu 22.04) rewrites +// tabs and other control characters in format output as '_'. The cwd goes +// last, as the one free-form field likely to contain anything. +const paneSep = "|pier|" + +var paneFormat = strings.Join([]string{ + "#{session_name}", "#{window_index}", "#{window_name}", "#{window_active}", "#{automatic-rename}", + "#{window_layout}", "#{pane_index}", "#{pane_active}", "#{pane_id}", "#{pane_pid}", "#{pane_current_path}", +}, paneSep) + +// parsePaneList folds list-panes output (in tmux's own session/window/pane +// order) into the saved tree. +func parsePaneList(out string) tmuxState { + var st tmuxState + for _, line := range strings.Split(out, "\n") { + f := strings.SplitN(line, paneSep, 11) + if len(f) != 11 { + continue + } + wi, err1 := strconv.Atoi(f[1]) + pi, err2 := strconv.Atoi(f[6]) + pid, _ := strconv.Atoi(f[9]) + if err1 != nil || err2 != nil { + continue + } + if n := len(st.Sessions); n == 0 || st.Sessions[n-1].Name != f[0] { + st.Sessions = append(st.Sessions, savedSession{Name: f[0]}) + } + s := &st.Sessions[len(st.Sessions)-1] + if n := len(s.Windows); n == 0 || s.Windows[n-1].Index != wi { + s.Windows = append(s.Windows, savedWindow{ + Index: wi, Name: f[2], Active: f[3] == "1", AutoName: f[4] == "1", Layout: f[5], + }) + } + w := &s.Windows[len(s.Windows)-1] + w.Panes = append(w.Panes, savedPane{Index: pi, Active: f[7] == "1", id: f[8], pid: pid, Path: f[10]}) + } + return st +} + +// snapshotTmux records the live tmux layout into dir: state.json plus one +// scrollback file per pane, each written atomically so a hard stop mid-write +// can never leave restore a torn file. No server means nothing to record — +// the last snapshot stays, so a session that booted and was never attached +// can still be restored after its next park. fg maps a pane's pid to its +// foreground command line. +func snapshotTmux(run tmuxRunner, dir string, fg func(pid int) []string) error { + out, err := run("list-panes", "-a", "-F", paneFormat) + if err != nil { + return nil + } + st := parsePaneList(string(out)) + if len(st.Sessions) == 0 { + return nil + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + keep := map[string]bool{} + for si := range st.Sessions { + s := &st.Sessions[si] + for wi := range s.Windows { + w := &s.Windows[wi] + for pi := range w.Panes { + p := &w.Panes[pi] + p.Argv = fg(p.pid) + p.Kind, p.Resume = classifyPane(w.Name, p.Argv) + if p.Kind == kindOther { + p.Command = commandLine(prettyArgv(p.Argv, exec.LookPath)) + } + b, err := run("capture-pane", "-p", "-J", "-S", "-"+strconv.Itoa(scrollbackLines), "-t", p.id) + if err != nil { + continue + } + name := scrollbackName(s.Name, w.Index, p.Index) + if writeIfChanged(filepath.Join(dir, name), trimTrailingBlank(b)) == nil { + p.Scrollback = name + keep[name] = true + } + } + } + } + st.Saved = time.Now().UTC() + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := writeAtomic(filepath.Join(dir, "state.json"), b); err != nil { + return err + } + // Scrollback of panes that no longer exist would only pile up. + old, _ := filepath.Glob(filepath.Join(dir, "*.txt")) + for _, f := range old { + if !keep[filepath.Base(f)] { + os.Remove(f) + } + } + return nil +} + +// scrollbackName is the per-pane history file: ...txt. +// tmux already forbids '.' and ':' in session names; '/' is the one character +// left that would escape the directory. +func scrollbackName(session string, window, pane int) string { + return fmt.Sprintf("%s.%d.%d.txt", strings.ReplaceAll(session, "/", "_"), window, pane) +} + +// trimTrailingBlank drops the empty rows below the cursor that capture-pane +// reports for the unused part of the screen — replayed, they would push the +// restored prompt to the bottom of the pane. +func trimTrailingBlank(b []byte) []byte { + s := strings.TrimRight(string(b), " \t\r\n") + if s == "" { + return nil + } + return []byte(s + "\n") +} + +// writeIfChanged skips identical rewrites: most panes are quiet between +// snapshots, and the disk is a network volume billed and throttled by IOPS. +func writeIfChanged(path string, data []byte) error { + if old, err := os.ReadFile(path); err == nil && string(old) == string(data) { + return nil + } + return writeAtomic(path, data) +} + +func writeAtomic(path string, data []byte) error { + f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return err + } + return os.Rename(f.Name(), path) +} + +// foreground returns the command line of the process in the foreground of +// the pane whose root process is pid. The terminal's foreground process +// group (tpgid) is the program the user is looking at. When that group is +// the pane's shell itself, a child may still be running inside it: a program +// started from a startup file, before job control is up, shares the shell's +// group — so a child in the same group wins over the shell. +func foreground(pid int) []string { + stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return nil + } + _, tpgid, ok := parseStat(string(stat)) + if !ok || tpgid <= 0 { + return procArgv(pid) + } + if tpgid != pid { + if argv := procArgv(tpgid); argv != nil { + return argv + } + return procArgv(pid) + } + kids, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) + for _, k := range strings.Fields(string(kids)) { + kid, _ := strconv.Atoi(k) + ks, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", kid)) + if err != nil { + continue + } + if pgrp, _, ok := parseStat(string(ks)); ok && pgrp == tpgid { + return procArgv(kid) + } + } + return procArgv(pid) +} + +// parseStat pulls the process group and the terminal's foreground process +// group out of /proc//stat. The command name in parentheses may itself +// contain spaces and parens, so fields are counted from the last ')'. +func parseStat(s string) (pgrp, tpgid int, ok bool) { + i := strings.LastIndexByte(s, ')') + if i < 0 { + return 0, 0, false + } + f := strings.Fields(s[i+1:]) + // state ppid pgrp session tty_nr tpgid ... + if len(f) < 6 { + return 0, 0, false + } + pgrp, err1 := strconv.Atoi(f[2]) + tpgid, err2 := strconv.Atoi(f[5]) + return pgrp, tpgid, err1 == nil && err2 == nil +} + +func procArgv(pid int) []string { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil || len(b) == 0 { + return nil + } + return strings.Split(strings.TrimRight(string(b), "\x00"), "\x00") +} + +// --- classification ------------------------------------------------------------- + +var ( + shells = map[string]bool{"bash": true, "sh": true, "dash": true, "zsh": true, "fish": true} + interpreters = map[string]bool{"node": true, "nodejs": true, "python": true, "python3": true, "ruby": true, "perl": true} + // Claude conversation ids are UUIDs. Anything else after --resume is a + // search term for its picker, which a restore can't answer. + uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) + +// classifyPane decides what restore does with a pane, from its window name +// and foreground argv. For claude it also returns the conversation id the +// command line resumed or pinned, if any. +func classifyPane(window string, argv []string) (kind, resume string) { + if window == "setup" || window == "setup-failed" || strings.Contains(strings.Join(argv, " "), ".pier-setup") { + return kindSetup, "" + } + if len(argv) == 0 || isShell(argv) { + return kindShell, "" + } + switch prog, args := program(argv); prog { + case "claude": + return kindClaude, claudeResumeID(args) + case "codex": + return kindCodex, "" + } + return kindOther, "" +} + +// isShell reports an interactive shell at its prompt: a shell binary with +// only options (a login shell's argv[0] is "-bash"). `bash script.sh` or +// `bash -c ...` is running something, so it is not. +func isShell(argv []string) bool { + if !shells[strings.TrimPrefix(filepath.Base(argv[0]), "-")] { + return false + } + for i := 1; i < len(argv); i++ { + switch a := argv[i]; { + case a == "-c": + return false + case a == "--rcfile" || a == "--init-file": + i++ // its value is a startup file, not a script + case !strings.HasPrefix(a, "-"): + return false + } + } + return true +} + +// program names what argv runs, looking through an interpreter: an npm +// install runs its CLIs as `node /usr/bin/codex ...` or +// `node .../@anthropic-ai/claude-code/cli.js ...`. +func program(argv []string) (name string, args []string) { + base := filepath.Base(argv[0]) + if interpreters[base] && len(argv) > 1 && !strings.HasPrefix(argv[1], "-") { + return scriptName(argv[1]), argv[2:] + } + return base, argv[1:] +} + +func scriptName(path string) string { + switch { + case strings.Contains(path, "/claude-code/"): + return "claude" + case strings.Contains(path, "/@openai/codex/"): + return "codex" + } + b := filepath.Base(path) + for _, ext := range []string{".js", ".cjs", ".mjs", ".py"} { + b = strings.TrimSuffix(b, ext) + } + return b +} + +// claudeResumeID finds the conversation a claude command line resumed +// (--resume/-r) or pinned (--session-id), in either "--flag id" or +// "--flag=id" form. None means restore falls back to --continue. +func claudeResumeID(args []string) string { + for i, a := range args { + for _, flag := range []string{"--resume", "-r", "--session-id"} { + v, ok := strings.CutPrefix(a, flag+"=") + if !ok && a == flag && i+1 < len(args) { + v, ok = args[i+1], true + } + if ok && uuidRe.MatchString(v) { + return v + } + } + } + return "" +} + +// claudeKeptFlags carries over the flags that shape how a conversation runs +// rather than what it says. A restore that dropped +// --dangerously-skip-permissions would leave an unattended agent blocked on +// a permission prompt; a positional prompt must never ride along, or the +// relaunch would send it again. +func claudeKeptFlags(args []string) []string { + var kept []string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--dangerously-skip-permissions": + kept = append(kept, a) + case a == "--model" || a == "--permission-mode": + if i+1 < len(args) { + kept = append(kept, a, args[i+1]) + i++ + } + case strings.HasPrefix(a, "--model=") || strings.HasPrefix(a, "--permission-mode="): + kept = append(kept, a) + } + } + return kept +} + +// prettyArgv turns an interpreter-run CLI back into what the user typed: +// `node /usr/lib/node_modules/pnpm/bin/pnpm.cjs dev` becomes `pnpm dev`, as +// long as that name resolves on PATH to the same tool's entry point. +// Anything it can't map stays verbatim, which still reruns correctly. +func prettyArgv(argv []string, lookPath func(string) (string, error)) []string { + if len(argv) < 2 || !interpreters[filepath.Base(argv[0])] || !filepath.IsAbs(argv[1]) { + return argv + } + name := strings.TrimSuffix(scriptName(argv[1]), "-cli") + if _, err := lookPath(name); err != nil { + return argv + } + return append([]string{name}, argv[2:]...) +} + +// launchCommand is what restore runs in an agent pane, relaunching the agent +// on its conversation. Other kinds run nothing. +func launchCommand(p savedPane) string { + switch p.Kind { + case kindClaude: + var args []string + if len(p.Argv) > 0 { + _, args = program(p.Argv) + } + cmd := []string{"claude", "--continue"} + if p.Resume != "" { + cmd = []string{"claude", "--resume", p.Resume} + } + return commandLine(append(cmd, claudeKeptFlags(args)...)) + case kindCodex: + return "codex resume --last" + } + return "" +} + +// commandLine joins argv for a shell, quoting only the words that need it. +func commandLine(argv []string) string { + q := make([]string, len(argv)) + for i, a := range argv { + q[i] = shWord(a) + } + return strings.Join(q, " ") +} + +var plainWord = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) + +func shWord(s string) string { + if plainWord.MatchString(s) { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// --- restore plan --------------------------------------------------------------- + +// planStep is one tmux command of a restore. The plan is plain data so the +// whole rebuild can be asserted on without a tmux binary. +type planStep struct { + Args []string + // Optional steps may fail harmlessly: moving a session's first window + // onto the index it already has is an error to tmux, and which index + // new-session picks depends on the user's base-index. + Optional bool + // AwaitPrompt holds the step until that pane's shell sits at its prompt. + // Keys sent any earlier arrive while the tty still echoes on its own, + // printing the command twice (once raw, once at the prompt). + AwaitPrompt string +} + +// rcBody is the startup file every restored pane's shell reads. A real login +// shell won't do: pier's ~/.bashrc ends by cd'ing into the repo, which would +// throw away the pane's saved cwd. So this reproduces one — /etc/profile and +// ~/.profile (PATH, then ~/.bashrc with ~/.config/pier/env) — and only then +// moves to the pane's cwd. The scrollback is replayed first so the restored +// prompt lands below it, tmux-resurrect style. +// +// The agent starts from the first prompt, via a one-shot PROMPT_COMMAND, not +// from this file: startup files run before bash turns job control on, so a +// program started here would share the shell's process group — tmux would +// see "bash" as the pane's command (and name the window after it), and +// Ctrl-Z would not reach the agent. It runs inside this interactive shell +// rather than replacing it, so quitting it leaves the user at a prompt in +// the same place instead of a closed pane. +const rcBody = `# Written by pier-supervisor restore; the startup file of a pane it rebuilt. +[ -n "${PIER_RESTORE_SCROLLBACK:-}" ] && cat -- "$PIER_RESTORE_SCROLLBACK" 2>/dev/null +[ -f /etc/profile ] && . /etc/profile +if [ -f ~/.profile ]; then . ~/.profile; elif [ -f ~/.bashrc ]; then . ~/.bashrc; fi +cd -- "${PIER_RESTORE_CWD:-$HOME}" 2>/dev/null +__pier_run=${PIER_RESTORE_RUN:-} +unset PIER_RESTORE_CWD PIER_RESTORE_SCROLLBACK PIER_RESTORE_RUN +if [ -n "$__pier_run" ]; then + __pier_pc=${PROMPT_COMMAND:-} + __pier_once() { + PROMPT_COMMAND=$__pier_pc + local run=$__pier_run + unset -f __pier_once; unset __pier_run __pier_pc + eval "$run" + } + PROMPT_COMMAND=__pier_once +else + unset __pier_run +fi +` + +// paneCommand is the shell-command tmux starts a restored pane with. The +// per-pane values ride env(1) on the command itself, not tmux -e: on +// new-session, -e sets the session environment, and every window the user +// opened later would replay this pane's scrollback and relaunch its agent. +func paneCommand(p savedPane, dir string) string { + env := []string{"PIER_RESTORE_CWD=" + p.Path} + if p.Scrollback != "" { + env = append(env, "PIER_RESTORE_SCROLLBACK="+filepath.Join(dir, p.Scrollback)) + } + if run := launchCommand(p); run != "" { + env = append(env, "PIER_RESTORE_RUN="+run) + } + return "env " + commandLine(env) + " bash --rcfile " + shWord(filepath.Join(dir, "restore.rc")) + " -i" +} + +// layoutSize reads the window size a layout string was captured at +// ("c3a1,203x51,0,0{...}"). +func layoutSize(layout string) (w, h string, ok bool) { + _, rest, found := strings.Cut(layout, ",") + if !found { + return "", "", false + } + dims, _, _ := strings.Cut(rest, ",") + w, h, found = strings.Cut(dims, "x") + if !found { + return "", "", false + } + if _, err := strconv.Atoi(w); err != nil { + return "", "", false + } + if _, err := strconv.Atoi(h); err != nil { + return "", "", false + } + return w, h, true +} + +// buildRestorePlan turns a snapshot into the tmux commands that rebuild it. +// `main` goes first — it is the session attach lands in, and it gets the +// SSH_AUTH_SOCK symlink the bootstrap's tmuxEnsure gives it (the other +// sessions get it too). Sessions are created at the size their layouts were +// captured at, so splits have room and select-layout reproduces the geometry +// before a client ever attaches. Panes are split off the newest pane, which +// keeps their indices in snapshot order for select-layout and targeting. +func buildRestorePlan(st tmuxState, home string) []planStep { + dir := stateDir(home) + sessions := make([]savedSession, 0, len(st.Sessions)) + for _, s := range st.Sessions { + if s.Name == "main" { + sessions = append([]savedSession{s}, sessions...) + } else { + sessions = append(sessions, s) + } + } + var steps []planStep + add := func(args ...string) { steps = append(steps, planStep{Args: args}) } + for _, s := range sessions { + var typed []planStep + first, activeWin := true, "" + for _, w := range s.Windows { + if len(w.Panes) == 0 { + continue + } + win := fmt.Sprintf("%s:%d", s.Name, w.Index) + var name []string + if !w.AutoName { + name = []string{"-n", w.Name} + } + p0 := w.Panes[0] + if first { + args := []string{"new-session", "-d", "-s", s.Name} + if x, y, ok := layoutSize(w.Layout); ok { + args = append(args, "-x", x, "-y", y) + } + args = append(args, name...) + args = append(args, "-c", p0.Path, "-e", "SSH_AUTH_SOCK="+home+"/.ssh/agent.sock", paneCommand(p0, dir)) + add(args...) + steps = append(steps, planStep{Args: []string{"move-window", "-s", s.Name + ":^", "-t", win}, Optional: true}) + first = false + } else { + args := append([]string{"new-window", "-d", "-t", win}, name...) + add(append(args, "-c", p0.Path, paneCommand(p0, dir))...) + } + activePane := "" + for i, p := range w.Panes { + if i > 0 { + add("split-window", "-t", win, "-c", p.Path, paneCommand(p, dir)) + } + target := fmt.Sprintf("%s.%d", win, p.Index) + if p.Active { + activePane = target + } + if p.Kind == kindOther && p.Command != "" { + typed = append(typed, planStep{Args: []string{"send-keys", "-t", target, "-l", p.Command}, AwaitPrompt: target}) + } + } + if len(w.Panes) > 1 { + add("select-layout", "-t", win, w.Layout) + if activePane != "" { + add("select-pane", "-t", activePane) + } + } + if w.Active { + activeWin = win + } + } + if activeWin != "" { + add("select-window", "-t", activeWin) + } + steps = append(steps, typed...) + } + return steps +} + +// --- restore -------------------------------------------------------------------- + +// restoreMain is `pier-supervisor restore`, run once per boot by +// pier-restore.service. It always exits 0 and always leaves the marker +// attach waits on: a failed restore must degrade to today's fresh tmux, not +// to a failed unit or a hung attach. +func restoreMain() int { + defer func() { + _ = os.MkdirAll(filepath.Dir(restoredMarker), 0o755) + _ = os.WriteFile(restoredMarker, nil, 0o644) + }() + home, err := os.UserHomeDir() + if err != nil { + fmt.Println("pier-restore:", err) + return 0 + } + fmt.Println("pier-restore:", restoreTmux(defaultTmux, home)) + return 0 +} + +// restoreTmux rebuilds the saved layout unless a tmux server already runs +// (someone beat us to it; never stack a second layout onto theirs) or there +// is nothing saved. It returns the journal line describing what happened. +func restoreTmux(run tmuxRunner, home string) string { + if _, err := run("list-sessions"); err == nil { + return "tmux already running; leaving it alone" + } + dir := stateDir(home) + b, err := os.ReadFile(filepath.Join(dir, "state.json")) + if errors.Is(err, os.ErrNotExist) { + return "no saved layout; nothing to restore" + } + if err != nil { + return "reading saved layout: " + err.Error() + } + var st tmuxState + if err := json.Unmarshal(b, &st); err != nil { + return "saved layout unreadable: " + err.Error() + } + if err := os.WriteFile(filepath.Join(dir, "restore.rc"), []byte(rcBody), 0o644); err != nil { + return "writing restore.rc: " + err.Error() + } + var failed []string + for _, step := range buildRestorePlan(st, home) { + if step.AwaitPrompt != "" { + awaitPrompt(run, step.AwaitPrompt) + } + if _, err := run(step.Args...); err != nil && !step.Optional { + failed = append(failed, fmt.Sprintf("tmux %s: %v", step.Args[0], err)) + } + } + msg := fmt.Sprintf("restored layout saved %s: %s", st.Saved.Format(time.RFC3339), summarize(st)) + if len(failed) > 0 { + msg += fmt.Sprintf("; %d step(s) failed: %s", len(failed), strings.Join(failed, "; ")) + } + return msg +} + +func summarize(st tmuxState) string { + var windows, panes int + kinds := map[string]int{} + for _, s := range st.Sessions { + windows += len(s.Windows) + for _, w := range s.Windows { + panes += len(w.Panes) + for _, p := range w.Panes { + kinds[p.Kind]++ + } + } + } + return fmt.Sprintf("%d session(s), %d window(s), %d pane(s) — %d claude, %d codex, %d command(s) typed back, %d shell(s), %d setup", + len(st.Sessions), windows, panes, kinds[kindClaude], kinds[kindCodex], kinds[kindOther], kinds[kindShell], kinds[kindSetup]) +} + +// awaitPromptFor bounds the wait for a pane's shell; a var so tests don't +// sit it out on systems without GNU stty. +var awaitPromptFor = 10 * time.Second + +// awaitPrompt waits until the pane's tty leaves canonical mode — readline +// switches it off exactly when bash starts reading a command line, so input +// sent from then on is drawn at the prompt instead of echoed raw. A timeout +// sends anyway: a doubled echo is cosmetic. +func awaitPrompt(run tmuxRunner, target string) { + out, err := run("display-message", "-p", "-t", target, "#{pane_tty}") + tty := strings.TrimSpace(string(out)) + if err != nil || tty == "" { + return + } + for deadline := time.Now().Add(awaitPromptFor); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + o, err := exec.Command("stty", "-F", tty, "-a").Output() + if err != nil { + return + } + for _, f := range strings.Fields(string(o)) { + if f == "-icanon" { + return + } + } + } +} + +// --- supervisor hooks ----------------------------------------------------------- + +// snapshotNow is the supervisor's snapshot of the agent user's tmux. +func snapshotNow() { + home, err := os.UserHomeDir() + if err != nil { + return + } + if err := snapshotTmux(defaultTmux, stateDir(home), foreground); err != nil { + fmt.Println("pier-supervisor: tmux snapshot:", err) + } +} + +// restoring reports a boot-time restore still in flight. A snapshot taken +// mid-rebuild would record a half-built layout over the full one it is +// rebuilding from. +func restoring() bool { + if _, err := os.Stat(restoredMarker); err == nil { + return false + } + if _, err := os.Stat(restoreUnitPath); err != nil { + return false + } + out, _ := exec.Command("systemctl", "is-active", "pier-restore.service").Output() + return strings.TrimSpace(string(out)) == "activating" +} diff --git a/cmd/pier-supervisor/tmux_test.go b/cmd/pier-supervisor/tmux_test.go new file mode 100644 index 0000000..f0a0915 --- /dev/null +++ b/cmd/pier-supervisor/tmux_test.go @@ -0,0 +1,342 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" +) + +const convID = "0f8e2b7a-5c4d-4e3f-9a1b-2c3d4e5f6a7b" + +func TestClassifyPane(t *testing.T) { + cases := []struct { + name string + window string + argv []string + kind, want string + }{ + {"login shell", "bash", []string{"-bash"}, kindShell, ""}, + {"nested shell", "zsh", []string{"zsh", "-l"}, kindShell, ""}, + // The shell a restored pane runs must read as a shell next time. + {"restored pane shell", "bash", []string{"bash", "--rcfile", "/home/agent/.pier/tmux/restore.rc", "-i"}, kindShell, ""}, + {"unknown process", "x", nil, kindShell, ""}, + {"bash running a script", "bash", []string{"bash", "deploy.sh"}, kindOther, ""}, + {"bash -c", "bash", []string{"bash", "-c", "make watch"}, kindOther, ""}, + {"plain claude", "claude", []string{"claude"}, kindClaude, ""}, + {"claude --resume id", "claude", []string{"claude", "--resume", convID}, kindClaude, convID}, + {"claude -r id", "claude", []string{"claude", "-r", convID, "--model", "opus"}, kindClaude, convID}, + {"claude --resume=id", "claude", []string{"claude", "--resume=" + convID}, kindClaude, convID}, + {"claude --session-id id", "claude", []string{"claude", "--dangerously-skip-permissions", "--session-id", convID}, kindClaude, convID}, + // A search term opens claude's picker; a restore can't answer it. + {"claude --resume search term", "claude", []string{"claude", "--resume", "auth bug"}, kindClaude, ""}, + {"claude --resume then flag", "claude", []string{"claude", "--resume", "--model", "opus"}, kindClaude, ""}, + {"claude via npm", "node", []string{"node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js", "-r", convID}, kindClaude, convID}, + {"claude native path", "claude", []string{"/home/agent/.local/bin/claude", "--continue"}, kindClaude, ""}, + {"codex native", "codex", []string{"codex"}, kindCodex, ""}, + {"codex via npm shim", "node", []string{"node", "/usr/bin/codex", "resume", "--last"}, kindCodex, ""}, + {"codex via npm package", "node", []string{"node", "/usr/lib/node_modules/@openai/codex/bin/codex.js"}, kindCodex, ""}, + {"dev server", "node", []string{"node", "/usr/lib/node_modules/pnpm/bin/pnpm.cjs", "dev"}, kindOther, ""}, + {"editor", "vim", []string{"vim", "main.go"}, kindOther, ""}, + {"setup window", "setup", []string{"bash", "-c", "whatever"}, kindSetup, ""}, + {"failed setup window", "setup-failed", []string{"sleep", "infinity"}, kindSetup, ""}, + {"setup by command", "bash", []string{"bash", "-c", "echo running > ~/.pier-setup.status; bash ./.pier/setup.sh"}, kindSetup, ""}, + } + for _, c := range cases { + kind, id := classifyPane(c.window, c.argv) + if kind != c.kind || id != c.want { + t.Errorf("%s: got (%s, %q), want (%s, %q)", c.name, kind, id, c.kind, c.want) + } + } +} + +func TestLaunchCommand(t *testing.T) { + cases := []struct { + pane savedPane + want string + }{ + {savedPane{Kind: kindClaude, Argv: []string{"claude"}}, "claude --continue"}, + {savedPane{Kind: kindClaude, Argv: []string{"claude", "-r", convID}, Resume: convID}, "claude --resume " + convID}, + // Flags that shape how the agent runs survive; a positional prompt + // must not, or the relaunch would send it again. + {savedPane{Kind: kindClaude, Argv: []string{"claude", "--dangerously-skip-permissions", "--model", "opus", "fix the login bug"}}, + "claude --continue --dangerously-skip-permissions --model opus"}, + {savedPane{Kind: kindClaude, Argv: []string{"node", "/x/claude-code/cli.js", "--permission-mode=plan"}}, "claude --continue --permission-mode=plan"}, + {savedPane{Kind: kindCodex, Argv: []string{"codex", "do the thing"}}, "codex resume --last"}, + {savedPane{Kind: kindOther, Argv: []string{"pnpm", "dev"}}, ""}, + {savedPane{Kind: kindShell}, ""}, + {savedPane{Kind: kindSetup}, ""}, + } + for _, c := range cases { + if got := launchCommand(c.pane); got != c.want { + t.Errorf("%v: got %q, want %q", c.pane.Argv, got, c.want) + } + } +} + +func TestPrettyArgv(t *testing.T) { + onPath := func(name string) (string, error) { + if name == "pnpm" || name == "npm" { + return "/usr/bin/" + name, nil + } + return "", errors.New("not found") + } + cases := []struct{ in, want []string }{ + {[]string{"node", "/usr/lib/node_modules/pnpm/bin/pnpm.cjs", "dev"}, []string{"pnpm", "dev"}}, + {[]string{"node", "/usr/lib/node_modules/npm/bin/npm-cli.js", "run", "dev"}, []string{"npm", "run", "dev"}}, + {[]string{"node", "server.js"}, []string{"node", "server.js"}}, // relative: the user typed it this way + {[]string{"node", "/opt/tool/unknown.js", "x"}, []string{"node", "/opt/tool/unknown.js", "x"}}, // not on PATH + {[]string{"make", "watch"}, []string{"make", "watch"}}, + } + for _, c := range cases { + if got := prettyArgv(c.in, onPath); !reflect.DeepEqual(got, c.want) { + t.Errorf("%v: got %v, want %v", c.in, got, c.want) + } + } +} + +func TestCommandLine(t *testing.T) { + got := commandLine([]string{"psql", "-c", "select 1", "it's", "a=b/c"}) + if want := `psql -c 'select 1' 'it'\''s' a=b/c`; got != want { + t.Errorf("got %s, want %s", got, want) + } +} + +func TestParseStat(t *testing.T) { + // The comm field may hold spaces and parens; fields count from the last ')'. + pgrp, tpgid, ok := parseStat("4242 (tmux: server) (x)) S 1 4242 4242 34816 5150 4194560 ...") + if !ok || pgrp != 4242 || tpgid != 5150 { + t.Errorf("got %d/%d/%v, want 4242/5150/true", pgrp, tpgid, ok) + } + if _, _, ok := parseStat("garbage"); ok { + t.Error("garbage must not parse") + } +} + +func TestLayoutSize(t *testing.T) { + if w, h, ok := layoutSize("b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}"); !ok || w != "200" || h != "50" { + t.Errorf("got %s x %s (%v)", w, h, ok) + } + for _, bad := range []string{"", "b25f", "b25f,wide,0,0"} { + if _, _, ok := layoutSize(bad); ok { + t.Errorf("%q must not parse", bad) + } + } +} + +func TestParsePaneList(t *testing.T) { + row := func(f ...string) string { return strings.Join(f, paneSep) + "\n" } + out := row("main", "0", "editor", "1", "0", "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}", "0", "1", "%0", "101", "/home/agent/work/shop") + + row("main", "0", "editor", "1", "0", "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}", "1", "0", "%1", "102", "/home/agent/work/shop/web") + + row("main", "1", "bash", "0", "1", "aaaa,200x50,0,0,2", "0", "1", "%2", "103", "/home/agent/odd"+paneSep+"dir") + + row("scratch", "0", "bash", "1", "1", "cccc,80x24,0,0,3", "0", "1", "%3", "104", "/home/agent") + st := parsePaneList(out) + if len(st.Sessions) != 2 || st.Sessions[0].Name != "main" || st.Sessions[1].Name != "scratch" { + t.Fatalf("sessions: %+v", st.Sessions) + } + main := st.Sessions[0] + if len(main.Windows) != 2 || len(main.Windows[0].Panes) != 2 || len(main.Windows[1].Panes) != 1 { + t.Fatalf("main: %+v", main) + } + w0 := main.Windows[0] + if w0.Name != "editor" || !w0.Active || w0.AutoName || w0.Layout != "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}" { + t.Errorf("window 0: %+v", w0) + } + if p := w0.Panes[1]; p.Index != 1 || p.Active || p.id != "%1" || p.pid != 102 || p.Path != "/home/agent/work/shop/web" { + t.Errorf("pane 0.1: %+v", p) + } + if !main.Windows[1].AutoName { + t.Error("automatic-rename must be recorded") + } + if p := main.Windows[1].Panes[0]; p.Path != "/home/agent/odd"+paneSep+"dir" { + t.Errorf("the separator inside a cwd must survive: %q", p.Path) + } +} + +func TestBuildRestorePlan(t *testing.T) { + st := tmuxState{Sessions: []savedSession{ + // Listed before main on purpose: main must still come back first. + {Name: "scratch", Windows: []savedWindow{{ + Index: 1, Name: "codex", AutoName: true, Active: true, Layout: "cccc,80x24,0,0,3", + Panes: []savedPane{{Index: 1, Active: true, Path: "/home/agent", Kind: kindCodex, Argv: []string{"codex"}}}, + }}}, + {Name: "main", Windows: []savedWindow{ + { + Index: 0, Name: "editor", Active: true, Layout: "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}", + Panes: []savedPane{ + {Index: 0, Active: true, Path: "/home/agent/work/shop", Kind: kindClaude, Resume: convID, + Argv: []string{"claude", "--resume", convID, "--dangerously-skip-permissions"}, Scrollback: "main.0.0.txt"}, + {Index: 1, Path: "/home/agent/work/shop/web", Kind: kindOther, Argv: []string{"pnpm", "dev"}, + Command: "pnpm dev", Scrollback: "main.0.1.txt"}, + }, + }, + { + Index: 1, Name: "setup", Layout: "aaaa,200x50,0,0,2", + Panes: []savedPane{{Index: 0, Active: true, Path: "/home/agent/work/shop", Kind: kindSetup, + Argv: []string{"bash", "-c", "... .pier-setup.status ..."}, Scrollback: "main.1.0.txt"}}, + }, + }}, + }} + const rc = " bash --rcfile /home/agent/.pier/tmux/restore.rc -i" + claude := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.0.txt 'PIER_RESTORE_RUN=claude --resume " + convID + " --dangerously-skip-permissions'" + rc + // The dev server does not run on restore: it is typed at the prompt. + dev := "env PIER_RESTORE_CWD=/home/agent/work/shop/web PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.1.txt" + rc + // Setup never re-runs: a shell with its scrollback. + setup := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.1.0.txt" + rc + codex := "env PIER_RESTORE_CWD=/home/agent 'PIER_RESTORE_RUN=codex resume --last'" + rc + sock := "SSH_AUTH_SOCK=/home/agent/.ssh/agent.sock" + + want := []planStep{ + {Args: []string{"new-session", "-d", "-s", "main", "-x", "200", "-y", "50", "-n", "editor", "-c", "/home/agent/work/shop", "-e", sock, claude}}, + {Args: []string{"move-window", "-s", "main:^", "-t", "main:0"}, Optional: true}, + {Args: []string{"split-window", "-t", "main:0", "-c", "/home/agent/work/shop/web", dev}}, + {Args: []string{"select-layout", "-t", "main:0", "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}"}}, + {Args: []string{"select-pane", "-t", "main:0.0"}}, + {Args: []string{"new-window", "-d", "-t", "main:1", "-n", "setup", "-c", "/home/agent/work/shop", setup}}, + {Args: []string{"select-window", "-t", "main:0"}}, + {Args: []string{"send-keys", "-t", "main:0.1", "-l", "pnpm dev"}, AwaitPrompt: "main:0.1"}, + // automatic-rename windows stay unnamed so they keep following their program. + {Args: []string{"new-session", "-d", "-s", "scratch", "-x", "80", "-y", "24", "-c", "/home/agent", "-e", sock, codex}}, + {Args: []string{"move-window", "-s", "scratch:^", "-t", "scratch:1"}, Optional: true}, + {Args: []string{"select-window", "-t", "scratch:1"}}, + } + got := buildRestorePlan(st, "/home/agent") + if !reflect.DeepEqual(got, want) { + var b strings.Builder + for _, s := range got { + fmt.Fprintf(&b, " %q optional=%v await=%q\n", s.Args, s.Optional, s.AwaitPrompt) + } + t.Errorf("plan mismatch; got:\n%s", b.String()) + } +} + +// TestSnapshotLeavesLastStateWithoutServer: a boot that never started tmux +// must not wipe the layout the next wake restores from. +func TestSnapshotLeavesLastStateWithoutServer(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "state.json"), []byte(`{"sessions":[]}`), 0o600) + noServer := func(args ...string) ([]byte, error) { return nil, errors.New("no server running") } + if err := snapshotTmux(noServer, dir, func(int) []string { return nil }); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(filepath.Join(dir, "state.json")); string(b) != `{"sessions":[]}` { + t.Errorf("state was rewritten: %s", b) + } +} + +func TestRestoreSkipsRunningServerAndMissingState(t *testing.T) { + home := t.TempDir() + running := func(args ...string) ([]byte, error) { + if args[0] != "list-sessions" { + t.Errorf("restore touched a running server: %v", args) + } + return []byte("main: 1 windows\n"), nil + } + if msg := restoreTmux(running, home); !strings.Contains(msg, "already running") { + t.Errorf("got %q", msg) + } + none := func(args ...string) ([]byte, error) { + if args[0] != "list-sessions" { + t.Errorf("restore ran tmux without a saved layout: %v", args) + } + return nil, errors.New("no server running") + } + if msg := restoreTmux(none, home); !strings.Contains(msg, "nothing to restore") { + t.Errorf("got %q", msg) + } +} + +// TestSnapshotRestoreRoundTrip drives a real tmux on an isolated socket: +// snapshot a two-window session, kill the server (the park), restore, and +// check the layout, cwds and scrollback came back. Skipped without tmux. +func TestSnapshotRestoreRoundTrip(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not installed") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not installed") + } + home := t.TempDir() + work := filepath.Join(home, "work") + os.MkdirAll(work, 0o755) + sock := fmt.Sprintf("pier-test-%d", os.Getpid()) + run := func(args ...string) ([]byte, error) { + cmd := exec.Command("tmux", append([]string{"-L", sock, "-f", "/dev/null"}, args...)...) + cmd.Env = append(os.Environ(), "HOME="+home, "TMUX=") + out, err := cmd.Output() + var ee *exec.ExitError + if errors.As(err, &ee) { + err = fmt.Errorf("%w: %s", err, ee.Stderr) + } + return out, err + } + t.Cleanup(func() { run("kill-server") }) + must := func(args ...string) { + t.Helper() + if out, err := run(args...); err != nil { + t.Fatalf("tmux %v: %v %s", args, err, out) + } + } + awaitPromptFor = 3 * time.Second + + must("new-session", "-d", "-s", "main", "-x", "120", "-y", "40", "-n", "code", "-c", work, "bash --norc -i") + must("send-keys", "-t", "main:0", "echo scrollback-marker", "Enter") + must("new-window", "-d", "-t", "main:1", "-n", "server", "-c", home, "sleep 600") + must("split-window", "-d", "-t", "main:1", "-c", work, "bash --norc -i") + time.Sleep(500 * time.Millisecond) + + dir := stateDir(home) + if err := snapshotTmux(run, dir, foreground); err != nil { + t.Fatal(err) + } + var st tmuxState + b, err := os.ReadFile(filepath.Join(dir, "state.json")) + if err != nil { + t.Fatal(err) + } + json.Unmarshal(b, &st) + if len(st.Sessions) != 1 || len(st.Sessions[0].Windows) != 2 || len(st.Sessions[0].Windows[1].Panes) != 2 { + t.Fatalf("snapshot: %s", b) + } + if runtime.GOOS == "linux" { + if p := st.Sessions[0].Windows[1].Panes[0]; p.Kind != kindOther || p.Command != "sleep 600" { + t.Errorf("sleep pane: kind %s command %q", p.Kind, p.Command) + } + } + + must("kill-server") + msg := restoreTmux(run, home) + if strings.Contains(msg, "failed") { + t.Errorf("restore: %s", msg) + } + out, err := run("list-panes", "-a", "-F", "#{session_name}:#{window_index}:#{window_name}.#{pane_index} #{pane_current_path}") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"main:0:code.0 " + work, "main:1:server.0 " + home, "main:1:server.1 " + work} { + if !strings.Contains(string(out), want) { + t.Errorf("restored panes missing %q:\n%s", want, out) + } + } + deadline := time.Now().Add(3 * time.Second) + for { + shot, _ := run("capture-pane", "-p", "-t", "main:0.0") + typed, _ := run("capture-pane", "-p", "-t", "main:1.0") + scrollOK := strings.Contains(string(shot), "scrollback-marker") + typedOK := runtime.GOOS != "linux" || strings.Contains(string(typed), "sleep 600") + if scrollOK && typedOK { + break + } + if time.Now().After(deadline) { + t.Fatalf("scrollback replayed: %v, command typed back: %v\npane 0:\n%s\npane 1.0:\n%s", scrollOK, typedOK, shot, typed) + } + time.Sleep(100 * time.Millisecond) + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 08754b9..4799bd5 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -57,6 +57,11 @@ the container services for this product: Honest seam: in v1 a park loses running processes (including the tmux server and any in-flight agent run) — files, git state, and installed tools survive. +What a wake brings back is the arrangement (§6 restore-on-wake): the tmux +layout, cwds and scrollback, with agents relaunched on their conversations +from the transcripts on disk. What it can't bring back is anything that was +mid-flight: an agent turn stops at its last saved message, and a dev server +returns as its command typed at the prompt, not running. v1.1 upgrade: EC2 hibernate / GCE suspend to preserve RAM across park. ## 3. Drivers @@ -181,6 +186,36 @@ The beacon additionally lists the session's listening TCP ports (one `sudo ss -Htnap` pass; sshd and systemd-resolved excluded) — this is how `pier proxy` knows what to mirror without the user declaring anything. +**Restore-on-wake.** Parking stays a plain shutdown; the supervisor makes it +cheap to come back from. Every ~30s, and once more right before `shutdown`, +it snapshots the agent user's tmux to `~/.pier/tmux/state.json` (atomic +write) from one `tmux list-panes -a` call: every session, window (index, +name, layout string, active) and pane (cwd, active, and the foreground +process's argv read from `/proc` via the pane pid's terminal foreground +group), plus each pane's last 2000 lines of scrollback beside it. No tmux +server leaves the last snapshot alone, so a boot nobody attached to is still +restorable after its next park. Each pane is classified: `agent-claude` +(recording a `--resume`/`-r`/`--session-id` conversation id when argv names +one), `agent-codex`, `setup` (the setup window), `shell`, or `other`. + +`pier-restore.service` (installed next to the supervisor unit; oneshot, +`RemainAfterExit=yes`, `KillMode=process`, because the tmux server it starts +lives in its cgroup) runs `pier-supervisor restore` once per boot, before +anyone attaches. When no tmux server runs and a snapshot exists, it rebuilds +every session (`main` first, with the attach-refreshed `SSH_AUTH_SOCK` +symlink), window and pane at its saved cwd and layout. Each pane's shell +replays its scrollback, then: claude relaunches with `claude --resume `, +or `claude --continue` (the newest conversation in that cwd) when no id was +recorded; codex with `codex resume --last`; `other` panes get the old +command typed at the prompt but not executed; setup windows come back as a +plain shell with their log, and setup never re-runs. It always ends by +writing `/run/pier/restored`. Attach waits up to ~15s for that marker, but +only when the unit and a snapshot both exist, so older sessions never wait. +A pool claim is a wake too: freshen drops the restored server and +`~/.pier/tmux` before its own setup window, and the prebuild scrub deletes +`~/.pier/tmux` so an image never carries a layout. No cloud API or +credential is involved, so it works the same on every cloud. + A session either exists fully set up or not at all — no half-states: - The create's **last act** is a `pier:ready` tag on the instance (written diff --git a/internal/driver/awsec2/direct_test.go b/internal/driver/awsec2/direct_test.go index a4b2752..d47ac0b 100644 --- a/internal/driver/awsec2/direct_test.go +++ b/internal/driver/awsec2/direct_test.go @@ -52,6 +52,15 @@ func TestAttachCommandFallsBackForUnknownTerminal(t *testing.T) { if !strings.Contains(remote, `infocmp "$TERM"`) || !strings.Contains(remote, "export TERM=xterm-256color") { t.Errorf("attach must fall back when the VM lacks the client's terminfo entry, got %q", remote) } + // The wait for the boot-time tmux restore is gated on the unit and a + // saved layout (sessions from before restore existed must never hang) + // and happens before tmux starts. + gate := strings.Index(remote, `[ -f /etc/systemd/system/pier-restore.service ] && [ -f ~/.pier/tmux/state.json ]`) + wait := strings.Index(remote, `[ -e /run/pier/restored ] && break`) + exec := strings.Index(remote, "exec tmux new-session -A -s main") + if gate < 0 || wait < gate || exec < wait { + t.Errorf("attach must wait for the restore marker, gated, before tmux; got %q", remote) + } } // Park, resume and resize invalidate the probe cache — a cached success diff --git a/internal/driver/awsec2/driver.go b/internal/driver/awsec2/driver.go index 6ec18cd..685bff3 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -312,6 +312,8 @@ func (d *Driver) Destroy(ctx context.Context, id string) error { // land in an empty $HOME (no repo yet) and steal the `main` tmux session // away from its workdir. The ready tag stops pier's own commands well before // this, so it's a backstop for races and raw ssh users — refuse, don't wait. +// The one wait it does make is short and bounded: for the boot-time tmux +// restore of a woken session to finish (see cmd/pier-supervisor/tmux.go). func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error) { const remote = `[ -S "$SSH_AUTH_SOCK" ] && ln -sf "$SSH_AUTH_SOCK" ~/.ssh/agent.sock [ -e "$HOME/.pier-bootstrapped" ] || { echo "pier: this session is still setting up — attach again when it shows running in pier ls" >&2; exit 1; } @@ -319,6 +321,13 @@ func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error # may not exist in the VM image's terminfo database yet. Keep the richer entry # when it is installed and otherwise use the portable 256-colour baseline. if ! infocmp "$TERM" >/dev/null 2>&1; then export TERM=xterm-256color; fi +# A woken session rebuilds its saved tmux layout at boot (pier-restore.service); +# give that a moment so this attach lands in the restored windows instead of +# racing it to an empty main. Sessions without the unit or a saved layout +# never wait. +if [ -f /etc/systemd/system/pier-restore.service ] && [ -f ~/.pier/tmux/state.json ]; then + for _ in $(seq 30); do [ -e /run/pier/restored ] && break; sleep 0.5; done +fi exec tmux new-session -A -s main` args := append(d.sshOpts(ctx, id), "-t", "-o", "ForwardAgent=yes", "agent@"+id, remote) cmd := exec.CommandContext(ctx, "ssh", args...) diff --git a/internal/driver/gcpgce/driver.go b/internal/driver/gcpgce/driver.go index fbfe8c0..bb9b3b4 100644 --- a/internal/driver/gcpgce/driver.go +++ b/internal/driver/gcpgce/driver.go @@ -373,7 +373,9 @@ func (d *Driver) Destroy(ctx context.Context, id string) error { // It also refuses on a missing bootstrap marker: attaching mid-create would // land in an empty $HOME (no repo yet) and steal the `main` tmux session // away from its workdir. The ready label stops pier's own commands well -// before this, so it's a backstop for races and raw ssh users. +// before this, so it's a backstop for races and raw ssh users. The one wait +// it does make is short and bounded: for the boot-time tmux restore of a +// woken session to finish (see cmd/pier-supervisor/tmux.go). func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error) { const remote = `[ -S "$SSH_AUTH_SOCK" ] && ln -sf "$SSH_AUTH_SOCK" ~/.ssh/agent.sock [ -e "$HOME/.pier-bootstrapped" ] || { echo "pier: this session is still setting up — attach again when it shows running in pier ls" >&2; exit 1; } @@ -381,6 +383,13 @@ func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error # may not exist in the VM image's terminfo database yet. Keep the richer entry # when it is installed and otherwise use the portable 256-colour baseline. if ! infocmp "$TERM" >/dev/null 2>&1; then export TERM=xterm-256color; fi +# A woken session rebuilds its saved tmux layout at boot (pier-restore.service); +# give that a moment so this attach lands in the restored windows instead of +# racing it to an empty main. Sessions without the unit or a saved layout +# never wait. +if [ -f /etc/systemd/system/pier-restore.service ] && [ -f ~/.pier/tmux/state.json ]; then + for _ in $(seq 30); do [ -e /run/pier/restored ] && break; sleep 0.5; done +fi exec tmux new-session -A -s main` args := append(d.sshOpts(id), "-t", "-o", "ForwardAgent=yes", "agent@"+id, remote) cmd := execCommandContext(ctx, "ssh", args...) diff --git a/internal/driver/gcpgce/driver_test.go b/internal/driver/gcpgce/driver_test.go index 25ea14c..b524d8c 100644 --- a/internal/driver/gcpgce/driver_test.go +++ b/internal/driver/gcpgce/driver_test.go @@ -97,6 +97,15 @@ func TestAttachCommandFallsBackForUnknownTerminal(t *testing.T) { if !strings.Contains(remote, `infocmp "$TERM"`) || !strings.Contains(remote, "export TERM=xterm-256color") { t.Errorf("attach must fall back when the VM lacks the client's terminfo entry, got %q", remote) } + // The wait for the boot-time tmux restore is gated on the unit and a + // saved layout (sessions from before restore existed must never hang) + // and happens before tmux starts. + gate := strings.Index(remote, `[ -f /etc/systemd/system/pier-restore.service ] && [ -f ~/.pier/tmux/state.json ]`) + wait := strings.Index(remote, `[ -e /run/pier/restored ] && break`) + exec := strings.Index(remote, "exec tmux new-session -A -s main") + if gate < 0 || wait < gate || exec < wait { + t.Errorf("attach must wait for the restore marker, gated, before tmux; got %q", remote) + } } func TestMachinesCatalog(t *testing.T) { diff --git a/internal/driver/payload/freshen.go b/internal/driver/payload/freshen.go index 09c865b..829e6c6 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -13,13 +13,25 @@ import ( // A warm pool member already carries everything slow: harnesses, supervisor, // the repo's history, a completed .pier/setup.sh. Claiming it means making // what's cheap-but-stale current again — this script is that step. It runs on -// the just-resumed member (a boot: the tmux server is always gone, /tmp is -// clean) after the same cargo pushes as create, minus supervisor + user-data. +// the just-resumed member (a boot: /tmp is clean, and any tmux server is the +// restore of the member's own fill-time layout, which the script discards) after the same cargo pushes as create, minus supervisor + user-data. const freshenTmpl = `#!/usr/bin/env bash # pier freshen — runs at claim, as agent, on a just-resumed warm pool member. set -euo pipefail +# The resume was a boot, so pier-restore.service may have rebuilt the +# member's fill-time tmux layout (its setup window, a shell) — none of it is +# this session's. Let that restore finish first (a server it started after +# the kill below would outlive it), then drop the server and the saved layout +# so the setup window below starts clean and the next park snapshots this +# session's own. +if [ -f /etc/systemd/system/pier-restore.service ]; then + for _ in $(seq 30); do [ -e /run/pier/restored ] && break; sleep 0.5; done +fi +tmux kill-server 2>/dev/null || true +rm -rf "$HOME/.pier/tmux" + tar -xf /tmp/pier-files.tar -C "$HOME" --strip-components=1 home 2>/dev/null || true set -a; . "$HOME/.config/pier/env" 2>/dev/null || true; set +a diff --git a/internal/driver/payload/payload_test.go b/internal/driver/payload/payload_test.go index 988edb6..8919f3e 100644 --- a/internal/driver/payload/payload_test.go +++ b/internal/driver/payload/payload_test.go @@ -181,6 +181,12 @@ func TestRenderBootstrapModes(t *testing.T) { // (docker.sock denied in .pier/setup.sh otherwise). `sudo -u agent tmux new-session -d -s main`, `setup=./.pier/setup.sh`, + // Restore-on-wake: a oneshot that leaves the tmux server it starts + // running (it lives in the unit's cgroup), enabled for every boot. + `ExecStart=/usr/local/bin/pier-supervisor restore`, + `RemainAfterExit=yes`, + `KillMode=process`, + `sudo systemctl enable --now pier-restore.service`, } { if !strings.Contains(origin, want) { t.Errorf("origin-mode bootstrap missing %q", want) @@ -198,6 +204,24 @@ func TestRenderBootstrapModes(t *testing.T) { } } +// A claim resumes the member, and the boot restores the member's fill-time +// tmux layout. None of it belongs to the claiming session: freshen must drop +// the server and the saved layout before anything else — and before its own +// tmuxEnsure, which would otherwise find the restored main and skip. +func TestFreshenDropsRestoredLayout(t *testing.T) { + kill := strings.Index(freshenTmpl, "tmux kill-server 2>/dev/null || true") + rm := strings.Index(freshenTmpl, `rm -rf "$HOME/.pier/tmux"`) + wait := strings.Index(freshenTmpl, "[ -e /run/pier/restored ] && break") + first := strings.Index(freshenTmpl, "tar -xf /tmp/pier-files.tar") + ensure := strings.Index(freshenTmpl, "tmux has-session -t main") + if wait < 0 || kill < 0 || rm < 0 { + t.Fatalf("freshen must wait out the restore, then kill tmux and drop ~/.pier/tmux") + } + if !(wait < kill && kill < first && rm < first && first < ensure) { + t.Errorf("order: wait %d, kill %d, rm %d must all precede the first step %d and tmuxEnsure %d", wait, kill, rm, first, ensure) + } +} + func TestExportRef(t *testing.T) { if got := exportRef("fix-auth"); got != "refs/pier/export-fix-auth" { t.Errorf("exportRef(fix-auth) = %q", got) diff --git a/internal/driver/payload/prebuild.go b/internal/driver/payload/prebuild.go index 94bfd93..92f53f8 100644 --- a/internal/driver/payload/prebuild.go +++ b/internal/driver/payload/prebuild.go @@ -203,8 +203,10 @@ git reset -q --hard b.WriteString("rm -f -- " + shQuote(p) + "\n") } b.WriteString(`# Credentials setup or the harnesses may have written from the pushed ones, -# and first-boot state a session must create for itself. -rm -rf ~/.config/pier ~/.pier-setup.d +# and first-boot state a session must create for itself — the supervisor's +# tmux snapshot included, or a session booted from this image would wake into +# the bake instance's windows and scrollback. +rm -rf ~/.config/pier ~/.pier-setup.d ~/.pier/tmux rm -f ~/.pier-bootstrapped ~/.pier-setup.status ~/.pier-setup.log ~/.bash_history \ ~/.config/gh/hosts.yml ~/.docker/config.json ~/.git-credentials ~/.ssh/agent.sock rm -f /tmp/pier-scrub.sh diff --git a/internal/driver/payload/prebuild_test.go b/internal/driver/payload/prebuild_test.go index 1a99026..182f1f8 100644 --- a/internal/driver/payload/prebuild_test.go +++ b/internal/driver/payload/prebuild_test.go @@ -78,7 +78,7 @@ func TestScrubScriptRemovesCargoAndKeepsWarmth(t *testing.T) { write(filepath.Join(repo, "node_modules", "dep", "index.js"), "warm") write(filepath.Join(repo, "tracked.txt"), "dirty edit\n") write(filepath.Join(home, ".cache", "pnpm", "store"), "warm") - for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials"} { + for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials", ".pier/tmux/state.json", ".pier/tmux/main.0.0.txt"} { write(filepath.Join(home, p), "x") } @@ -110,7 +110,7 @@ func TestScrubScriptRemovesCargoAndKeepsWarmth(t *testing.T) { t.Errorf("%s survived the scrub", name) } } - for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials"} { + for _, p := range []string{".pier-setup.status", ".pier-setup.log", ".pier-bootstrapped", ".docker/config.json", ".git-credentials", ".pier/tmux/state.json", ".pier/tmux/main.0.0.txt"} { if _, err := os.Stat(filepath.Join(home, p)); !errors.Is(err, os.ErrNotExist) { t.Errorf("~/%s survived the scrub", p) } diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index aa21d13..a9e7595 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -105,7 +105,8 @@ func DurConf(d time.Duration) string { // --- bootstrap ---------------------------------------------------------------- // The tmux-server start and the async setup window are shared with the pool // freshen script (freshen.go): a claimed member resumes from parked — a boot, -// so the tmux server is always gone — and re-runs setup to catch drift. +// so the only tmux server is the member's restored fill-time layout, which +// freshen kills first — and re-runs setup to catch drift. const tmuxEnsure = `# SSH_AUTH_SOCK points at the attach-refreshed symlink (dangling until the # first attach forwards an agent; harmless when it never does). @@ -160,14 +161,46 @@ After=multi-user.target [Service] User=agent RuntimeDirectory=pier +# /run/pier is shared with pier-restore.service; a supervisor restart must +# not wipe the restore marker attach waits on (/run is tmpfs: a boot still +# clears it). +RuntimeDirectoryPreserve=yes ExecStart=/usr/local/bin/pier-supervisor Restart=always +[Install] +WantedBy=multi-user.target +UNIT +# Parking is a shutdown, so a wake finds every process gone. This rebuilds +# the tmux layout the supervisor snapshotted (windows, cwds, scrollback, +# agents resumed on their conversations) once per boot, before anyone +# attaches; attach waits for its /run/pier/restored marker. +sudo tee /etc/systemd/system/pier-restore.service >/dev/null <<'UNIT' +[Unit] +Description=pier tmux restore (brings a parked session's layout back on wake) +After=local-fs.target + +[Service] +Type=oneshot +User=agent +RuntimeDirectory=pier +RuntimeDirectoryPreserve=yes +ExecStart=/usr/local/bin/pier-supervisor restore +TimeoutStartSec=60 +# The tmux server this starts is the user's whole session, and it lives in +# this unit's cgroup. RemainAfterExit keeps the unit active once restore +# exits (an inactive oneshot's leftover processes are killed); KillMode=process +# keeps a stop or restart of the unit from taking the server down with it. +RemainAfterExit=yes +KillMode=process + [Install] WantedBy=multi-user.target UNIT sudo systemctl daemon-reload sudo systemctl enable --now pier-supervisor.service +# --now on a fresh instance finds no saved layout and just writes the marker. +sudo systemctl enable --now pier-restore.service tar -xf /tmp/pier-files.tar -C "$HOME" --strip-components=1 home 2>/dev/null || true set -a; . "$HOME/.config/pier/env" 2>/dev/null || true; set +a From 70d4db336098c9116602ba4a43b13006f7b4d45f Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 14:04:55 +0200 Subject: [PATCH 04/24] style(payload): wrap the freshen doc comment --- internal/driver/payload/freshen.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/driver/payload/freshen.go b/internal/driver/payload/freshen.go index 829e6c6..c3dc8a4 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -14,7 +14,8 @@ import ( // the repo's history, a completed .pier/setup.sh. Claiming it means making // what's cheap-but-stale current again — this script is that step. It runs on // the just-resumed member (a boot: /tmp is clean, and any tmux server is the -// restore of the member's own fill-time layout, which the script discards) after the same cargo pushes as create, minus supervisor + user-data. +// restore of the member's own fill-time layout, which the script discards) +// after the same cargo pushes as create, minus supervisor + user-data. const freshenTmpl = `#!/usr/bin/env bash # pier freshen — runs at claim, as agent, on a just-resumed warm pool member. From 7c581f3f8a02c60a173b0a397cc0d795bd77e249 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 14:06:16 +0200 Subject: [PATCH 05/24] docs: describe ready sessions, speed profiles, reminders and the app --- README.md | 148 ++++++++++++++++++++++------------- docs/SPEC.md | 59 +++++++++----- skills/pier-onboard/SKILL.md | 11 +-- 3 files changed, 139 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 4367983..511519c 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ resizes it. ![pier demo](docs/demo.gif) ``` -pier setup # once: a short wizard sets up the account groundwork +pier setup # once: pick your cloud, confirm the defaults, bake this repo cd ~/code/myapp pier fix-login # new session: your branch, your secrets, your dev env pier proxy # sessions as hostnames: the dev server at fix-login.pier:3000 -pier # the TUI: list, attach, create, resize, destroy +pier # the app: sessions, repos, settings ``` ## Contents @@ -85,7 +85,7 @@ intact. And to see what the agent built, |---|---| | running | `~$0.04/h` | | parked | `~$3-4/mo` (disk only) | -| warm pool member (parked) | `~$3-4/mo` (disk only) | +| ready session (parked) | `~$3-4/mo` (disk only) | There is no control plane. No server, no database, no daemon on your laptop. Session state lives in instance tags. Every byte between you and the VM is @@ -131,21 +131,39 @@ You also need, on the laptop: pier setup ``` -The wizard handles the account groundwork once: - -1. Asks which cloud and authenticates against your AWS profile or GCP - project. -2. Creates the groundwork. On AWS: an IAM role and instance profile carrying - only `AmazonSSMManagedInstanceCore`, plus one egress-only security group. - On GCP: the compute and IAP API enables, plus two firewall rules that - admit only Google's IAP range to pier VMs and shut everything else out. -3. Writes `~/.config/pier/config.toml`. -4. Detects the agent config and credentials it will copy into sessions. -5. Offers to bake an image for the current repo. -6. Runs the `pier doctor` checks. +The wizard asks as little as it can and applies nothing silently: + +1. **Cloud.** Which cloud, then it authenticates against your AWS profile or + GCP project. +2. **Defaults.** Every default a new session will use, in one block, each + with what it costs: machine, disk, park-after, runaway cap, speed profile, + the agent config copied in, and the pier-onboard skill. Press enter to + accept or `e` to walk through them. +3. **Account.** Creates the groundwork. On AWS: an IAM role and instance + profile carrying only `AmazonSSMManagedInstanceCore`, plus one egress-only + security group. On GCP: the compute and IAP API enables, plus two + firewall rules that admit only Google's IAP range to pier VMs. Then it + writes `~/.config/pier/config.toml` and runs the `pier doctor` checks. +4. **This repo.** Run inside a repo, it offers to bake the repo's session + image, so new sessions start in a minute or two instead of running the + full setup. Everything it creates is tagged and removable with `pier teardown`. Change -any setting later from inside the TUI. Run `pier` and press `s`. +anything later: run `pier` and press `s`. + +### Speed profiles + +A profile presets two settings: whether pier reminds you to bake, and how +many ready sessions each baked repo keeps. + +| Profile | New session | Idle cost per repo | +|---|---|---| +| **Fast** (default) | claims a parked, set-up ready session: ~25s | image ~$1-2/mo + one parked disk | +| **Lean** | boots from the repo's image, setup re-runs warm: ~1-2 min | image only | +| **Minimal** | stock launch, full setup every time | $0 | + +A ready session is a stopped VM, so waiting costs disk only. Change either +setting and the profile reads Custom. No admin rights? `pier setup --print-admin` prints the handful of commands for an admin to run once. The wizard then works with what exists. @@ -192,28 +210,27 @@ for pier VMs and one allow rule above it for exactly Google's IAP range ## Usage ``` -pier the TUI: sessions live-updating - enter attach · n new · d delete · p pin · m resize · w pools · s settings · r refresh · q quit +pier the app: Sessions · Repos · Settings tabs (? for keys) pier [base] new session off base (default HEAD), then attach -d, --detach create without attaching - --idle idle self-park timeout (default 30m) + --idle park after this much idle time (default from settings) --cap unattended runaway cap (default 8h) --no-park shorthand for --idle never - --no-pool skip this repo's warm pool for this create + --no-ready launch fresh instead of claiming a ready session pier ls [--json] plain list, or stable JSON for automation -pier attach attach (parked sessions auto-resume, ~20-60s) +pier attach attach (a parked session resumes, ~20-60s, tmux restored) pier logs show the setup script log (-f follows) pier rm [-f] destroy the session and its disk -pier keep pin: disable idle self-park +pier keep pin: never park when idle pier resize grow/shrink the VM (~1-2 min, same CPU arch) -pier bake prebake this repo's session image (~1-2 min creates) -pier pool warm pool status + cost (the TUI's w page, scriptable) -pier pool set keep warm sessions ready for this repo (0 = off) -pier pool fill [--detach] top this repo's pool up to size now -pier pool drain [repo] destroy a repo's warm members +pier repos [--json] every repo: session image, ready sessions, idle cost, tips +pier bake build this repo's session image (repo prebuilt by default) + --toolchain-only keep repo state out of the image for this bake +pier ready [n] show, or set, how many ready sessions this repo keeps pier mcp login one-time browser approvals for OAuth MCP servers pier proxy every running session as .pier (macOS) pier port

manual port forward (8080:3000 = local:session) +pier setup [--skills] first-time setup; --skills only refreshes the agent skill pier doctor environment + account checks pier teardown remove all pier groundwork from the account ``` @@ -303,10 +320,10 @@ Don't write those files by hand. pier ships a coding agent to inspect your repo, write all three files with the right boundaries, and keep loose local files protected by `.gitignore`. -`pier setup` offers it at the end, one confirm per agent on the machine — -Claude Code (`~/.claude/skills`) and Codex (`~/.codex/skills`) read the -same skill format — and `pier skills` runs the same install without -questions, e.g. to refresh after a pier upgrade. To pin it to one repo +`pier setup` installs it with the rest of the defaults, for every agent on +the machine — Claude Code (`~/.claude/skills`) and Codex +(`~/.codex/skills`) read the same skill format — and `pier setup --skills` +refreshes it without the other questions, e.g. after a pier upgrade. To pin it to one repo instead, copy it into that repo's `.claude/skills/`. Then ask your agent to "set this repo up for pier". @@ -410,28 +427,46 @@ the warm re-run gets slow. Images are keyed to the repo, so one project's toolchain never bleeds into another's. Re-baking supersedes the old image, and `pier teardown` sweeps them all by tag. -### Warm pools: the create is already done +### Ready sessions: the create is already done -Even a baked create spends a minute booting and then runs your setup script. -A warm pool does that work ahead of time: +Even a create from a prebuilt image boots a VM and re-runs setup. A ready +session has done that ahead of time: it's a parked, **setup-complete** +session waiting to be claimed. Baked repos keep one by default (the Fast +profile); the Repos tab (`+`/`-`) or `pier ready ` changes that per repo. -``` -cd ~/code/shop && pier pool set 2 -``` +The next `pier ` claims one instead of creating — resume, check out +your branch, re-push secrets, apply your dirty edits — and refills in the +background from the repo's image. None left just means a normal create: +ready sessions accelerate, never gate, and `--no-ready` skips them for one +create. + +Ready sessions are stopped instances, so each costs disk only (~$3-4/mo at +40 GiB). They recycle themselves when they go stale — after a re-bake, a +setup-script change, or 14 days (`pool.max_age`) — and `pier repos` shows +what each repo holds and costs. `pier ready 0` turns them off and removes +the parked ones. + +### Rebake reminders + +pier never rebakes on its own. With bake reminders on (the Fast and Lean +profiles), it says when a bake would make sessions start faster, and only +states facts: the repo has no image yet, its image is older than the +reminder age (30 days by default), or `.pier/setup.sh` or `.pier/bake.sh` +changed since the bake. Reminders show after a create (at most once a day +per repo), as a dot on the Repos tab, and in `pier repos`. + +### The app -pier now keeps two parked, **setup-complete** sessions ready for `shop`. The -next `pier ` claims one instead of creating — resume, check out your -branch, re-push secrets, apply your dirty edits — and refills the pool in -the background. An empty pool just means a normal create; the pool -accelerates, never gates, and `--no-pool` skips it for one create. +`pier` with no arguments opens the app: a **Sessions** tab (every session +with a state dot, a detail pane, attach, logs, resize, keep, delete), a +**Repos** tab (session image, ready sessions, idle cost and reminders per +repo), and **Settings**, one page grouped into Cloud, New sessions, Idle & +cost, and Speed, showing only the active cloud's fields with a line on what +each changes. The header keeps running and parked counts and the live +hourly cost in view; `?` lists the keys. -Warm members are parked instances, so each costs disk only (~$3-4/mo). -They recycle themselves when they go stale — after a re-bake, a -setup-script change, or 14 days (`pool.max_age`) — and `pier pool` shows -every pool you're holding with what it costs. In the TUI, `w` opens the -pool page: `+`/`-` and enter size the current repo's pool, `d` drains any -repo's members, and the header counts what's warm. Strictly opt-in; -`pier pool set 0` turns it off and drains. +Every frontend runs on the same API (`pkg/pier`): the CLI, the app, and the +coming Mac app. ### Setup that can't fail silently @@ -439,9 +474,9 @@ repo's members, and the header counts what's warm. Strictly opt-in; checkout, dirty patch, untracked files, and `.pier/include` extras are in place. The outcome always surfaces: -- `pier ls` and the TUI show `(setup running)` or `(setup failed)` +- `pier ls` and the app show `(setup running)` or `(setup failed)` - `pier logs ` prints the log from anywhere, no attach needed - (`-f` follows, `l` in the TUI) + (`-f` follows, `l` in the app) - `~/.pier-setup.log` ends with `pier setup: done` or `pier setup: FAILED (exit N)` - a failed window renames to `setup-failed` and stays open instead of vanishing @@ -460,7 +495,7 @@ checkout-flow shop working (strained) 2h ~$0.04/h $ pier resize checkout-flow t4g.xlarge ``` -Or press `m` in the TUI. It lists same-arch machines with their vCPU, memory +Or press `m` in the app. It lists same-arch machines with their vCPU, memory and hourly cost, so nobody memorizes instance type names. One park/resume cycle, about a minute on AWS and about two on GCP, disk and @@ -474,7 +509,7 @@ The cloud says "running" long before a session is usable, so pier doesn't: - A session lists as `creating` until the bootstrap's last act marks the instance ready (a tag on AWS, a label on GCP). - Attaching early gets a plain "still setting up", not a raw connection error. -- TUI creates run in the background and the row flips when ready. +- Creates from the app run in the background and the row flips when ready. - A deleted session lists as `deleting` until the cloud actually removes it. GCE takes a minute there and would otherwise read as parked. - A create that fails cleans up its own instance. @@ -516,7 +551,7 @@ or pay for. ``` laptop AWS account (yours) ────── ─────────────────── -pier CLI/TUI ── aws cli ──────────▶ EC2 API (create/stop/start/tags) +pier CLI/app ── aws cli ──────────▶ EC2 API (create/stop/start/tags) │ ┌─────────────────────────┐ └── ssh ─────────────────────▶ │ session VM │ (direct to its public IP, │ tmux ▸ claude / codex │ @@ -583,7 +618,10 @@ The choices contributors should know before proposing changes reviewable, applied atomically after checkout. - **Truth over optimism in states.** The ready tag, the setup status file, the strained flag: pier reports what is, not what the cloud claims. -- **`pier ls` stays plain** (pipeable). The TUI is the pretty view. +- **`pier ls` stays plain** (pipeable). The app is the pretty view. +- **One API, many frontends.** Capabilities live in `pkg/pier`, which never + prints, exits or reads stdin; the CLI, the app and the Mac app render what + it returns. ## vs. other tools diff --git a/docs/SPEC.md b/docs/SPEC.md index 4799bd5..79ad4c7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -391,8 +391,10 @@ protected by the repository's `.gitignore`. files it is *not* carrying so a missing one fails loud at create, not deep in `make dev`. -4. **Warm pools (opt-in, per repo)** — `pier pool set ` (or the TUI's - w page) keeps N parked, **setup-complete** members ready; `pier ` +4. **Ready sessions (per repo; formerly warm pools)** — baked repos keep + `speed.ready_sessions` (default 1, the Fast profile) parked, + **setup-complete** members ready, overridable per repo with + `pier ready ` or the app's Repos tab; `pier ` then claims one — resume + freshen (secrets re-push, branch at base, dirty patch, supervisor conf reset, async setup re-run for drift) instead of create + boot + full setup — and refills the pool detached in the @@ -476,13 +478,19 @@ minutes: IAM rights. `pier teardown` reverses it. 4. **Doctor** — quota headroom, connectivity, plugin; writes `~/.config/pier/config.toml`; prints `cd && pier `. -5. **Skills** — one confirm per detected agent (claude, codex — same - SKILL.md format), then the bundled pier-onboard skill (embedded in the - binary) is installed/refreshed under `~/./skills`. A dir - confirmed here postdates manifest detection, so it's appended to the - session manifest — only when the manifest was accepted at all. Runs - before the bake offer so no question hides behind the build. - `pier skills` is the same install standalone, no questions. +5. **Skills** — the pier-onboard skill (embedded in the binary) is one row + of the confirmed defaults block: accepting installs it for every + detected agent (claude, codex — same SKILL.md format) under + `~/./skills`; editing asks per agent. An installed dir is + appended to the session manifest — only when the manifest was accepted + at all. `pier setup --skills` is the same install standalone. + +**Revised flow (2026-09).** Setup now asks only for the cloud and account, +then shows every default in one block — machine, disk, park-after, runaway +cap, speed profile, copied-in config, the skill — with its cost, for enter +(accept) or `e` (walk each). Nothing is applied silently. Inside a repo it +ends by offering the repo's bake, and always by pointing at the app's +settings page (`pier`, then `s`). Second dev on a prepared account: detect finds groundwork (`Existed`), creates nothing, done in ~90s. @@ -496,8 +504,14 @@ shows headroom (e.g. `12/32 vCPU`). ## 11. CLI +Every frontend — this CLI, the app (`pier` with no arguments), and the +coming Mac app — runs on `pkg/pier`, which owns the capabilities and never +prints, exits or reads stdin. The Mac app will reach it through a +`pier api` JSON-RPC server over stdio (the LSP model: a child process, no +daemon, no port). + ``` -pier TUI: list / attach / new / delete / pin (new = background create, +pier the app: Sessions · Repos · Settings tabs (new = background create, listed as "creating" until the create writes its ready tag) pier [base] create from cwd repo (branch off base, default HEAD) and attach pier ls list own sessions @@ -511,13 +525,11 @@ pier port

[p...] manual port forwards, zero-sudo any-OS fallback (3 pier rm destroy (instance + disk) pier keep disable auto-park for a session pier resize change VM size (running: park→modify→resume; same arch) -pier pool warm pool status + cost (TUI: the w page) -pier pool set keep warm sessions ready for the cwd repo (0 = off) -pier pool fill [--detach] top the cwd repo's pool up to size now -pier pool drain [repo] destroy a repo's warm members -pier setup wizard (--print-admin for the no-IAM-rights path) -pier skills install/refresh the bundled agent skills standalone -pier bake build/refresh the prebaked image +pier repos [--json] every repo: session image, ready sessions, idle cost, reminders +pier ready [n] show / set the cwd repo's ready sessions (0 = off, removes them) +pier setup wizard (--print-admin for the no-IAM-rights path, + --skills to refresh the bundled agent skill only) +pier bake build/refresh the repo's session image (--toolchain-only) pier doctor checks pier teardown remove account groundwork ``` @@ -548,10 +560,19 @@ disk_gib = 40 manifest = [".codex/auth.json", ".codex/config.toml", ".claude/settings.json", ".claude/CLAUDE.md"] # claude_oauth_token = "..." # from `claude setup-token` (macOS Keychain path) -[pool] # warm pools (§7.4); strictly opt-in +[speed] # what a speed profile presets (fast/lean/minimal) +ready_sessions = 1 # per baked repo; parked, disk-only cost +bake_reminders = true # suggest `pier bake`; never automatic +image_repo = true # bake prebuilds the repo (false = toolchains only) +reminder_age = "30d" # remind when an image gets this old + +[pool] # ready sessions (§7.4) # max_age = "14d" # member recycle age -# [pool.sizes] # written by `pier pool set`, keyed by repo +# [pool.sizes] # per-repo overrides, written by `pier ready` # shop = 2 + +# [images.] # written by `pier bake`: baked_at, setup_sha, +# # bake_sha, repo_included — feeds the reminders ``` ## 13. v1 cut line diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index 78db4ea..574b03b 100644 --- a/skills/pier-onboard/SKILL.md +++ b/skills/pier-onboard/SKILL.md @@ -77,7 +77,7 @@ fail** — start with `set -euo pipefail`, don't swallow errors. **It must be safe to re-run on a warm disk.** `pier bake` runs it once on a checkout and images the result. Every session from that image, and every -warm pool claim, runs it again over the dependencies, build caches and +ready-session claim, runs it again over the dependencies, build caches and volumes the earlier run left. Use installs that are incremental when nothing changed (`pnpm install`, `poetry sync`, `docker compose build`, `docker compose up -d`), and never fail on "already exists". @@ -138,7 +138,8 @@ committed (but a new, not-yet-committed `.pier/setup.sh` still travels). watch `pier ls` — `(setup running)` should clear; if it shows `(setup failed)`, attach and read `~/.pier-setup.log`. - If `.pier/setup.sh` is heavy (long installs, docker pulls, migrations), - mention `pier pool set `: it keeps n parked, setup-complete sessions - ready to claim, so new sessions skip the wait entirely (~$3-4/mo per - warm member, disk only). Changing `.pier/setup.sh` later is safe — warm - members notice and recycle on the next claim or fill. + mention ready sessions: once the repo is baked, pier keeps parked, + setup-complete sessions to claim (one by default; `pier ready ` or the + app's Repos tab changes it), so new sessions skip the wait (~$3-4/mo each, + disk only). Changing `.pier/setup.sh` later is safe — ready sessions + notice and recycle on the next claim or refill. From fededd5f4200b5c3fb1ffda71ac017dac722444c Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 14:06:53 +0200 Subject: [PATCH 06/24] perf(aws): run independent pre-launch lookups concurrently --- internal/driver/awsec2/create.go | 42 +++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/internal/driver/awsec2/create.go b/internal/driver/awsec2/create.go index 59a16af..b52a156 100644 --- a/internal/driver/awsec2/create.go +++ b/internal/driver/awsec2/create.go @@ -28,11 +28,18 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv if err := payload.ValidateNames(spec); err != nil { return nil, err } - me, err := d.user(ctx) - if err != nil { - return nil, err - } - + // Each AWS CLI call costs ~0.4s of process startup and a round trip, and + // nothing here waits on the caller identity: look it up alongside the + // arch → AMI chain instead of before it. + type ident struct { + arn string + err error + } + identity := make(chan ident, 1) + go func() { + arn, err := d.user(ctx) + identity <- ident{arn, err} + }() arch, err := d.archOf(ctx, d.InstanceType) if err != nil { return nil, err @@ -45,6 +52,11 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv if err != nil { return nil, err } + id0 := <-identity + if id0.err != nil { + return nil, id0.err + } + me := id0.arn // The keypair is instance-id-keyed but must exist pre-launch (pubkey goes // into user-data), so generate under a temp name and rename after launch. @@ -191,18 +203,30 @@ func (d *Driver) push(ctx context.Context, id, local, remote string, progress fu // launch retries on IAM instance-profile propagation lag (the spike showed // one retry is usually needed right after `pier setup`). func (d *Driver) launch(ctx context.Context, spec driver.CreateSpec, me, ami, udPath string) (string, error) { + // The security group (two calls) and the image's root device are + // independent lookups: run them side by side. + type lookup struct { + val string + err error + } + root := make(chan lookup, 1) + go func() { + dev, err := d.aws(ctx, "ec2", "describe-images", "--image-ids", ami, + "--query", "Images[0].RootDeviceName", "--output", "text") + root <- lookup{dev, err} + }() sg, err := d.securityGroupID(ctx) + rd := <-root if err != nil { return "", err } if sg == "" { return "", fmt.Errorf("security group %s not found — run `pier setup`", SecurityGroup) } - rootDev, err := d.aws(ctx, "ec2", "describe-images", "--image-ids", ami, - "--query", "Images[0].RootDeviceName", "--output", "text") - if err != nil { - return "", err + if rd.err != nil { + return "", rd.err } + rootDev := rd.val tags, _ := json.Marshal([]map[string]any{ {"ResourceType": "instance", "Tags": tagList(spec, me, "pier-"+spec.Name)}, From 83fbe3044a3f74e029be47450b380224c6ed422f Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 17:19:42 +0200 Subject: [PATCH 07/24] fix(bake): wait out slow image snapshots instead of the cli's ten minutes --- internal/driver/awsec2/bake.go | 49 ++++++++++++++++++++++++++- internal/driver/awsec2/create_test.go | 27 +++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/driver/awsec2/bake.go b/internal/driver/awsec2/bake.go index 9204444..017c97c 100644 --- a/internal/driver/awsec2/bake.go +++ b/internal/driver/awsec2/bake.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/usepier/pier/internal/driver" @@ -131,7 +132,7 @@ exit 1` if err != nil { return "", err } - if _, err := d.aws(ctx, "ec2", "wait", "image-available", "--image-ids", img); err != nil { + if err := d.waitImage(ctx, img, spec.Step); err != nil { return "", err } @@ -142,3 +143,49 @@ exit 1` } return img, nil } + +// imageWait bounds how long a bake waits for its image. A prebuilt image +// carries the repo's dependencies and container images, and snapshotting that +// routinely outlasts the CLI waiter's fixed ten minutes — which used to fail a +// bake whose image was still on its way to being fine. +var imageWait, imagePoll = 90 * time.Minute, 20 * time.Second + +// waitImage polls the image until it's available, reporting the snapshot's +// progress about once a minute so a long wait doesn't read as a hang. +func (d *Driver) waitImage(ctx context.Context, img string, step func(string)) error { + deadline := time.Now().Add(imageWait) + lastNote := time.Now() + for { + out, err := d.aws(ctx, "ec2", "describe-images", "--image-ids", img, + "--query", "Images[0].[State,BlockDeviceMappings[0].Ebs.SnapshotId]", "--output", "text") + if err != nil { + return err + } + f := strings.Fields(out) + state := "" + if len(f) > 0 { + state = f[0] + } + switch state { + case "available": + return nil + case "failed", "invalid", "deregistered", "error": + return fmt.Errorf("image %s ended %s", img, state) + } + if time.Now().After(deadline) { + return fmt.Errorf("image %s still %s after %s — it may yet finish; check it in the EC2 console", img, state, imageWait) + } + if len(f) > 1 && f[1] != "None" && time.Since(lastNote) >= time.Minute { + if pct, err := d.aws(ctx, "ec2", "describe-snapshots", "--snapshot-ids", f[1], + "--query", "Snapshots[0].Progress", "--output", "text"); err == nil { + step("snapshot " + pct) + } + lastNote = time.Now() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(imagePoll): + } + } +} diff --git a/internal/driver/awsec2/create_test.go b/internal/driver/awsec2/create_test.go index 9147e59..b26b003 100644 --- a/internal/driver/awsec2/create_test.go +++ b/internal/driver/awsec2/create_test.go @@ -212,3 +212,30 @@ func TestLaunchVolumeInitialization(t *testing.T) { } }) } + +// A prebuilt image's snapshot routinely takes longer than the CLI waiter's +// fixed ten minutes; the bake must keep waiting (and say how far along it +// is) instead of failing an image that is on its way to being fine. +func TestWaitImageOutlastsSlowSnapshots(t *testing.T) { + swap(t, &imagePoll, time.Millisecond) + bin := t.TempDir() + count := filepath.Join(bin, "n") + script := "#!/bin/sh\ncase \"$2\" in\n" + + "describe-images) n=$(cat " + count + " 2>/dev/null || echo 0); n=$((n+1)); echo $n > " + count + "\n" + + " if [ $n -ge 3 ]; then echo 'available snap-1'; elif [ \"$FAIL\" = 1 ]; then echo 'failed snap-1'; else echo 'pending snap-1'; fi ;;\n" + + "describe-snapshots) echo '42%' ;;\nesac\n" + if err := os.WriteFile(filepath.Join(bin, "aws"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+":"+os.Getenv("PATH")) + d := &Driver{} + if err := d.waitImage(context.Background(), "ami-1", func(string) {}); err != nil { + t.Fatalf("a pending image must be waited out, got %v", err) + } + + os.Remove(count) + t.Setenv("FAIL", "1") + if err := d.waitImage(context.Background(), "ami-1", func(string) {}); err == nil || !strings.Contains(err.Error(), "failed") { + t.Fatalf("a failed image must stop the wait with its state, got %v", err) + } +} From d273ecbd346461da67dee045f4de63b2bf54dc3f Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 17:32:19 +0200 Subject: [PATCH 08/24] fix(cli): say just baked for a fresh image --- cmd/pier/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 9074650..57f64dc 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -816,7 +816,10 @@ func imageLabel(r pier.Repo) string { if !r.Baked.RepoIncluded { kind = "toolchains" } - return kind + ", " + pier.Age(r.Baked.BakedAt, time.Now()) + " old" + if a := pier.Age(r.Baked.BakedAt, time.Now()); a != "now" { + return kind + ", " + a + " old" + } + return kind + ", just baked" } func readyLabel(r pier.Repo) string { From 438deacfddfa06cbf1acca5751b4064b9084b7e5 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 18:48:53 +0200 Subject: [PATCH 09/24] fix(supervisor): restore claude panes as claude, even with nothing to resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane whose root process is the program itself (tmux ran claude directly) was saved as the first child in its process group — one of claude's MCP servers — so a wake typed that command back instead of relaunching claude. Children are only searched when the pane's root is a shell. A claude or codex pane with no conversation to resume made --continue exit with an error and left a bare shell; the relaunch now falls back to a fresh agent. --- cmd/pier-supervisor/tmux.go | 27 ++++++++--- cmd/pier-supervisor/tmux_test.go | 79 +++++++++++++++++++++++++++++--- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/cmd/pier-supervisor/tmux.go b/cmd/pier-supervisor/tmux.go index f8a812a..ece0b00 100644 --- a/cmd/pier-supervisor/tmux.go +++ b/cmd/pier-supervisor/tmux.go @@ -250,7 +250,7 @@ func writeAtomic(path string, data []byte) error { // started from a startup file, before job control is up, shares the shell's // group — so a child in the same group wins over the shell. func foreground(pid int) []string { - stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + stat, err := readProc(fmt.Sprintf("/proc/%d/stat", pid)) if err != nil { return nil } @@ -264,10 +264,18 @@ func foreground(pid int) []string { } return procArgv(pid) } - kids, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) + // Only a shell hides its program among its children. A pane whose root + // is the program itself (tmux ran `claude` directly) must not be + // mistaken for one of that program's own children — claude's MCP + // servers share its group, and the first of them used to win. + root := procArgv(pid) + if len(root) == 0 || !shells[strings.TrimPrefix(filepath.Base(root[0]), "-")] { + return root + } + kids, _ := readProc(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) for _, k := range strings.Fields(string(kids)) { kid, _ := strconv.Atoi(k) - ks, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", kid)) + ks, err := readProc(fmt.Sprintf("/proc/%d/stat", kid)) if err != nil { continue } @@ -296,8 +304,11 @@ func parseStat(s string) (pgrp, tpgid int, ok bool) { return pgrp, tpgid, err1 == nil && err2 == nil } +// readProc reads a /proc file; a var so tests can stand in a process table. +var readProc = os.ReadFile + func procArgv(pid int) []string { - b, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + b, err := readProc(fmt.Sprintf("/proc/%d/cmdline", pid)) if err != nil || len(b) == 0 { return nil } @@ -444,13 +455,17 @@ func launchCommand(p savedPane) string { if len(p.Argv) > 0 { _, args = program(p.Argv) } + kept := claudeKeptFlags(args) cmd := []string{"claude", "--continue"} if p.Resume != "" { cmd = []string{"claude", "--resume", p.Resume} } - return commandLine(append(cmd, claudeKeptFlags(args)...)) + // A pane where claude sat unused has no conversation to resume, and + // `--continue` then exits with an error; fall back to a fresh claude + // so the pane comes back as it was — claude, ready. + return commandLine(append(cmd, kept...)) + " || " + commandLine(append([]string{"claude"}, kept...)) case kindCodex: - return "codex resume --last" + return "codex resume --last || codex" } return "" } diff --git a/cmd/pier-supervisor/tmux_test.go b/cmd/pier-supervisor/tmux_test.go index f0a0915..03ea538 100644 --- a/cmd/pier-supervisor/tmux_test.go +++ b/cmd/pier-supervisor/tmux_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "reflect" "runtime" + "strconv" "strings" "testing" "time" @@ -62,14 +63,14 @@ func TestLaunchCommand(t *testing.T) { pane savedPane want string }{ - {savedPane{Kind: kindClaude, Argv: []string{"claude"}}, "claude --continue"}, - {savedPane{Kind: kindClaude, Argv: []string{"claude", "-r", convID}, Resume: convID}, "claude --resume " + convID}, + {savedPane{Kind: kindClaude, Argv: []string{"claude"}}, "claude --continue || claude"}, + {savedPane{Kind: kindClaude, Argv: []string{"claude", "-r", convID}, Resume: convID}, "claude --resume " + convID + " || claude"}, // Flags that shape how the agent runs survive; a positional prompt // must not, or the relaunch would send it again. {savedPane{Kind: kindClaude, Argv: []string{"claude", "--dangerously-skip-permissions", "--model", "opus", "fix the login bug"}}, - "claude --continue --dangerously-skip-permissions --model opus"}, - {savedPane{Kind: kindClaude, Argv: []string{"node", "/x/claude-code/cli.js", "--permission-mode=plan"}}, "claude --continue --permission-mode=plan"}, - {savedPane{Kind: kindCodex, Argv: []string{"codex", "do the thing"}}, "codex resume --last"}, + "claude --continue --dangerously-skip-permissions --model opus || claude --dangerously-skip-permissions --model opus"}, + {savedPane{Kind: kindClaude, Argv: []string{"node", "/x/claude-code/cli.js", "--permission-mode=plan"}}, "claude --continue --permission-mode=plan || claude --permission-mode=plan"}, + {savedPane{Kind: kindCodex, Argv: []string{"codex", "do the thing"}}, "codex resume --last || codex"}, {savedPane{Kind: kindOther, Argv: []string{"pnpm", "dev"}}, ""}, {savedPane{Kind: kindShell}, ""}, {savedPane{Kind: kindSetup}, ""}, @@ -185,12 +186,12 @@ func TestBuildRestorePlan(t *testing.T) { }}, }} const rc = " bash --rcfile /home/agent/.pier/tmux/restore.rc -i" - claude := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.0.txt 'PIER_RESTORE_RUN=claude --resume " + convID + " --dangerously-skip-permissions'" + rc + claude := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.0.txt 'PIER_RESTORE_RUN=claude --resume " + convID + " --dangerously-skip-permissions || claude --dangerously-skip-permissions'" + rc // The dev server does not run on restore: it is typed at the prompt. dev := "env PIER_RESTORE_CWD=/home/agent/work/shop/web PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.1.txt" + rc // Setup never re-runs: a shell with its scrollback. setup := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.1.0.txt" + rc - codex := "env PIER_RESTORE_CWD=/home/agent 'PIER_RESTORE_RUN=codex resume --last'" + rc + codex := "env PIER_RESTORE_CWD=/home/agent 'PIER_RESTORE_RUN=codex resume --last || codex'" + rc sock := "SSH_AUTH_SOCK=/home/agent/.ssh/agent.sock" want := []planStep{ @@ -340,3 +341,67 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) { time.Sleep(100 * time.Millisecond) } } + +// fakeProc stands in a /proc tree: pid → (comm pgrp tpgid argv children). +func fakeProc(t *testing.T, procs map[int]struct { + pgrp, tpgid int + argv []string + kids []int +}) { + t.Helper() + old := readProc + t.Cleanup(func() { readProc = old }) + readProc = func(path string) ([]byte, error) { + var pid int + var file string + if _, err := fmt.Sscanf(path, "/proc/%d/%s", &pid, &file); err != nil { + return nil, os.ErrNotExist + } + p, ok := procs[pid] + if !ok { + return nil, os.ErrNotExist + } + switch { + case file == "stat": + return []byte(fmt.Sprintf("%d (x) S 1 %d %d 0 %d 0", pid, p.pgrp, p.pgrp, p.tpgid)), nil + case file == "cmdline": + return []byte(strings.Join(p.argv, "\x00") + "\x00"), nil + case strings.HasSuffix(file, "children"): + var ks []string + for _, k := range p.kids { + ks = append(ks, strconv.Itoa(k)) + } + return []byte(strings.Join(ks, " ")), nil + } + return nil, os.ErrNotExist + } +} + +// A pane whose root is the program itself keeps its identity: claude's MCP +// servers share its process group, and the first of them used to be saved +// as the pane's command — so a wake typed `npm exec …` instead of resuming +// the conversation. +func TestForegroundKeepsAProgramRootOverItsChildren(t *testing.T) { + type proc = struct { + pgrp, tpgid int + argv []string + kids []int + } + fakeProc(t, map[int]proc{ + 10: {10, 10, []string{"claude"}, []int{11}}, + 11: {10, 10, []string{"npm exec godspeed-mcp"}, nil}, + }) + if got := foreground(10); len(got) != 1 || got[0] != "claude" { + t.Errorf("want claude, got %q", got) + } + + // A shell still gives way to the program running inside it. + fakeProc(t, map[int]proc{ + 20: {20, 20, []string{"bash", "-c", "claude"}, []int{21}}, + 21: {20, 20, []string{"claude"}, []int{22}}, + 22: {20, 20, []string{"npm exec some-mcp"}, nil}, + }) + if got := foreground(20); len(got) != 1 || got[0] != "claude" { + t.Errorf("want the shell's program, got %q", got) + } +} From d906168bd7880eaf84af5913adef2717d3bdcd41 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 20:47:45 +0200 Subject: [PATCH 10/24] feat: bring built environments up with .pier/start.sh, including on wake setup.sh builds an environment; start.sh brings a built one up, the way a laptop runs `docker compose up` after a reboot rather than a fresh install. Sessions from a prebuilt image and ready-session claims now run start.sh when the repo has one, instead of re-running the whole setup, and a parked session's boot runs it after the tmux restore, so it wakes with its services running. All three go through one session-up script the bootstrap writes (modes setup, warm, wake); parked ready sessions skip the wake start because their claim runs it. Restore no longer brings back pier's own setup and start windows: their run is over, `pier logs` keeps the output, and a wake starts a fresh one. The shell rc only moves a login into the repo when it starts in $HOME, so new tmux windows and splits keep the folder they were opened in; older images get the line rewritten at first boot. --- README.md | 14 +++++ cmd/pier-supervisor/tmux.go | 49 +++++++++++++++- cmd/pier-supervisor/tmux_test.go | 24 +++++++- docs/SPEC.md | 12 ++++ internal/driver/payload/freshen.go | 4 +- internal/driver/payload/payload_test.go | 10 ++-- internal/driver/payload/prebuild.go | 2 +- internal/driver/payload/prebuild_test.go | 52 +++++++++++++++++ internal/driver/payload/scripts.go | 71 ++++++++++++++++-------- internal/pool/claim.go | 2 +- internal/pool/fill.go | 6 ++ skills/pier-onboard/SKILL.md | 38 ++++++++++++- 12 files changed, 244 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 511519c..715e51d 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ Pier uses one committed `.pier/` directory with three optional files: | File | Runs / read | Contains | |---|---|---| | `.pier/setup.sh` | Once at `pier bake` (prebuild), then every session's first boot, async, in a `setup` tmux window | Repo state: deps, services, migrations, seeds (must be safe to re-run) | +| `.pier/start.sh` | Instead of setup.sh when the environment is already built (image sessions, ready-session claims), and on every wake | Bring it up: quick incremental installs, start services | | `.pier/include` | At create | Ignored files to carry (env files, local certs) | | `.pier/bake.sh` | Once, during `pier bake` | Toolchains beyond the default image (pnpm, python, rust, ...) | @@ -291,6 +292,19 @@ docker compose up -d pnpm db:migrate ``` +`.pier/setup.sh` builds the environment; `.pier/start.sh` brings a built one +up, the way a laptop runs `docker compose up` after a reboot rather than a +fresh install. Sessions from the repo's image, claimed ready sessions, and +parked sessions waking up run start.sh when the repo has one, so it decides +how fast services are back. Without it, those sessions re-run setup.sh. + +```bash +# .pier/start.sh (never reset or reseed here: data survives parking) +set -euo pipefail +pnpm install +docker compose up -d +``` + Prefer `docker compose up -d` for services. A bare background process must fully detach with `setsid cmd log 2>&1 &` or it dies when the setup window closes. diff --git a/cmd/pier-supervisor/tmux.go b/cmd/pier-supervisor/tmux.go index ece0b00..f62a9b9 100644 --- a/cmd/pier-supervisor/tmux.go +++ b/cmd/pier-supervisor/tmux.go @@ -36,7 +36,7 @@ const ( const ( kindClaude = "agent-claude" // relaunched on its conversation kindCodex = "agent-codex" // relaunched on its most recent conversation - kindSetup = "setup" // plain shell with scrollback; setup never re-runs + kindSetup = "setup" // pier's own setup/start window: not restored; a wake starts afresh kindShell = "shell" // login shell in the saved cwd kindOther = "other" // shell with the old command typed, not run ) @@ -329,7 +329,11 @@ var ( // and foreground argv. For claude it also returns the conversation id the // command line resumed or pinned, if any. func classifyPane(window string, argv []string) (kind, resume string) { - if window == "setup" || window == "setup-failed" || strings.Contains(strings.Join(argv, " "), ".pier-setup") { + switch window { + case "setup", "setup-failed", "start", "start-failed": + return kindSetup, "" + } + if strings.Contains(strings.Join(argv, " "), ".pier-setup") { return kindSetup, "" } if len(argv) == 0 || isShell(argv) { @@ -582,6 +586,15 @@ func layoutSize(layout string) (w, h string, ok bool) { // captured at, so splits have room and select-layout reproduces the geometry // before a client ever attaches. Panes are split off the newest pane, which // keeps their indices in snapshot order for select-layout and targeting. +func allSetup(panes []savedPane) bool { + for _, p := range panes { + if p.Kind != kindSetup { + return false + } + } + return true +} + func buildRestorePlan(st tmuxState, home string) []planStep { dir := stateDir(home) sessions := make([]savedSession, 0, len(st.Sessions)) @@ -598,7 +611,11 @@ func buildRestorePlan(st tmuxState, home string) []planStep { var typed []planStep first, activeWin := true, "" for _, w := range s.Windows { - if len(w.Panes) == 0 { + // pier's own setup/start windows are logs of a run that's over — + // `pier logs` keeps the output and the status file keeps the + // outcome — and a wake starts a fresh one. Bringing them back + // only stacks stale windows beside the new one. + if len(w.Panes) == 0 || allSetup(w.Panes) { continue } win := fmt.Sprintf("%s:%d", s.Name, w.Index) @@ -669,9 +686,35 @@ func restoreMain() int { return 0 } fmt.Println("pier-restore:", restoreTmux(defaultTmux, home)) + if cmd := wakeStart(home); cmd != nil { + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("pier-restore: start.sh window: %v %s\n", err, strings.TrimSpace(string(out))) + } else { + fmt.Println("pier-restore: started .pier/start.sh (if the repo has one) to bring services back up") + } + } return 0 } +// wakeStart is the command that brings a woken session's environment back +// up — the repo's .pier/start.sh, in its own tmux window, via the +// session-up script the bootstrap left — or nil when this boot isn't a +// session's wake: before the bootstrap has run (a first boot, or a boot from +// a baked image), or on a ready session, whose claim runs start itself. +func wakeStart(home string) *exec.Cmd { + up := filepath.Join(home, ".pier", "session-up.sh") + for _, need := range []string{filepath.Join(home, ".pier-bootstrapped"), up} { + if _, err := os.Stat(need); err != nil { + return nil + } + } + if _, err := os.Stat(filepath.Join(home, ".pier", "no-wake-start")); err == nil { + return nil + } + return exec.Command("bash", up, "wake") +} + // restoreTmux rebuilds the saved layout unless a tmux server already runs // (someone beat us to it; never stack a second layout onto theirs) or there // is nothing saved. It returns the journal line describing what happened. diff --git a/cmd/pier-supervisor/tmux_test.go b/cmd/pier-supervisor/tmux_test.go index 03ea538..cb51a35 100644 --- a/cmd/pier-supervisor/tmux_test.go +++ b/cmd/pier-supervisor/tmux_test.go @@ -189,8 +189,6 @@ func TestBuildRestorePlan(t *testing.T) { claude := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.0.txt 'PIER_RESTORE_RUN=claude --resume " + convID + " --dangerously-skip-permissions || claude --dangerously-skip-permissions'" + rc // The dev server does not run on restore: it is typed at the prompt. dev := "env PIER_RESTORE_CWD=/home/agent/work/shop/web PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.0.1.txt" + rc - // Setup never re-runs: a shell with its scrollback. - setup := "env PIER_RESTORE_CWD=/home/agent/work/shop PIER_RESTORE_SCROLLBACK=/home/agent/.pier/tmux/main.1.0.txt" + rc codex := "env PIER_RESTORE_CWD=/home/agent 'PIER_RESTORE_RUN=codex resume --last || codex'" + rc sock := "SSH_AUTH_SOCK=/home/agent/.ssh/agent.sock" @@ -200,7 +198,8 @@ func TestBuildRestorePlan(t *testing.T) { {Args: []string{"split-window", "-t", "main:0", "-c", "/home/agent/work/shop/web", dev}}, {Args: []string{"select-layout", "-t", "main:0", "b25f,200x50,0,0{100x50,0,0,0,99x50,101,0,1}"}}, {Args: []string{"select-pane", "-t", "main:0.0"}}, - {Args: []string{"new-window", "-d", "-t", "main:1", "-n", "setup", "-c", "/home/agent/work/shop", setup}}, + // pier's setup window is not restored: its run is over, and a wake + // starts a fresh one. {Args: []string{"select-window", "-t", "main:0"}}, {Args: []string{"send-keys", "-t", "main:0.1", "-l", "pnpm dev"}, AwaitPrompt: "main:0.1"}, // automatic-rename windows stay unnamed so they keep following their program. @@ -405,3 +404,22 @@ func TestForegroundKeepsAProgramRootOverItsChildren(t *testing.T) { t.Errorf("want the shell's program, got %q", got) } } + +// A boot brings services back only for a session that has been bootstrapped +// and isn't a ready session awaiting its claim. +func TestWakeStartOnlyForBootstrappedSessions(t *testing.T) { + home := t.TempDir() + os.MkdirAll(filepath.Join(home, ".pier"), 0o755) + if wakeStart(home) != nil { + t.Error("a first boot (nothing bootstrapped) must not start anything") + } + os.WriteFile(filepath.Join(home, ".pier-bootstrapped"), nil, 0o644) + os.WriteFile(filepath.Join(home, ".pier", "session-up.sh"), []byte("true"), 0o644) + if wakeStart(home) == nil { + t.Error("a bootstrapped session's wake must bring the environment up") + } + os.WriteFile(filepath.Join(home, ".pier", "no-wake-start"), nil, 0o644) + if wakeStart(home) != nil { + t.Error("a ready session's wake is its claim, which starts things itself") + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 79ad4c7..6d8d0ef 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -340,6 +340,18 @@ protected by the repository's `.gitignore`. warm disk. Freshness is manual (`pier bake` again); there is no control plane to schedule rebakes. + **setup vs start (2026-09).** Measured on the same monorepo, a fully warm + re-run of `.pier/setup.sh` still took 197s — its own reset-and-reseed and + stack start, on every session. A laptop runs install once and `up` after + each reboot; pier now has the same split. `.pier/start.sh` (optional) + runs instead of setup when the environment is already built — a + prebuilt-image session (the bootstrap found a checkout), a ready-session + claim — and on every wake: pier-restore.service runs it after rebuilding + the tmux layout, so a parked session comes back with its services + running. All three go through one script the bootstrap writes to + `~/.pier/session-up.sh` (modes setup|warm|wake). Ready sessions carry + `~/.pier/no-wake-start` while parked, since their claim runs start itself. + EBS restores snapshots lazily from S3, and a prebuilt image is gigabytes. AWS launches from a baked image therefore set `VolumeInitializationRate` (300 MiB/s, billed per GiB of snapshot data) on the root mapping. A CLI diff --git a/internal/driver/payload/freshen.go b/internal/driver/payload/freshen.go index c3dc8a4..2acb000 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -17,7 +17,7 @@ import ( // restore of the member's own fill-time layout, which the script discards) // after the same cargo pushes as create, minus supervisor + user-data. -const freshenTmpl = `#!/usr/bin/env bash +var freshenTmpl = `#!/usr/bin/env bash # pier freshen — runs at claim, as agent, on a just-resumed warm pool member. set -euo pipefail @@ -62,7 +62,7 @@ tar -xf /tmp/pier-files.tar -C . --strip-components=1 repo 2>/dev/null || true # Cosmetic: prompts show the session, not the pool placeholder. sudo hostnamectl set-hostname '{{HOSTNAME}}' 2>/dev/null || true -` + tmuxEnsure + setupWindow + ` +` + sessionUp("warm") + ` rm -f /tmp/pier.bundle /tmp/pier-files.tar /tmp/pier-dirty.patch /tmp/pier-freshen.sh echo freshened ` diff --git a/internal/driver/payload/payload_test.go b/internal/driver/payload/payload_test.go index 8919f3e..285aa44 100644 --- a/internal/driver/payload/payload_test.go +++ b/internal/driver/payload/payload_test.go @@ -163,24 +163,24 @@ func TestRenderBootstrapModes(t *testing.T) { `echo running > ~/.pier-setup.status`, // Runs on presence via bash: a 0644 .pier/setup.sh must not skip // silently. - `if [ -f "$setup" ]; then`, - `bash $setup 2>&1`, + `[ -f "$script" ] || exit 0`, + `bash $script 2>&1`, // The cloud-init wait must guard on binaries the stock image LACKS // (Ubuntu ships git/tmux, so those never triggered the wait and // setup raced the installs it depends on). `command -v docker >/dev/null && command -v node >/dev/null && command -v claude >/dev/null || sudo cloud-init status --wait`, `c=\${PIPESTATUS[0]}`, - `pier setup: FAILED (exit \$c)`, + `pier $name: FAILED (exit \$c)`, // The failure rename must target its own pane: a bare rename-window // resolved to the attached client's current window and mislabeled // the user's shell as setup-failed. - `tmux rename-window -t \$TMUX_PANE setup-failed`, + `tmux rename-window -t \$TMUX_PANE $name-failed`, // The tmux server must start through sudo, which re-runs initgroups: // the bootstrap's own login predates cloud-init's usermod -aG docker // on stock images, and every window inherits groups from the server // (docker.sock denied in .pier/setup.sh otherwise). `sudo -u agent tmux new-session -d -s main`, - `setup=./.pier/setup.sh`, + `script=./.pier/setup.sh`, // Restore-on-wake: a oneshot that leaves the tmux server it starts // running (it lives in the unit's cgroup), enabled for every boot. `ExecStart=/usr/local/bin/pier-supervisor restore`, diff --git a/internal/driver/payload/prebuild.go b/internal/driver/payload/prebuild.go index 92f53f8..4605abf 100644 --- a/internal/driver/payload/prebuild.go +++ b/internal/driver/payload/prebuild.go @@ -206,7 +206,7 @@ git reset -q --hard # and first-boot state a session must create for itself — the supervisor's # tmux snapshot included, or a session booted from this image would wake into # the bake instance's windows and scrollback. -rm -rf ~/.config/pier ~/.pier-setup.d ~/.pier/tmux +rm -rf ~/.config/pier ~/.pier-setup.d ~/.pier/session-up.sh ~/.pier/no-wake-start ~/.pier/tmux rm -f ~/.pier-bootstrapped ~/.pier-setup.status ~/.pier-setup.log ~/.bash_history \ ~/.config/gh/hosts.yml ~/.docker/config.json ~/.git-credentials ~/.ssh/agent.sock rm -f /tmp/pier-scrub.sh diff --git a/internal/driver/payload/prebuild_test.go b/internal/driver/payload/prebuild_test.go index 182f1f8..d7fc10e 100644 --- a/internal/driver/payload/prebuild_test.go +++ b/internal/driver/payload/prebuild_test.go @@ -180,3 +180,55 @@ func TestRenderBootstrapReusesPrebuiltCheckout(t *testing.T) { t.Error("a baked create must not claim to wait on a stock image's cloud-init") } } + +// session-up picks the script by mode: a fresh environment builds with +// setup.sh; a warm one (prebuilt image, claimed ready session) only needs +// start.sh when the repo has one; a wake runs start.sh or nothing. +func TestSessionUpPicksTheScriptByMode(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + script := strings.NewReplacer("{{REPO}}", "r").Replace(sessionUpTmpl) + run := func(t *testing.T, mode string, withStart bool) string { + t.Helper() + home := t.TempDir() + repo := filepath.Join(home, "work", "r", ".pier") + os.MkdirAll(repo, 0o755) + os.WriteFile(filepath.Join(repo, "setup.sh"), []byte("true"), 0o644) + if withStart { + os.WriteFile(filepath.Join(repo, "start.sh"), []byte("true"), 0o644) + } + stubs := t.TempDir() + log := filepath.Join(stubs, "calls") + for _, name := range []string{"tmux", "sudo"} { + os.WriteFile(filepath.Join(stubs, name), []byte("#!/bin/sh\necho "+name+" \"$@\" >> "+log+"\n"), 0o755) + } + cmd := exec.Command("bash", "-c", script, "session-up", mode) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+stubs+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("session-up %s: %v\n%s", mode, err, out) + } + b, _ := os.ReadFile(log) + return string(b) + } + for _, c := range []struct { + mode string + withStart bool + want string // "" = no window at all + }{ + {"setup", true, "bash ./.pier/setup.sh"}, + {"warm", true, "bash ./.pier/start.sh"}, + {"warm", false, "bash ./.pier/setup.sh"}, + {"wake", true, "bash ./.pier/start.sh"}, + {"wake", false, ""}, + } { + calls := run(t, c.mode, c.withStart) + window := strings.Contains(calls, "new-window") + switch { + case c.want == "" && window: + t.Errorf("%s without start.sh must open no window, got:\n%s", c.mode, calls) + case c.want != "" && !strings.Contains(calls, c.want): + t.Errorf("%s (start.sh=%v) must run %q, got:\n%s", c.mode, c.withStart, c.want, calls) + } + } +} diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index a9e7595..35f2d68 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -79,7 +79,12 @@ runcmd: # Headless chromium for browser MCPs/skills (playwright cache + shared libs). [ -e /home/agent/.cache/ms-playwright ] || { npx -y playwright install-deps chromium && sudo -Hu agent npx -y playwright install chromium; } getent group docker >/dev/null && usermod -aG docker agent - grep -q 'pier/env' /home/agent/.bashrc || printf '\n[ -f ~/.config/pier/env ] && set -a && . ~/.config/pier/env && set +a\n[ -S ~/.ssh/agent.sock ] && export SSH_AUTH_SOCK=~/.ssh/agent.sock\ncd ~/work/* 2>/dev/null || true\n' >> /home/agent/.bashrc + # Land new logins in the repo — but only a shell starting in $HOME: an + # unconditional cd dragged every new tmux window and split (and the + # restored panes) out of the folder it was opened in. + grep -q 'pier/env' /home/agent/.bashrc || printf '\n[ -f ~/.config/pier/env ] && set -a && . ~/.config/pier/env && set +a\n[ -S ~/.ssh/agent.sock ] && export SSH_AUTH_SOCK=~/.ssh/agent.sock\nif [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi\n' >> /home/agent/.bashrc + # Images and sessions made before that fix carry the unconditional line. + sed -i 's#^cd ~/work/\* 2>/dev/null || true$#if [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi#' /home/agent/.bashrc ` // RenderUserData fills the shared cloud-init template with this session's @@ -120,28 +125,50 @@ const tmuxEnsure = `# SSH_AUTH_SOCK points at the attach-refreshed symlink (dang tmux has-session -t main 2>/dev/null || sudo -u agent tmux new-session -d -s main -e "SSH_AUTH_SOCK=$HOME/.ssh/agent.sock" -c "$HOME/work/{{REPO}}" ` -const setupWindow = `# Background setup, after checkout + patch + .pier/include extras are all in -# place: the repo's .pier/setup.sh, unless a PIER_SETUP_SCRIPT override rode -# the tar into ~/.config/pier (outer double quotes expand $setup now, into the single-quoted -# bash -c; \$ defers the rest to run time). The outcome must be impossible to -# miss — a failed setup used to vanish with its window: ~/.pier-setup.status -# holds "running" then the exit code (the supervisor beacons it to ls/TUI), -# the log's last line says done/FAILED, and a failed window renames to -# setup-failed and stays open instead of closing. The rename targets its own -# pane id: with a client attached, a bare rename-window can resolve "current -# window" to the attached client's window and mislabel the user's shell. -setup=./.pier/setup.sh -if [ -f "$HOME/.config/pier/setup.sh" ]; then setup="$HOME/.config/pier/setup.sh"; fi -# Presence is the signal, not the exec bit: git only carries +x when the -# author remembered chmod, and gating on -x skipped a committed 0644 -# .pier/setup.sh with no trace — the one silent failure setup promises not -# to have. bash runs it either way. -if [ -f "$setup" ]; then - tmux new-window -d -t main -n setup "bash -c 'set -a; . ~/.config/pier/env 2>/dev/null; set +a; cd ~/work/{{REPO}} || exit 1; echo running > ~/.pier-setup.status; bash $setup 2>&1 | tee ~/.pier-setup.log; c=\${PIPESTATUS[0]}; echo \$c > ~/.pier-setup.status; if [ \$c -eq 0 ]; then echo \"pier setup: done\" >> ~/.pier-setup.log; else echo \"pier setup: FAILED (exit \$c)\" | tee -a ~/.pier-setup.log; tmux rename-window -t \$TMUX_PANE setup-failed; exec sleep infinity; fi'" -fi +// sessionUpTmpl is written to ~/.pier/session-up.sh by the bootstrap and the +// claim freshen, and run in one of three modes. setup builds the environment +// with .pier/setup.sh. warm means the environment is already built (a +// prebuilt image, a claimed ready session): run .pier/start.sh when the repo +// has one — bringing services up takes seconds where a full setup takes +// minutes — and fall back to setup otherwise. wake is a parked session's boot +// (pier-restore.service runs it): start.sh when there is one, else nothing. +// +// The outcome must be impossible to miss — a failed setup used to vanish with +// its window: ~/.pier-setup.status holds "running" then the exit code (the +// supervisor beacons it to ls and the app), the log's last line says +// done/FAILED, and a failed window renames itself and stays open. The rename +// targets its own pane id: with a client attached, a bare rename-window can +// resolve "current window" to the attached client's window. +const sessionUpTmpl = `#!/usr/bin/env bash +# pier session-up — brings this session's tmux and environment up (setup|warm|wake). +mode=${1:-setup} +` + tmuxEnsure + `cd "$HOME/work/{{REPO}}" || exit 1 +# A PIER_SETUP_SCRIPT override rides the tar into ~/.config/pier. Presence is +# the signal, not the exec bit: git only carries +x when the author +# remembered chmod. bash runs it either way. +script=./.pier/setup.sh +if [ -f "$HOME/.config/pier/setup.sh" ]; then script="$HOME/.config/pier/setup.sh"; fi +name=setup +case "$mode" in + warm) if [ -f ./.pier/start.sh ]; then script=./.pier/start.sh; name=start; fi ;; + wake) [ -f ./.pier/start.sh ] || exit 0; script=./.pier/start.sh; name=start ;; +esac +[ -f "$script" ] || exit 0 +tmux new-window -d -t main -n "$name" "bash -c 'set -a; . ~/.config/pier/env 2>/dev/null; set +a; cd ~/work/{{REPO}} || exit 1; echo running > ~/.pier-setup.status; bash $script 2>&1 | tee ~/.pier-setup.log; c=\${PIPESTATUS[0]}; echo \$c > ~/.pier-setup.status; if [ \$c -eq 0 ]; then echo \"pier $name: done\" >> ~/.pier-setup.log; else echo \"pier $name: FAILED (exit \$c)\" | tee -a ~/.pier-setup.log; tmux rename-window -t \$TMUX_PANE $name-failed; exec sleep infinity; fi'" ` -const bootstrapTmpl = `#!/usr/bin/env bash +// sessionUp writes the session-up script and runs it in mode — the tail the +// bootstrap and the freshen share. +func sessionUp(mode string) string { + return `mkdir -p "$HOME/.pier" +cat > "$HOME/.pier/session-up.sh" <<'PIER_SESSION_UP' +` + sessionUpTmpl + `PIER_SESSION_UP +rm -f "$HOME/.pier/no-wake-start" +bash "$HOME/.pier/session-up.sh" ` + mode + ` +` +} + +var bootstrapTmpl = `#!/usr/bin/env bash # pier bootstrap — runs once, as agent, on the fresh instance. set -euo pipefail @@ -248,7 +275,7 @@ if [ ! -f "$HOME/.tmux.conf" ]; then printf 'set -g mouse on\nset -g history-limit 50000\nset -g focus-events on\n' > "$HOME/.tmux.conf" fi -` + tmuxEnsure + setupWindow + ` +` + sessionUp(`"$([ -n "$prebuilt" ] && echo warm || echo setup)"`) + ` # Attach gates on this marker: nobody lands in a half-set-up session. Written # after the repo checkout and tmux session exist; deliberately NOT after # .pier/setup.sh, which runs async in its tmux window. diff --git a/internal/pool/claim.go b/internal/pool/claim.go index e7efdc2..df8197e 100644 --- a/internal/pool/claim.go +++ b/internal/pool/claim.go @@ -136,7 +136,7 @@ func freshen(ctx context.Context, p Params, member *driver.Session, name, branch } } } - progress("freshening: branch, secrets, setup re-run") + progress("switching to your branch, refreshing secrets, starting the environment") var extra []string if pl.ForwardAgent { extra = []string{"-A"} diff --git a/internal/pool/fill.go b/internal/pool/fill.go index 236b484..cbbb98d 100644 --- a/internal/pool/fill.go +++ b/internal/pool/fill.go @@ -86,6 +86,12 @@ func fillOne(ctx context.Context, p Params, hasSetup bool) error { return err } } + // A ready session's next boot is its claim, and the claim brings the + // environment up itself (freshen, warm mode): the wake start would only + // race it. + if _, err := p.Driver.Exec(ctx, sess.ID, "mkdir -p ~/.pier && touch ~/.pier/no-wake-start"); err != nil { + return err + } p.progress()("parking " + name) return p.Driver.Park(ctx, sess.ID) } diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index 574b03b..8092b2e 100644 --- a/skills/pier-onboard/SKILL.md +++ b/skills/pier-onboard/SKILL.md @@ -14,6 +14,7 @@ directory control the rest: |---|---|---| | `.pier/bake.sh` | Once, during `pier bake`, on a throwaway instance that becomes the repo's AMI | **Toolchains** — language runtimes, package managers | | `.pier/setup.sh` | Once during `pier bake` (the prebuild), then on every session's first boot, async, after the repo lands | **Repo state** — dependency install, services, migrations, seeds | +| `.pier/start.sh` | Instead of setup.sh whenever the environment is already built (a session from the repo's image, a claimed ready session), and on every wake from parking | **Bring it up** — quick incremental installs, start services. No resets, no reseeds | | `.pier/include` | List read at create time; matching files ride to the VM | **Ignored files** dev needs — env files, local certs | Your job: inspect this repo, write the files that apply, protect loose local @@ -76,9 +77,9 @@ nonzero exit shows as `(setup failed)` in `pier ls`, so **let failures fail** — start with `set -euo pipefail`, don't swallow errors. **It must be safe to re-run on a warm disk.** `pier bake` runs it once on a -checkout and images the result. Every session from that image, and every -ready-session claim, runs it again over the dependencies, build caches and -volumes the earlier run left. Use installs that are incremental when +checkout and images the result, and a repo without `.pier/start.sh` runs it +again in every session from that image and every ready-session claim, over +the dependencies, build caches and volumes the earlier run left. Use installs that are incremental when nothing changed (`pnpm install`, `poetry sync`, `docker compose build`, `docker compose up -d`), and never fail on "already exists". @@ -102,6 +103,37 @@ where possible. A bare background process must fully detach — when the script ends and SIGHUPs its process group (`nohup` alone does not detach it). +## Step 3b — write `.pier/start.sh` (whenever setup.sh starts services) + +setup.sh builds an environment; start.sh brings an already-built one up — +what a developer does after rebooting their laptop, not after cloning. +Sessions from the repo's image, claimed ready sessions, and parked +sessions waking up all run start.sh instead of setup.sh, so it is what +decides how long a new session takes to have its services back. Keep it to: + +- incremental installs that take seconds when nothing changed and pick up + the branch's dependency changes (`pnpm install`, `poetry sync`), plus + cheap codegen the app needs (a Prisma client, an OpenAPI client); +- starting services (`docker compose up -d`, or the repo's `make dev`). + +Never reset or reseed data here: databases and volumes survive parking, +and a start that wipes them destroys the session's state on every wake. + +```bash +#!/usr/bin/env bash +# pier start: bring the built environment up (image sessions, ready-session +# claims, every wake). Logs to ~/.pier-setup.log like setup. +set -euo pipefail +pnpm install +docker compose up -d +``` + +While you're in the compose file, check what makes it slow to come up. A +one-shot job that other services wait on (`service_completed_successfully`) +runs on every start, so it must talk to the services already running, not +boot its own (a CLI without its server URL often starts a throwaway local +server per command). + ## Step 4 — write `.pier/include` (only if ignored files are needed) One path or glob per line, relative to the repo root. `*`, `?`, `[]` match From f347231bcbe936b0951d24913936eca064eb497d Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 20:54:46 +0200 Subject: [PATCH 11/24] fix(payload): rewrite the shell rc before tmux starts, not only in cloud-init --- internal/driver/payload/scripts.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index 35f2d68..1d30720 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -161,6 +161,10 @@ tmux new-window -d -t main -n "$name" "bash -c 'set -a; . ~/.config/pier/env 2>/ // bootstrap and the freshen share. func sessionUp(mode string) string { return `mkdir -p "$HOME/.pier" +# The shell rc must only move a login into the repo when it starts in $HOME +# (user-data rewrites older rcs too, but on a baked image cloud-init is still +# running when this does, and tmux must not start with the old line). +sed -i 's#^cd ~/work/\* 2>/dev/null || true$#if [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi#' "$HOME/.bashrc" 2>/dev/null || true cat > "$HOME/.pier/session-up.sh" <<'PIER_SESSION_UP' ` + sessionUpTmpl + `PIER_SESSION_UP rm -f "$HOME/.pier/no-wake-start" From 7fc353a1bc6b5f157c1d117e8e5d3dccd9ade663 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 23:14:04 +0200 Subject: [PATCH 12/24] refactor!: drop start.sh; setup runs once and wakes restart containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup runs when a session is built and never again. A ready-session claim resumes, checks out the branch and refreshes secrets without re-running it, and a wake re-runs nothing. What a wake needs is what was running: the supervisor records the running Docker containers with each tmux snapshot and before it parks, and the first supervisor of each boot starts exactly those again once the daemon answers — any repo, no extra file. BREAKING CHANGE: `.pier/start.sh` is no longer read. --- README.md | 20 ++--- cmd/pier-supervisor/containers.go | 102 +++++++++++++++++++++++ cmd/pier-supervisor/main.go | 13 +++ cmd/pier-supervisor/tmux.go | 27 +----- cmd/pier-supervisor/tmux_test.go | 78 ++++++++++++++--- docs/SPEC.md | 21 +++-- internal/driver/payload/freshen.go | 6 +- internal/driver/payload/payload_test.go | 10 +-- internal/driver/payload/prebuild.go | 2 +- internal/driver/payload/prebuild_test.go | 64 ++++---------- internal/driver/payload/scripts.go | 63 ++++++-------- internal/pool/fill.go | 6 -- skills/pier-onboard/SKILL.md | 37 ++------ 13 files changed, 252 insertions(+), 197 deletions(-) create mode 100644 cmd/pier-supervisor/containers.go diff --git a/README.md b/README.md index 715e51d..419d475 100644 --- a/README.md +++ b/README.md @@ -276,8 +276,7 @@ Pier uses one committed `.pier/` directory with three optional files: | File | Runs / read | Contains | |---|---|---| -| `.pier/setup.sh` | Once at `pier bake` (prebuild), then every session's first boot, async, in a `setup` tmux window | Repo state: deps, services, migrations, seeds (must be safe to re-run) | -| `.pier/start.sh` | Instead of setup.sh when the environment is already built (image sessions, ready-session claims), and on every wake | Bring it up: quick incremental installs, start services | +| `.pier/setup.sh` | Once at `pier bake` (prebuild), then once per new session, async, in a `setup` tmux window | Repo state: deps, services, migrations, seeds (must be safe to re-run) | | `.pier/include` | At create | Ignored files to carry (env files, local certs) | | `.pier/bake.sh` | Once, during `pier bake` | Toolchains beyond the default image (pnpm, python, rust, ...) | @@ -292,18 +291,11 @@ docker compose up -d pnpm db:migrate ``` -`.pier/setup.sh` builds the environment; `.pier/start.sh` brings a built one -up, the way a laptop runs `docker compose up` after a reboot rather than a -fresh install. Sessions from the repo's image, claimed ready sessions, and -parked sessions waking up run start.sh when the repo has one, so it decides -how fast services are back. Without it, those sessions re-run setup.sh. - -```bash -# .pier/start.sh (never reset or reseed here: data survives parking) -set -euo pipefail -pnpm install -docker compose up -d -``` +Setup runs **once per session**, when the session is built: a ready session +ran it before you claimed it, and a parked session never runs it again. +Waking a parked session brings back its tmux windows and agent +conversations, and restarts the Docker containers that were running when it +parked, so the stack comes back without setup. Prefer `docker compose up -d` for services. A bare background process must fully detach with `setsid cmd log 2>&1 &` or it dies when the diff --git a/cmd/pier-supervisor/containers.go b/cmd/pier-supervisor/containers.go new file mode 100644 index 0000000..c0f3639 --- /dev/null +++ b/cmd/pier-supervisor/containers.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "time" +) + +// Parking is a shutdown, and a shutdown stops every container. A laptop gets +// its stack back after a reboot because the containers still exist and come +// back up; a woken session gets the same here: the supervisor keeps the list +// of containers that were running (with the tmux snapshot, and once more +// right before it parks), and the first supervisor of each boot starts those +// exact containers again — no compose file, no repo script, nothing to +// configure. Setup never re-runs: the containers, their volumes and their +// data are all still on the disk. + +// containersFile holds the IDs of the containers running at the last +// snapshot. Kept outside ~/.pier/tmux: a claim discards the saved tmux +// layout, but its ready session's containers are exactly what it wants back. +func containersFile(home string) string { return filepath.Join(home, ".pier", "containers.json") } + +// containersRestarted marks, per boot (/run is tmpfs), that this boot's +// restart already ran, so a supervisor restart doesn't repeat it. +var containersRestarted = "/run/pier/containers-restarted" + +// restartedThisBoot is set once the boot's restart has run (or there was +// nothing to restart). Until then snapshots leave the saved list alone: a +// snapshot taken while docker is still starting would record nothing and +// lose the list the restart is about to use. +var restartedThisBoot atomic.Bool + +// docker runs the docker CLI; a var so tests can stand in a daemon. +var docker = func(args ...string) ([]byte, error) { + return exec.Command("docker", args...).Output() +} + +// snapshotContainers records the running containers. A docker that doesn't +// answer leaves the previous list in place. +func snapshotContainers(home string) { + if !restartedThisBoot.Load() { + return + } + out, err := docker("ps", "-q", "--no-trunc") + if err != nil { + return + } + ids := strings.Fields(string(out)) + if ids == nil { + ids = []string{} + } + b, _ := json.Marshal(ids) + _ = os.MkdirAll(filepath.Dir(containersFile(home)), 0o700) + _ = writeIfChanged(containersFile(home), b) +} + +// restartContainers starts the containers that were running when the +// session last parked. It waits for the docker daemon (it starts alongside +// the supervisor at boot) for up to wait, then starts them in one call — +// docker orders nothing, but compose healthchecks and restart policies still +// apply to each container as it comes up. Returns a line for the journal. +func restartContainers(home string, wait time.Duration) string { + defer restartedThisBoot.Store(true) + if _, err := os.Stat(containersRestarted); err == nil { + return "" + } + defer func() { + _ = os.MkdirAll(filepath.Dir(containersRestarted), 0o755) + _ = os.WriteFile(containersRestarted, nil, 0o644) + }() + b, err := os.ReadFile(containersFile(home)) + if err != nil { + return "" + } + var ids []string + if json.Unmarshal(b, &ids) != nil || len(ids) == 0 { + return "" + } + deadline := time.Now().Add(wait) + for { + if _, err := docker("info", "--format", "{{.ID}}"); err == nil { + break + } + if time.Now().After(deadline) { + return fmt.Sprintf("containers: docker didn't answer within %s — %d container(s) not restarted", wait, len(ids)) + } + time.Sleep(time.Second) + } + // Containers removed since the snapshot fail individually; docker still + // starts the rest. + out, err := docker(append([]string{"start"}, ids...)...) + started := len(strings.Fields(string(out))) + if err != nil { + return fmt.Sprintf("containers: restarted %d of %d (%v)", started, len(ids), err) + } + return fmt.Sprintf("containers: restarted %d that were running before the park", started) +} diff --git a/cmd/pier-supervisor/main.go b/cmd/pier-supervisor/main.go index e9355f9..49cb1ff 100644 --- a/cmd/pier-supervisor/main.go +++ b/cmd/pier-supervisor/main.go @@ -75,6 +75,19 @@ func main() { os.Exit(restoreMain()) } + // Bring back the containers the session had running before it parked, + // alongside the loop: docker may still be starting. + go func() { + home, err := os.UserHomeDir() + if err != nil { + restartedThisBoot.Store(true) + return + } + if msg := restartContainers(home, 3*time.Minute); msg != "" { + fmt.Println("pier-supervisor:", msg) + } + }() + idleSince := time.Now() detachedBusySince := time.Time{} last := status{} diff --git a/cmd/pier-supervisor/tmux.go b/cmd/pier-supervisor/tmux.go index f62a9b9..aab463a 100644 --- a/cmd/pier-supervisor/tmux.go +++ b/cmd/pier-supervisor/tmux.go @@ -686,35 +686,9 @@ func restoreMain() int { return 0 } fmt.Println("pier-restore:", restoreTmux(defaultTmux, home)) - if cmd := wakeStart(home); cmd != nil { - out, err := cmd.CombinedOutput() - if err != nil { - fmt.Printf("pier-restore: start.sh window: %v %s\n", err, strings.TrimSpace(string(out))) - } else { - fmt.Println("pier-restore: started .pier/start.sh (if the repo has one) to bring services back up") - } - } return 0 } -// wakeStart is the command that brings a woken session's environment back -// up — the repo's .pier/start.sh, in its own tmux window, via the -// session-up script the bootstrap left — or nil when this boot isn't a -// session's wake: before the bootstrap has run (a first boot, or a boot from -// a baked image), or on a ready session, whose claim runs start itself. -func wakeStart(home string) *exec.Cmd { - up := filepath.Join(home, ".pier", "session-up.sh") - for _, need := range []string{filepath.Join(home, ".pier-bootstrapped"), up} { - if _, err := os.Stat(need); err != nil { - return nil - } - } - if _, err := os.Stat(filepath.Join(home, ".pier", "no-wake-start")); err == nil { - return nil - } - return exec.Command("bash", up, "wake") -} - // restoreTmux rebuilds the saved layout unless a tmux server already runs // (someone beat us to it; never stack a second layout onto theirs) or there // is nothing saved. It returns the journal line describing what happened. @@ -807,6 +781,7 @@ func snapshotNow() { if err := snapshotTmux(defaultTmux, stateDir(home), foreground); err != nil { fmt.Println("pier-supervisor: tmux snapshot:", err) } + snapshotContainers(home) } // restoring reports a boot-time restore still in flight. A snapshot taken diff --git a/cmd/pier-supervisor/tmux_test.go b/cmd/pier-supervisor/tmux_test.go index cb51a35..ec090da 100644 --- a/cmd/pier-supervisor/tmux_test.go +++ b/cmd/pier-supervisor/tmux_test.go @@ -405,21 +405,73 @@ func TestForegroundKeepsAProgramRootOverItsChildren(t *testing.T) { } } -// A boot brings services back only for a session that has been bootstrapped -// and isn't a ready session awaiting its claim. -func TestWakeStartOnlyForBootstrappedSessions(t *testing.T) { +// A wake gets back exactly the containers that were running at the park, +// once per boot, after docker answers; an empty or missing list starts +// nothing. +func TestRestartContainers(t *testing.T) { home := t.TempDir() - os.MkdirAll(filepath.Join(home, ".pier"), 0o755) - if wakeStart(home) != nil { - t.Error("a first boot (nothing bootstrapped) must not start anything") + os.MkdirAll(filepath.Join(home, ".pier"), 0o700) + marker := filepath.Join(t.TempDir(), "restarted") + oldDocker := docker + t.Cleanup(func() { docker = oldDocker }) + var calls [][]string + up := false + docker = func(args ...string) ([]byte, error) { + calls = append(calls, args) + switch args[0] { + case "info": + if !up { + up = true + return nil, fmt.Errorf("daemon starting") + } + return []byte("id"), nil + case "start": + return []byte(strings.Join(args[1:], "\n")), nil + case "ps": + return []byte("c1\nc2\n"), nil + } + return nil, nil + } + withMarker(t, marker) + + restartedThisBoot.Store(true) + snapshotContainers(home) + restartedThisBoot.Store(false) + msg := restartContainers(home, time.Minute) + if !strings.Contains(msg, "restarted 2") { + t.Fatalf("want both containers restarted, got %q (calls %v)", msg, calls) + } + last := calls[len(calls)-1] + if last[0] != "start" || strings.Join(last[1:], ",") != "c1,c2" { + t.Errorf("want `docker start c1 c2`, got %v", last) } - os.WriteFile(filepath.Join(home, ".pier-bootstrapped"), nil, 0o644) - os.WriteFile(filepath.Join(home, ".pier", "session-up.sh"), []byte("true"), 0o644) - if wakeStart(home) == nil { - t.Error("a bootstrapped session's wake must bring the environment up") + if !restartedThisBoot.Load() { + t.Error("snapshots must resume once the restart ran") } - os.WriteFile(filepath.Join(home, ".pier", "no-wake-start"), nil, 0o644) - if wakeStart(home) != nil { - t.Error("a ready session's wake is its claim, which starts things itself") + calls = nil + if msg := restartContainers(home, time.Minute); msg != "" || len(calls) != 0 { + t.Errorf("a second supervisor start in the same boot must not restart again, got %q %v", msg, calls) } } + +// A snapshot taken before the boot's restart ran must not overwrite the +// list with the (still empty) set of running containers. +func TestSnapshotWaitsForTheRestart(t *testing.T) { + home := t.TempDir() + oldDocker := docker + t.Cleanup(func() { docker = oldDocker }) + docker = func(args ...string) ([]byte, error) { return []byte(""), nil } + os.MkdirAll(filepath.Join(home, ".pier"), 0o700) + os.WriteFile(containersFile(home), []byte(`["c1"]`), 0o600) + restartedThisBoot.Store(false) + snapshotContainers(home) + if b, _ := os.ReadFile(containersFile(home)); string(b) != `["c1"]` { + t.Errorf("the saved list must survive until the restart, got %s", b) + } +} + +func withMarker(t *testing.T, path string) { + old := containersRestarted + containersRestarted = path + t.Cleanup(func() { containersRestarted = old }) +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 6d8d0ef..b132fd3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -340,17 +340,16 @@ protected by the repository's `.gitignore`. warm disk. Freshness is manual (`pier bake` again); there is no control plane to schedule rebakes. - **setup vs start (2026-09).** Measured on the same monorepo, a fully warm - re-run of `.pier/setup.sh` still took 197s — its own reset-and-reseed and - stack start, on every session. A laptop runs install once and `up` after - each reboot; pier now has the same split. `.pier/start.sh` (optional) - runs instead of setup when the environment is already built — a - prebuilt-image session (the bootstrap found a checkout), a ready-session - claim — and on every wake: pier-restore.service runs it after rebuilding - the tmux layout, so a parked session comes back with its services - running. All three go through one script the bootstrap writes to - `~/.pier/session-up.sh` (modes setup|warm|wake). Ready sessions carry - `~/.pier/no-wake-start` while parked, since their claim runs start itself. + **Setup runs once per session (2026-09).** Setup runs when a session + is built — the bootstrap, a ready-session fill, the bake — and never + again: a ready-session claim resumes, checks out the branch and re-pushes + secrets without it, and a wake re-runs nothing. What a wake needs is what + was running: the supervisor records the running Docker containers with + each tmux snapshot (and once more before it parks) in + `~/.pier/containers.json`, and the first supervisor of each boot waits for + the daemon and `docker start`s exactly those — any repo, nothing to + configure. Processes outside Docker come back as the tmux restore types + them at the prompt. EBS restores snapshots lazily from S3, and a prebuilt image is gigabytes. AWS launches from a baked image therefore set `VolumeInitializationRate` diff --git a/internal/driver/payload/freshen.go b/internal/driver/payload/freshen.go index 2acb000..674fcd5 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -25,8 +25,8 @@ set -euo pipefail # member's fill-time tmux layout (its setup window, a shell) — none of it is # this session's. Let that restore finish first (a server it started after # the kill below would outlive it), then drop the server and the saved layout -# so the setup window below starts clean and the next park snapshots this -# session's own. +# so the next park snapshots this session's own. Setup does not run: the +# ready session ran it at fill, and its containers came back at this boot. if [ -f /etc/systemd/system/pier-restore.service ]; then for _ in $(seq 30); do [ -e /run/pier/restored ] && break; sleep 0.5; done fi @@ -62,7 +62,7 @@ tar -xf /tmp/pier-files.tar -C . --strip-components=1 repo 2>/dev/null || true # Cosmetic: prompts show the session, not the pool placeholder. sudo hostnamectl set-hostname '{{HOSTNAME}}' 2>/dev/null || true -` + sessionUp("warm") + ` +` + sessionUp(false) + ` rm -f /tmp/pier.bundle /tmp/pier-files.tar /tmp/pier-dirty.patch /tmp/pier-freshen.sh echo freshened ` diff --git a/internal/driver/payload/payload_test.go b/internal/driver/payload/payload_test.go index 285aa44..8919f3e 100644 --- a/internal/driver/payload/payload_test.go +++ b/internal/driver/payload/payload_test.go @@ -163,24 +163,24 @@ func TestRenderBootstrapModes(t *testing.T) { `echo running > ~/.pier-setup.status`, // Runs on presence via bash: a 0644 .pier/setup.sh must not skip // silently. - `[ -f "$script" ] || exit 0`, - `bash $script 2>&1`, + `if [ -f "$setup" ]; then`, + `bash $setup 2>&1`, // The cloud-init wait must guard on binaries the stock image LACKS // (Ubuntu ships git/tmux, so those never triggered the wait and // setup raced the installs it depends on). `command -v docker >/dev/null && command -v node >/dev/null && command -v claude >/dev/null || sudo cloud-init status --wait`, `c=\${PIPESTATUS[0]}`, - `pier $name: FAILED (exit \$c)`, + `pier setup: FAILED (exit \$c)`, // The failure rename must target its own pane: a bare rename-window // resolved to the attached client's current window and mislabeled // the user's shell as setup-failed. - `tmux rename-window -t \$TMUX_PANE $name-failed`, + `tmux rename-window -t \$TMUX_PANE setup-failed`, // The tmux server must start through sudo, which re-runs initgroups: // the bootstrap's own login predates cloud-init's usermod -aG docker // on stock images, and every window inherits groups from the server // (docker.sock denied in .pier/setup.sh otherwise). `sudo -u agent tmux new-session -d -s main`, - `script=./.pier/setup.sh`, + `setup=./.pier/setup.sh`, // Restore-on-wake: a oneshot that leaves the tmux server it starts // running (it lives in the unit's cgroup), enabled for every boot. `ExecStart=/usr/local/bin/pier-supervisor restore`, diff --git a/internal/driver/payload/prebuild.go b/internal/driver/payload/prebuild.go index 4605abf..4c26fa0 100644 --- a/internal/driver/payload/prebuild.go +++ b/internal/driver/payload/prebuild.go @@ -206,7 +206,7 @@ git reset -q --hard # and first-boot state a session must create for itself — the supervisor's # tmux snapshot included, or a session booted from this image would wake into # the bake instance's windows and scrollback. -rm -rf ~/.config/pier ~/.pier-setup.d ~/.pier/session-up.sh ~/.pier/no-wake-start ~/.pier/tmux +rm -rf ~/.config/pier ~/.pier-setup.d ~/.pier/containers.json ~/.pier/tmux rm -f ~/.pier-bootstrapped ~/.pier-setup.status ~/.pier-setup.log ~/.bash_history \ ~/.config/gh/hosts.yml ~/.docker/config.json ~/.git-credentials ~/.ssh/agent.sock rm -f /tmp/pier-scrub.sh diff --git a/internal/driver/payload/prebuild_test.go b/internal/driver/payload/prebuild_test.go index d7fc10e..54167d8 100644 --- a/internal/driver/payload/prebuild_test.go +++ b/internal/driver/payload/prebuild_test.go @@ -181,54 +181,20 @@ func TestRenderBootstrapReusesPrebuiltCheckout(t *testing.T) { } } -// session-up picks the script by mode: a fresh environment builds with -// setup.sh; a warm one (prebuilt image, claimed ready session) only needs -// start.sh when the repo has one; a wake runs start.sh or nothing. -func TestSessionUpPicksTheScriptByMode(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("bash not available") - } - script := strings.NewReplacer("{{REPO}}", "r").Replace(sessionUpTmpl) - run := func(t *testing.T, mode string, withStart bool) string { - t.Helper() - home := t.TempDir() - repo := filepath.Join(home, "work", "r", ".pier") - os.MkdirAll(repo, 0o755) - os.WriteFile(filepath.Join(repo, "setup.sh"), []byte("true"), 0o644) - if withStart { - os.WriteFile(filepath.Join(repo, "start.sh"), []byte("true"), 0o644) - } - stubs := t.TempDir() - log := filepath.Join(stubs, "calls") - for _, name := range []string{"tmux", "sudo"} { - os.WriteFile(filepath.Join(stubs, name), []byte("#!/bin/sh\necho "+name+" \"$@\" >> "+log+"\n"), 0o755) - } - cmd := exec.Command("bash", "-c", script, "session-up", mode) - cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+stubs+":"+os.Getenv("PATH")) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("session-up %s: %v\n%s", mode, err, out) - } - b, _ := os.ReadFile(log) - return string(b) - } - for _, c := range []struct { - mode string - withStart bool - want string // "" = no window at all - }{ - {"setup", true, "bash ./.pier/setup.sh"}, - {"warm", true, "bash ./.pier/start.sh"}, - {"warm", false, "bash ./.pier/setup.sh"}, - {"wake", true, "bash ./.pier/start.sh"}, - {"wake", false, ""}, - } { - calls := run(t, c.mode, c.withStart) - window := strings.Contains(calls, "new-window") - switch { - case c.want == "" && window: - t.Errorf("%s without start.sh must open no window, got:\n%s", c.mode, calls) - case c.want != "" && !strings.Contains(calls, c.want): - t.Errorf("%s (start.sh=%v) must run %q, got:\n%s", c.mode, c.withStart, c.want, calls) - } +// Setup runs once, when a session is built: the bootstrap starts it, and a +// ready-session claim never does — its setup already ran at fill, and its +// containers come back by themselves on the claim's boot. +func TestSetupRunsOnceNotOnClaim(t *testing.T) { + spec := driver.CreateSpec{Name: "x", Repo: "/tmp/r", Branch: "feat"} + if !strings.Contains(renderBootstrap(spec, "origin", "abc", ""), "tmux new-window -d -t main -n setup") { + t.Error("the bootstrap must run setup") + } + fr := strings.NewReplacer("{{REPO}}", "r").Replace(freshenTmpl) + fr = strings.ReplaceAll(fr, "the setup window below", "") + if strings.Contains(fr, "tmux new-window -d -t main -n setup") || strings.Contains(fr, "bash $setup") { + t.Error("a claim must not re-run setup") + } + if !strings.Contains(fr, "tmux new-session -d -s main") { + t.Error("a claim still needs its tmux session") } } diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index 1d30720..de351a2 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -125,51 +125,38 @@ const tmuxEnsure = `# SSH_AUTH_SOCK points at the attach-refreshed symlink (dang tmux has-session -t main 2>/dev/null || sudo -u agent tmux new-session -d -s main -e "SSH_AUTH_SOCK=$HOME/.ssh/agent.sock" -c "$HOME/work/{{REPO}}" ` -// sessionUpTmpl is written to ~/.pier/session-up.sh by the bootstrap and the -// claim freshen, and run in one of three modes. setup builds the environment -// with .pier/setup.sh. warm means the environment is already built (a -// prebuilt image, a claimed ready session): run .pier/start.sh when the repo -// has one — bringing services up takes seconds where a full setup takes -// minutes — and fall back to setup otherwise. wake is a parked session's boot -// (pier-restore.service runs it): start.sh when there is one, else nothing. -// -// The outcome must be impossible to miss — a failed setup used to vanish with -// its window: ~/.pier-setup.status holds "running" then the exit code (the -// supervisor beacons it to ls and the app), the log's last line says -// done/FAILED, and a failed window renames itself and stays open. The rename -// targets its own pane id: with a client attached, a bare rename-window can -// resolve "current window" to the attached client's window. -const sessionUpTmpl = `#!/usr/bin/env bash -# pier session-up — brings this session's tmux and environment up (setup|warm|wake). -mode=${1:-setup} -` + tmuxEnsure + `cd "$HOME/work/{{REPO}}" || exit 1 +// setupWindow runs the repo's .pier/setup.sh in its own tmux window, once, +// when a session is built (a ready session ran it at fill, so its claim +// doesn't). The outcome must be impossible to miss — a failed setup used to +// vanish with its window: ~/.pier-setup.status holds "running" then the exit +// code (the supervisor beacons it to ls and the app), the log's last line +// says done/FAILED, and a failed window renames itself setup-failed and stays +// open. The rename targets its own pane id: with a client attached, a bare +// rename-window can resolve "current window" to the attached client's. +const setupWindow = `cd "$HOME/work/{{REPO}}" # A PIER_SETUP_SCRIPT override rides the tar into ~/.config/pier. Presence is # the signal, not the exec bit: git only carries +x when the author # remembered chmod. bash runs it either way. -script=./.pier/setup.sh -if [ -f "$HOME/.config/pier/setup.sh" ]; then script="$HOME/.config/pier/setup.sh"; fi -name=setup -case "$mode" in - warm) if [ -f ./.pier/start.sh ]; then script=./.pier/start.sh; name=start; fi ;; - wake) [ -f ./.pier/start.sh ] || exit 0; script=./.pier/start.sh; name=start ;; -esac -[ -f "$script" ] || exit 0 -tmux new-window -d -t main -n "$name" "bash -c 'set -a; . ~/.config/pier/env 2>/dev/null; set +a; cd ~/work/{{REPO}} || exit 1; echo running > ~/.pier-setup.status; bash $script 2>&1 | tee ~/.pier-setup.log; c=\${PIPESTATUS[0]}; echo \$c > ~/.pier-setup.status; if [ \$c -eq 0 ]; then echo \"pier $name: done\" >> ~/.pier-setup.log; else echo \"pier $name: FAILED (exit \$c)\" | tee -a ~/.pier-setup.log; tmux rename-window -t \$TMUX_PANE $name-failed; exec sleep infinity; fi'" +setup=./.pier/setup.sh +if [ -f "$HOME/.config/pier/setup.sh" ]; then setup="$HOME/.config/pier/setup.sh"; fi +if [ -f "$setup" ]; then + tmux new-window -d -t main -n setup "bash -c 'set -a; . ~/.config/pier/env 2>/dev/null; set +a; cd ~/work/{{REPO}} || exit 1; echo running > ~/.pier-setup.status; bash $setup 2>&1 | tee ~/.pier-setup.log; c=\${PIPESTATUS[0]}; echo \$c > ~/.pier-setup.status; if [ \$c -eq 0 ]; then echo \"pier setup: done\" >> ~/.pier-setup.log; else echo \"pier setup: FAILED (exit \$c)\" | tee -a ~/.pier-setup.log; tmux rename-window -t \$TMUX_PANE setup-failed; exec sleep infinity; fi'" +fi ` -// sessionUp writes the session-up script and runs it in mode — the tail the -// bootstrap and the freshen share. -func sessionUp(mode string) string { - return `mkdir -p "$HOME/.pier" -# The shell rc must only move a login into the repo when it starts in $HOME +// sessionUp is the tail the bootstrap and the claim freshen share: the +// shell rc fix, the tmux server, and — only when a session is being built — +// the setup window. +func sessionUp(runSetup bool) string { + s := `# The shell rc must only move a login into the repo when it starts in $HOME # (user-data rewrites older rcs too, but on a baked image cloud-init is still # running when this does, and tmux must not start with the old line). sed -i 's#^cd ~/work/\* 2>/dev/null || true$#if [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi#' "$HOME/.bashrc" 2>/dev/null || true -cat > "$HOME/.pier/session-up.sh" <<'PIER_SESSION_UP' -` + sessionUpTmpl + `PIER_SESSION_UP -rm -f "$HOME/.pier/no-wake-start" -bash "$HOME/.pier/session-up.sh" ` + mode + ` -` +` + tmuxEnsure + if runSetup { + s += setupWindow + } + return s } var bootstrapTmpl = `#!/usr/bin/env bash @@ -279,7 +266,7 @@ if [ ! -f "$HOME/.tmux.conf" ]; then printf 'set -g mouse on\nset -g history-limit 50000\nset -g focus-events on\n' > "$HOME/.tmux.conf" fi -` + sessionUp(`"$([ -n "$prebuilt" ] && echo warm || echo setup)"`) + ` +` + sessionUp(true) + ` # Attach gates on this marker: nobody lands in a half-set-up session. Written # after the repo checkout and tmux session exist; deliberately NOT after # .pier/setup.sh, which runs async in its tmux window. diff --git a/internal/pool/fill.go b/internal/pool/fill.go index cbbb98d..236b484 100644 --- a/internal/pool/fill.go +++ b/internal/pool/fill.go @@ -86,12 +86,6 @@ func fillOne(ctx context.Context, p Params, hasSetup bool) error { return err } } - // A ready session's next boot is its claim, and the claim brings the - // environment up itself (freshen, warm mode): the wake start would only - // race it. - if _, err := p.Driver.Exec(ctx, sess.ID, "mkdir -p ~/.pier && touch ~/.pier/no-wake-start"); err != nil { - return err - } p.progress()("parking " + name) return p.Driver.Park(ctx, sess.ID) } diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index 8092b2e..f42d504 100644 --- a/skills/pier-onboard/SKILL.md +++ b/skills/pier-onboard/SKILL.md @@ -13,8 +13,7 @@ directory control the rest: | File | When it runs / travels | What belongs in it | |---|---|---| | `.pier/bake.sh` | Once, during `pier bake`, on a throwaway instance that becomes the repo's AMI | **Toolchains** — language runtimes, package managers | -| `.pier/setup.sh` | Once during `pier bake` (the prebuild), then on every session's first boot, async, after the repo lands | **Repo state** — dependency install, services, migrations, seeds | -| `.pier/start.sh` | Instead of setup.sh whenever the environment is already built (a session from the repo's image, a claimed ready session), and on every wake from parking | **Bring it up** — quick incremental installs, start services. No resets, no reseeds | +| `.pier/setup.sh` | Once during `pier bake` (the prebuild), then once per new session, async, after the repo lands | **Repo state** — dependency install, services, migrations, seeds | | `.pier/include` | List read at create time; matching files ride to the VM | **Ignored files** dev needs — env files, local certs | Your job: inspect this repo, write the files that apply, protect loose local @@ -77,9 +76,10 @@ nonzero exit shows as `(setup failed)` in `pier ls`, so **let failures fail** — start with `set -euo pipefail`, don't swallow errors. **It must be safe to re-run on a warm disk.** `pier bake` runs it once on a -checkout and images the result, and a repo without `.pier/start.sh` runs it -again in every session from that image and every ready-session claim, over -the dependencies, build caches and volumes the earlier run left. Use installs that are incremental when +checkout and images the result, and every new session from that image runs +it again, over the dependencies, build caches and volumes the earlier run +left. A claimed ready session and a woken parked session never re-run it: +their containers and data are already there. Use installs that are incremental when nothing changed (`pnpm install`, `poetry sync`, `docker compose build`, `docker compose up -d`), and never fail on "already exists". @@ -103,34 +103,9 @@ where possible. A bare background process must fully detach — when the script ends and SIGHUPs its process group (`nohup` alone does not detach it). -## Step 3b — write `.pier/start.sh` (whenever setup.sh starts services) - -setup.sh builds an environment; start.sh brings an already-built one up — -what a developer does after rebooting their laptop, not after cloning. -Sessions from the repo's image, claimed ready sessions, and parked -sessions waking up all run start.sh instead of setup.sh, so it is what -decides how long a new session takes to have its services back. Keep it to: - -- incremental installs that take seconds when nothing changed and pick up - the branch's dependency changes (`pnpm install`, `poetry sync`), plus - cheap codegen the app needs (a Prisma client, an OpenAPI client); -- starting services (`docker compose up -d`, or the repo's `make dev`). - -Never reset or reseed data here: databases and volumes survive parking, -and a start that wipes them destroys the session's state on every wake. - -```bash -#!/usr/bin/env bash -# pier start: bring the built environment up (image sessions, ready-session -# claims, every wake). Logs to ~/.pier-setup.log like setup. -set -euo pipefail -pnpm install -docker compose up -d -``` - While you're in the compose file, check what makes it slow to come up. A one-shot job that other services wait on (`service_completed_successfully`) -runs on every start, so it must talk to the services already running, not +runs on every `up`, so it must talk to the services already running, not boot its own (a CLI without its server URL often starts a throwaway local server per command). From 94ea3b5a5aaca6bd0954a008e41ce924b2ef92ee Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Sat, 26 Sep 2026 23:46:54 +0200 Subject: [PATCH 13/24] fix(bootstrap): restart the supervisor so a baked image's older one can't linger --- internal/driver/payload/prebuild_test.go | 8 +++++++- internal/driver/payload/scripts.go | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/driver/payload/prebuild_test.go b/internal/driver/payload/prebuild_test.go index 54167d8..2b914fc 100644 --- a/internal/driver/payload/prebuild_test.go +++ b/internal/driver/payload/prebuild_test.go @@ -186,9 +186,15 @@ func TestRenderBootstrapReusesPrebuiltCheckout(t *testing.T) { // containers come back by themselves on the claim's boot. func TestSetupRunsOnceNotOnClaim(t *testing.T) { spec := driver.CreateSpec{Name: "x", Repo: "/tmp/r", Branch: "feat"} - if !strings.Contains(renderBootstrap(spec, "origin", "abc", ""), "tmux new-window -d -t main -n setup") { + boot := renderBootstrap(spec, "origin", "abc", "") + if !strings.Contains(boot, "tmux new-window -d -t main -n setup") { t.Error("the bootstrap must run setup") } + // A baked image boots its own older supervisor before the bootstrap + // installs the new one; only a restart puts the new binary in charge. + if !strings.Contains(boot, "systemctl restart pier-supervisor.service") { + t.Error("the bootstrap must restart the supervisor it just installed") + } fr := strings.NewReplacer("{{REPO}}", "r").Replace(freshenTmpl) fr = strings.ReplaceAll(fr, "the setup window below", "") if strings.Contains(fr, "tmux new-window -d -t main -n setup") || strings.Contains(fr, "bash $setup") { diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index de351a2..c55844f 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -216,7 +216,11 @@ KillMode=process WantedBy=multi-user.target UNIT sudo systemctl daemon-reload -sudo systemctl enable --now pier-supervisor.service +# restart, not enable --now: a baked image boots its own (older) supervisor +# before this runs, and --now leaves a running unit alone — the session +# would spend its first boot on the image's binary. +sudo systemctl enable pier-supervisor.service +sudo systemctl restart pier-supervisor.service # --now on a fresh instance finds no saved layout and just writes the marker. sudo systemctl enable --now pier-restore.service From b80c46d62d294985983c5d3083dba5892753e40e Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 13:42:22 +0300 Subject: [PATCH 14/24] refactor!: bring back pier pool, in the CLI and the app The pool keeps its name. `pier pool` shows it, `pier pool set ` sizes it, `pier pool fill` tops it up now and `pier pool drain [repo]` empties any repo's pool from anywhere; fills still also happen by themselves after every create and size change. The app's Repos tab has the same actions: +/- sets the size, f fills, x drains. The speed setting is speed.pool_size; configs that stored it under its interim name are read as before. BREAKING CHANGE: `pier ready` is replaced by `pier pool`, and `--no-ready` by `--no-pool`. --- README.md | 49 ++++--- cmd/pier-supervisor/containers.go | 2 +- cmd/pier/main.go | 209 +++++++++++++++++++---------- docs/SPEC.md | 20 +-- internal/config/config.go | 69 ++++++---- internal/driver/payload/freshen.go | 2 +- internal/driver/payload/scripts.go | 2 +- internal/pool/claim.go | 6 +- internal/pool/fill.go | 12 +- internal/tui/tui.go | 57 ++++---- internal/tui/tui_test.go | 42 ++++-- internal/tui/view.go | 31 ++--- internal/wizard/wizard.go | 4 +- pkg/pier/account.go | 18 +-- pkg/pier/client.go | 2 +- pkg/pier/create.go | 28 ++-- pkg/pier/repos.go | 62 +++++---- pkg/pier/sessions.go | 10 +- pkg/pier/sessions_test.go | 12 +- skills/pier-onboard/SKILL.md | 8 +- 20 files changed, 374 insertions(+), 271 deletions(-) diff --git a/README.md b/README.md index 419d475..5560958 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ intact. And to see what the agent built, |---|---| | running | `~$0.04/h` | | parked | `~$3-4/mo` (disk only) | -| ready session (parked) | `~$3-4/mo` (disk only) | +| pooled session (parked) | `~$3-4/mo` (disk only) | There is no control plane. No server, no database, no daemon on your laptop. Session state lives in instance tags. Every byte between you and the VM is @@ -154,15 +154,15 @@ anything later: run `pier` and press `s`. ### Speed profiles A profile presets two settings: whether pier reminds you to bake, and how -many ready sessions each baked repo keeps. +big a pool each baked repo keeps. | Profile | New session | Idle cost per repo | |---|---|---| -| **Fast** (default) | claims a parked, set-up ready session: ~25s | image ~$1-2/mo + one parked disk | +| **Fast** (default) | claims a parked, set-up session from the pool: ~25s | image ~$1-2/mo + one parked disk | | **Lean** | boots from the repo's image, setup re-runs warm: ~1-2 min | image only | | **Minimal** | stock launch, full setup every time | $0 | -A ready session is a stopped VM, so waiting costs disk only. Change either +A pooled session is a stopped VM, so waiting costs disk only. Change either setting and the profile reads Custom. No admin rights? `pier setup --print-admin` prints the handful of commands @@ -216,17 +216,17 @@ pier [base] new session off base (default HEAD), then attach --idle park after this much idle time (default from settings) --cap unattended runaway cap (default 8h) --no-park shorthand for --idle never - --no-ready launch fresh instead of claiming a ready session + --no-pool launch fresh instead of claiming a pooled session pier ls [--json] plain list, or stable JSON for automation pier attach attach (a parked session resumes, ~20-60s, tmux restored) pier logs show the setup script log (-f follows) pier rm [-f] destroy the session and its disk pier keep pin: never park when idle pier resize grow/shrink the VM (~1-2 min, same CPU arch) -pier repos [--json] every repo: session image, ready sessions, idle cost, tips +pier repos [--json] every repo: session image, pool, idle cost, tips pier bake build this repo's session image (repo prebuilt by default) --toolchain-only keep repo state out of the image for this bake -pier ready [n] show, or set, how many ready sessions this repo keeps +pier pool this repo's pool (set | fill | drain [repo]) pier mcp login one-time browser approvals for OAuth MCP servers pier proxy every running session as .pier (macOS) pier port

manual port forward (8080:3000 = local:session) @@ -291,7 +291,7 @@ docker compose up -d pnpm db:migrate ``` -Setup runs **once per session**, when the session is built: a ready session +Setup runs **once per session**, when the session is built: a pooled session ran it before you claimed it, and a parked session never runs it again. Waking a parked session brings back its tmux windows and agent conversations, and restarts the Docker containers that were running when it @@ -433,24 +433,29 @@ the warm re-run gets slow. Images are keyed to the repo, so one project's toolchain never bleeds into another's. Re-baking supersedes the old image, and `pier teardown` sweeps them all by tag. -### Ready sessions: the create is already done +### Warm pools: the create is already done -Even a create from a prebuilt image boots a VM and re-runs setup. A ready -session has done that ahead of time: it's a parked, **setup-complete** -session waiting to be claimed. Baked repos keep one by default (the Fast -profile); the Repos tab (`+`/`-`) or `pier ready ` changes that per repo. +Even a create from a prebuilt image boots a VM and runs setup. A pool holds +sessions that already did: parked, **setup-complete** sessions waiting to be +claimed. Baked repos keep a pool of one by default (the Fast profile). + +``` +pier pool this repo's pool: ready, filling, what it costs +pier pool set 2 keep two ready (0 turns the pool off and drains it) +pier pool fill top it up now, e.g. right after a bake +pier pool drain [repo] destroy a repo's pooled sessions, from anywhere +``` The next `pier ` claims one instead of creating — resume, check out -your branch, re-push secrets, apply your dirty edits — and refills in the -background from the repo's image. None left just means a normal create: -ready sessions accelerate, never gate, and `--no-ready` skips them for one -create. +your branch, re-push secrets, apply your dirty edits; setup doesn't run +again — and the pool refills in the background. An empty pool just means a +normal create: the pool accelerates, never gates, and `--no-pool` skips it +for one create. -Ready sessions are stopped instances, so each costs disk only (~$3-4/mo at +Pooled sessions are stopped instances, so each costs disk only (~$3-4/mo at 40 GiB). They recycle themselves when they go stale — after a re-bake, a -setup-script change, or 14 days (`pool.max_age`) — and `pier repos` shows -what each repo holds and costs. `pier ready 0` turns them off and removes -the parked ones. +setup-script change, or 14 days (`pool.max_age`). The app's Repos tab shows +every repo's pool with its cost: `+`/`-` sets the size, `f` fills, `x` drains. ### Rebake reminders @@ -465,7 +470,7 @@ per repo), as a dot on the Repos tab, and in `pier repos`. `pier` with no arguments opens the app: a **Sessions** tab (every session with a state dot, a detail pane, attach, logs, resize, keep, delete), a -**Repos** tab (session image, ready sessions, idle cost and reminders per +**Repos** tab (session image, pool, idle cost and reminders per repo), and **Settings**, one page grouped into Cloud, New sessions, Idle & cost, and Speed, showing only the active cloud's fields with a line on what each changes. The header keeps running and parked counts and the live diff --git a/cmd/pier-supervisor/containers.go b/cmd/pier-supervisor/containers.go index c0f3639..d170530 100644 --- a/cmd/pier-supervisor/containers.go +++ b/cmd/pier-supervisor/containers.go @@ -22,7 +22,7 @@ import ( // containersFile holds the IDs of the containers running at the last // snapshot. Kept outside ~/.pier/tmux: a claim discards the saved tmux -// layout, but its ready session's containers are exactly what it wants back. +// layout, but its pooled session's containers are exactly what it wants back. func containersFile(home string) string { return filepath.Join(home, ".pier", "containers.json") } // containersRestarted marks, per boot (/run is tmpfs), that this boot's diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 57f64dc..7601a24 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -77,7 +77,7 @@ var helpSections = []helpSection{ {" --idle ", "park after this much idle time (default from settings)"}, {" --cap ", "runaway cap for this session"}, {" --no-park", "never park this session when idle"}, - {" --no-ready", "launch fresh instead of claiming a ready session"}, + {" --no-pool", "launch fresh instead of claiming a pooled session"}, {"pier ls [--json]", "list sessions"}, {"pier attach ", "attach; a parked session resumes with its tmux restored"}, {"pier logs [-f]", "show or follow the setup log"}, @@ -89,11 +89,14 @@ var helpSections = []helpSection{ { title: "Repos", items: []helpItem{ - {"pier repos [--json]", "every repo: session image, ready sessions, monthly cost"}, + {"pier repos [--json]", "every repo: session image, pool, monthly cost"}, {"pier bake", "build this repo's session image (repo prebuilt; see settings)"}, {" --toolchain-only", "keep repo state out of the image for this bake"}, {" --with-repo", "prebuild the repo into the image for this bake"}, - {"pier ready [n]", "show, or set, how many ready sessions this repo keeps"}, + {"pier pool", "this repo's pool: parked, set-up sessions new ones claim"}, + {"pier pool set ", "keep n in this repo's pool (0 = off)"}, + {"pier pool fill [-d]", "top the pool up now (e.g. right after a bake)"}, + {"pier pool drain [repo]", "destroy a repo's pooled sessions, from anywhere"}, }, }, { @@ -146,10 +149,8 @@ func main() { cmdRepos(args[1:]) case "bake": cmdBake(args[1:]) - case "ready": - cmdReady(args[1:]) - case pier.RefillCommand: - cmdRefill() + case "pool": + cmdPool(args[1:]) case "setup": cmdSetup(args[1:]) case "doctor": @@ -238,11 +239,11 @@ func cmdNew(args []string) { } } fs := flag.NewFlagSet("new", flag.ExitOnError) - var detach, noPark, noReady bool + var detach, noPark, noPool bool fs.BoolVar(&detach, "d", false, "") fs.BoolVar(&detach, "detach", false, "") fs.BoolVar(&noPark, "no-park", false, "") - fs.BoolVar(&noReady, "no-ready", false, "") + fs.BoolVar(&noPool, "no-pool", false, "") idleS := fs.String("idle", "", "") capS := fs.String("cap", "", "") fs.Parse(flagArgs) @@ -250,7 +251,7 @@ func cmdNew(args []string) { printUsage() os.Exit(1) } - req := pier.CreateRequest{Branch: pos[0], NoReady: noReady, Progress: progress} + req := pier.CreateRequest{Branch: pos[0], NoPool: noPool, Progress: progress} if len(pos) == 2 { req.Base = pos[1] } @@ -290,10 +291,10 @@ func cmdNew(args []string) { } stop() // create done — ctrl-c back to its default for the prompt + attach switch { - case res.RefillErr != nil: - fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" ready-session refill didn't start: "+res.RefillErr.Error())) - case res.RefillLog != "": - fmt.Println(ui.Dim.Render("refilling ready sessions in the background — log: " + ui.Tilde(res.RefillLog))) + case res.FillErr != nil: + fmt.Println(ui.Warn.Render("!") + ui.Dim.Render(" pool refill didn't start: "+res.FillErr.Error())) + case res.FillLog != "": + fmt.Println(ui.Dim.Render("refilling the pool in the background — log: " + ui.Tilde(res.FillLog))) } sess := res.Session fmt.Println(ui.OK.Render("session " + sess.Name + " ready")) @@ -437,12 +438,12 @@ func cmdLS(args []string) { if err != nil { fatal(err) } - // Ready sessions are inventory, not sessions: kept out of the JSON + // Pooled sessions are inventory, not sessions: kept out of the JSON // entirely, and one dim summary line below the table instead of rows. - sessions, ready := pier.SplitReady(all) - readyLine := "" - if len(ready) > 0 { - readyLine = ui.Dim.Render(fmt.Sprintf("+ %d ready session(s) waiting — `pier repos`", len(ready))) + sessions, pooled := pier.SplitPool(all) + poolLine := "" + if len(pooled) > 0 { + poolLine = ui.Dim.Render(fmt.Sprintf("+ %d pooled session(s) waiting to be claimed — `pier pool`", len(pooled))) } if jsonOutput { if err := writeSessionsJSON(os.Stdout, sessions); err != nil { @@ -452,8 +453,8 @@ func cmdLS(args []string) { } if len(sessions) == 0 { fmt.Println(ui.Dim.Render("no sessions — start one with `pier `")) - if readyLine != "" { - fmt.Println(readyLine) + if poolLine != "" { + fmt.Println(poolLine) } return } @@ -468,8 +469,8 @@ func cmdLS(args []string) { anyFailedCreate = anyFailedCreate || s.State == pier.StateFailed } w.Flush() - if readyLine != "" { - fmt.Println(readyLine) + if poolLine != "" { + fmt.Println(poolLine) } if anyFailedCreate { fmt.Println("\n" + ui.Bad.Render("✗") + ui.Dim.Render(" create failed = the instance was rolled back, nothing is running — `pier rm ` clears the row")) @@ -755,7 +756,7 @@ type repoJSON struct { Name string `json:"name"` Image string `json:"image"` BakedAt *time.Time `json:"baked_at"` - ReadyTarget int `json:"ready_target"` + PoolSize int `json:"ready_target"` Ready int `json:"ready"` Filling int `json:"filling"` Sessions int `json:"sessions"` @@ -775,7 +776,7 @@ func cmdRepos(args []string) { if jsonOutput { items := make([]repoJSON, 0, len(repos)) for _, r := range repos { - j := repoJSON{Name: r.Name, Image: r.Image, ReadyTarget: r.ReadyTarget, Ready: r.Ready, + j := repoJSON{Name: r.Name, Image: r.Image, PoolSize: r.PoolSize, Ready: r.Ready, Filling: r.Filling, Sessions: r.Sessions, MonthlyUSD: r.MonthlyUSD, Reminders: r.Reminders} if r.Baked != nil { t := r.Baked.BakedAt @@ -793,9 +794,9 @@ func cmdRepos(args []string) { return } w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0) - fmt.Fprintln(w, "REPO\tIMAGE\tREADY\tSESSIONS\tIDLE COST") + fmt.Fprintln(w, "REPO\tIMAGE\tPOOL\tSESSIONS\tIDLE COST") for _, r := range repos { - fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", r.Name, imageLabel(r), readyLabel(r), r.Sessions, money(r.MonthlyUSD)) + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", r.Name, imageLabel(r), poolLabel(r), r.Sessions, money(r.MonthlyUSD)) } w.Flush() for _, r := range repos { @@ -822,11 +823,11 @@ func imageLabel(r pier.Repo) string { return kind + ", just baked" } -func readyLabel(r pier.Repo) string { - if r.ReadyTarget == 0 && r.Ready == 0 && r.Filling == 0 { +func poolLabel(r pier.Repo) string { + if r.PoolSize == 0 && r.Ready == 0 && r.Filling == 0 { return "off" } - l := fmt.Sprintf("%d/%d", r.Ready, r.ReadyTarget) + l := fmt.Sprintf("%d/%d", r.Ready, r.PoolSize) if r.Filling > 0 { l += fmt.Sprintf(" (+%d filling)", r.Filling) } @@ -880,66 +881,126 @@ func cmdBake(args []string) { } fmt.Println(ui.OK.Render("baked "+res.Image) + ui.Dim.Render(" — new "+name+" sessions start from it; pier reminds you when a rebake would help")) if res.ReadyStale { - fmt.Println(ui.Dim.Render("ready sessions were built on the old image — they recycle on the next claim or refill")) + fmt.Println(ui.Dim.Render("the pool was built on the old image — its sessions recycle on the next claim or `pier pool fill`")) } } -// cmdReady: `pier ready` shows this repo's ready sessions; `pier ready ` -// sets how many to keep (0 turns them off and removes the parked ones). -func cmdReady(args []string) { - c := open() - root := repoRoot() - repo := filepath.Base(root) +// cmdPool: this repo's pool — parked, setup-complete sessions `pier ` +// claims instead of launching. `pier pool` shows it; set/fill/drain change it. +// Fills also happen by themselves after every create and every size change. +func cmdPool(args []string) { if len(args) == 0 { - sessions, err := c.Sessions(context.Background()) + poolStatus() + return + } + switch args[0] { + case "set": + if len(args) != 2 { + fatal(fmt.Errorf("usage: pier pool set (0-8; 0 turns the pool off and removes its sessions)")) + } + n, err := strconv.Atoi(args[1]) + if err != nil || n < 0 || n > 8 { + fatal(fmt.Errorf("pool size: want a number 0-8 (got %q)", args[1])) + } + c := open() + root := repoRoot() + logPath, err := c.SetPoolSize(context.Background(), root, n, func(e pier.Event) { stepLine(e.Message) }) if err != nil { fatal(err) } - for _, r := range c.Repos(sessions, root) { - if r.Name != repo { - continue - } - fmt.Printf("%s: %s ready sessions", repo, readyLabel(r)) - if r.Ready+r.Filling > 0 { - fmt.Print(ui.Dim.Render(fmt.Sprintf(" — each ~$%.0f/mo parked (disk only)", c.DiskMonthlyUSD()))) - } - fmt.Println() - if r.Image == "" && r.ReadyTarget == 0 { - fmt.Println(ui.Dim.Render("ready sessions follow the speed settings for repos with a session image — `pier bake` first")) + repo := filepath.Base(root) + if n == 0 { + fmt.Println(ui.OK.Render("pool off for " + repo)) + return + } + fmt.Println(ui.OK.Render(fmt.Sprintf("pool size %d for %s", n, repo)) + + ui.Dim.Render(fmt.Sprintf(" — each pooled session ~$%.0f/mo parked (disk only)", c.DiskMonthlyUSD()))) + fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(logPath))) + case "fill": + detach := len(args) > 1 && (args[1] == "-d" || args[1] == "--detach") + c := open() + root := repoRoot() + if c.Config().PoolSize(filepath.Base(root)) == 0 { + fatal(fmt.Errorf("no pool for %s — `pier pool set ` first", filepath.Base(root))) + } + if detach { + logPath, err := c.SpawnFill(root) + if err != nil { + fatal(err) } + fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(logPath))) + return } - return - } - if len(args) != 1 { - fatal(fmt.Errorf("usage: pier ready [n] (0-8; 0 turns them off)")) - } - n, err := strconv.Atoi(args[0]) - if err != nil || n < 0 || n > 8 { - fatal(fmt.Errorf("ready sessions: want a number 0-8 (got %q)", args[0])) + // ctrl-c must cancel the ctx so a half-made member is terminated. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := c.FillPool(ctx, root, progress); err != nil { + fatal(err) + } + case "drain": + c := open() + repo := "" + if len(args) > 1 { + repo = args[1] + } else { + repo = filepath.Base(repoRoot()) + } + n, err := c.DrainPool(context.Background(), repo, func(e pier.Event) { stepLine(e.Message) }) + if err != nil { + fatal(err) + } + if n == 0 { + fmt.Println(ui.Dim.Render("no pooled sessions for " + repo)) + return + } + fmt.Println(ui.OK.Render(fmt.Sprintf("drained %d pooled session(s) from %s", n, repo))) + if size := c.Config().PoolSize(repo); size > 0 { + fmt.Println(ui.Dim.Render(fmt.Sprintf("pool size for %s is still %d — the next create or `pier pool fill` refills it; `pier pool set 0` turns it off", repo, size))) + } + default: + fatal(fmt.Errorf("usage: pier pool [set | fill [-d] | drain [repo]]")) } - logPath, err := c.SetReady(context.Background(), root, n, func(e pier.Event) { stepLine(e.Message) }) +} + +// poolStatus prints every repo's pool — this repo's first — with its cost. +func poolStatus() { + c := open() + sessions, err := c.Sessions(context.Background()) if err != nil { fatal(err) } - if n == 0 { - fmt.Println(ui.OK.Render("no ready sessions for " + repo)) - return + cur, _ := pier.RepoRoot("") + any := false + for _, r := range c.Repos(sessions, cur) { + if r.PoolSize == 0 && r.Ready+r.Filling+r.Stale == 0 { + if r.Current { + fmt.Println(r.Name + ": no pool" + ui.Dim.Render(poolHint(r))) + } + continue + } + any = true + line := fmt.Sprintf("%s: %d/%d ready", r.Name, r.Ready, r.PoolSize) + if r.Filling > 0 { + line += fmt.Sprintf(", %d filling", r.Filling) + } + if r.Stale > 0 { + line += ui.Warn.Render(fmt.Sprintf(", %d stale", r.Stale)) + ui.Dim.Render(" (recycle on the next fill)") + } + if r.Ready+r.Filling > 0 { + line += ui.Dim.Render(fmt.Sprintf(" — ~$%.0f/mo parked", float64(r.Ready+r.Filling)*c.DiskMonthlyUSD())) + } + fmt.Println(line) + } + if !any && cur == "" { + fmt.Println(ui.Dim.Render("no pools — `pier pool set ` inside a repo keeps set-up sessions ready to claim")) } - fmt.Println(ui.OK.Render(fmt.Sprintf("keeping %d ready session(s) for %s", n, repo)) + - ui.Dim.Render(fmt.Sprintf(" — each ~$%.0f/mo parked (disk only)", c.DiskMonthlyUSD()))) - fmt.Println(ui.Dim.Render("filling in the background — log: " + ui.Tilde(logPath))) } -// cmdRefill is the hidden command background refills re-exec. -func cmdRefill() { - c := open() - root := repoRoot() - // ctrl-c must cancel the ctx so a half-made member is terminated. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := c.Refill(ctx, root, progress); err != nil { - fatal(err) +func poolHint(r pier.Repo) string { + if r.Image == "" { + return " — pools follow the speed settings for repos with a session image; `pier bake` first" } + return " — `pier pool set ` keeps set-up sessions ready to claim" } // --- setup / doctor / teardown ------------------------------------------------------ @@ -1019,7 +1080,7 @@ func printChecks(checks []pier.Check) bool { func cmdTeardown() { c := open() - if !confirm("remove all pier groundwork, images and ready sessions from the account?", false) { + if !confirm("remove all pier groundwork, images and pooled sessions from the account?", false) { return } if err := c.Teardown(context.Background(), func(e pier.Event) { stepLine(e.Message) }); err != nil { diff --git a/docs/SPEC.md b/docs/SPEC.md index b132fd3..7b0a742 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -341,8 +341,8 @@ protected by the repository's `.gitignore`. plane to schedule rebakes. **Setup runs once per session (2026-09).** Setup runs when a session - is built — the bootstrap, a ready-session fill, the bake — and never - again: a ready-session claim resumes, checks out the branch and re-pushes + is built — the bootstrap, a pool fill, the bake — and never + again: a pool claim resumes, checks out the branch and re-pushes secrets without it, and a wake re-runs nothing. What a wake needs is what was running: the supervisor records the running Docker containers with each tmux snapshot (and once more before it parks) in @@ -402,10 +402,10 @@ protected by the repository's `.gitignore`. files it is *not* carrying so a missing one fails loud at create, not deep in `make dev`. -4. **Ready sessions (per repo; formerly warm pools)** — baked repos keep - `speed.ready_sessions` (default 1, the Fast profile) parked, +4. **Warm pools (per repo)** — baked repos keep + `speed.pool_size` (default 1, the Fast profile) parked, **setup-complete** members ready, overridable per repo with - `pier ready ` or the app's Repos tab; `pier ` + `pier pool set ` or the app's Repos tab; `pier ` then claims one — resume + freshen (secrets re-push, branch at base, dirty patch, supervisor conf reset, async setup re-run for drift) instead of create + boot + full setup — and refills the pool detached in the @@ -536,8 +536,8 @@ pier port

[p...] manual port forwards, zero-sudo any-OS fallback (3 pier rm destroy (instance + disk) pier keep disable auto-park for a session pier resize change VM size (running: park→modify→resume; same arch) -pier repos [--json] every repo: session image, ready sessions, idle cost, reminders -pier ready [n] show / set the cwd repo's ready sessions (0 = off, removes them) +pier repos [--json] every repo: session image, pooled sessions, idle cost, reminders +pier pool [set | fill | drain [repo]] the cwd repo's warm pool pier setup wizard (--print-admin for the no-IAM-rights path, --skills to refresh the bundled agent skill only) pier bake build/refresh the repo's session image (--toolchain-only) @@ -572,14 +572,14 @@ manifest = [".codex/auth.json", ".codex/config.toml", ".claude/settings.json", " # claude_oauth_token = "..." # from `claude setup-token` (macOS Keychain path) [speed] # what a speed profile presets (fast/lean/minimal) -ready_sessions = 1 # per baked repo; parked, disk-only cost +pool_size = 1 # per baked repo; parked, disk-only cost bake_reminders = true # suggest `pier bake`; never automatic image_repo = true # bake prebuilds the repo (false = toolchains only) reminder_age = "30d" # remind when an image gets this old -[pool] # ready sessions (§7.4) +[pool] # pooled sessions (§7.4) # max_age = "14d" # member recycle age -# [pool.sizes] # per-repo overrides, written by `pier ready` +# [pool.sizes] # per-repo overrides, written by `pier pool set` # shop = 2 # [images.] # written by `pier bake`: baked_at, setup_sha, diff --git a/internal/config/config.go b/internal/config/config.go index cd02265..2b969a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,13 +28,17 @@ type Config struct { Images map[string]ImageInfo `toml:"images,omitempty"` } -// Speed holds the two settings a speed profile presets, plus how images +// Speed holds the two settings a speed profile presets (pool size and bake +// reminders), plus how images // are built and when pier suggests rebuilding them. type Speed struct { - // ReadySessions is how many parked, setup-complete sessions pier keeps - // ready for each repo that has a session image (a repo's own entry in - // pool.sizes overrides it). Parked = disk-only cost while waiting. - ReadySessions int `toml:"ready_sessions"` + // PoolSize is how many parked, setup-complete sessions pier keeps in each + // baked repo's pool (a repo's own entry in pool.sizes overrides it). + // Parked = disk-only cost while waiting. + PoolSize int `toml:"pool_size"` + // LegacyReadySessions is pool_size's name in one pre-release build; read + // once at load so configs written by it keep their setting. + LegacyReadySessions *int `toml:"ready_sessions,omitempty"` // BakeReminders: suggest `pier bake` when a repo has no image, its image // gets old, or its .pier scripts changed since the bake. Never automatic. BakeReminders bool `toml:"bake_reminders"` @@ -87,7 +91,7 @@ type GCP struct { } // Pool configures repo-scoped warm session pools. Strictly opt-in: a pool -// exists only for repos with a size entry. Managed by `pier ready`, not +// exists only for repos with a size entry. Managed by `pier pool set`, not // the TUI settings — like baked images, a pool is a per-repo cost decision. type Pool struct { // MaxAge recycles members older than this, bounding how far a warm @@ -123,7 +127,7 @@ func Default() Config { MachineType: "e2-medium", DiskGiB: 40, }, - Speed: Speed{ReadySessions: 1, BakeReminders: true, ImageRepo: true, ReminderAge: "30d"}, + Speed: Speed{PoolSize: 1, BakeReminders: true, ImageRepo: true, ReminderAge: "30d"}, } } @@ -136,12 +140,19 @@ func Dir() string { return filepath.Dir(Path()) } func Load() (Config, error) { c := Default() - if _, err := toml.DecodeFile(Path(), &c); err != nil { + md, err := toml.DecodeFile(Path(), &c) + if err != nil { if os.IsNotExist(err) { return c, fmt.Errorf("no config at %s — run `pier setup` first", Path()) } return c, err } + if c.Speed.LegacyReadySessions != nil { + if !md.IsDefined("speed", "pool_size") { + c.Speed.PoolSize = *c.Speed.LegacyReadySessions + } + c.Speed.LegacyReadySessions = nil // the next write drops the old key + } return c, nil } @@ -252,9 +263,9 @@ func (c *Config) ClearBakes() { c.Images = nil } -// PoolSize is how many ready sessions pier keeps for repo: the repo's own +// PoolSize is how many sessions pier keeps in repo's pool: the repo's own // entry when it has one, otherwise the speed default — but only for repos -// with a session image. Without one a ready session would pay the full cold +// with a session image. Without one a pooled session would pay the full cold // setup on every refill, which is the cost the reminder to bake points at. func (c Config) PoolSize(repo string) int { if n, ok := c.Pool.Sizes[repo]; ok { @@ -263,10 +274,10 @@ func (c Config) PoolSize(repo string) int { if c.BakedImage(repo) == "" { return 0 } - return c.Speed.ReadySessions + return c.Speed.PoolSize } -// SetPoolSize records repo's ready-session count. A count equal to what the +// SetPoolSize records repo's pool size. A count equal to what the // default would give removes the entry, so the repo follows later default // changes; anything else (0 included) is pinned for this repo. func (c *Config) SetPoolSize(repo string, n int) { @@ -283,7 +294,7 @@ func (c *Config) SetPoolSize(repo string, n int) { c.Pool.Sizes[repo] = n } -// PoolRepos lists every repo with a nonzero ready-session target: explicit +// PoolRepos lists every repo with a nonzero pool size: explicit // entries plus baked repos riding the default. func (c Config) PoolRepos() []string { seen := map[string]bool{} @@ -321,15 +332,15 @@ func (c Config) bakedRepos() map[string]bool { } // Profile names the speed preset the current settings match: "fast" -// (reminders + 1 ready session), "lean" (reminders, none ready), "minimal" +// (reminders + a pool of 1), "lean" (reminders, no pool), "minimal" // (neither), or "custom" for anything else. func (c Config) Profile() string { switch { - case c.Speed.BakeReminders && c.Speed.ReadySessions == 1: + case c.Speed.BakeReminders && c.Speed.PoolSize == 1: return "fast" - case c.Speed.BakeReminders && c.Speed.ReadySessions == 0: + case c.Speed.BakeReminders && c.Speed.PoolSize == 0: return "lean" - case !c.Speed.BakeReminders && c.Speed.ReadySessions == 0: + case !c.Speed.BakeReminders && c.Speed.PoolSize == 0: return "minimal" } return "custom" @@ -339,11 +350,11 @@ func (c Config) Profile() string { func (c *Config) ApplyProfile(p string) error { switch p { case "fast": - c.Speed.BakeReminders, c.Speed.ReadySessions = true, 1 + c.Speed.BakeReminders, c.Speed.PoolSize = true, 1 case "lean": - c.Speed.BakeReminders, c.Speed.ReadySessions = true, 0 + c.Speed.BakeReminders, c.Speed.PoolSize = true, 0 case "minimal": - c.Speed.BakeReminders, c.Speed.ReadySessions = false, 0 + c.Speed.BakeReminders, c.Speed.PoolSize = false, 0 default: return fmt.Errorf("speed profile: want fast, lean or minimal (got %q)", p) } @@ -589,14 +600,14 @@ var Settings = []Field{ Key: "speed.profile", Group: "speed", Label: "profile", Hint: "preset for the two rows below", Kind: KindChoice, NoCustom: true, Default: "fast", Options: []Option{ - {Value: "fast", Label: "Fast", Desc: "image + 1 ready session per repo · ~25s starts"}, + {Value: "fast", Label: "Fast", Desc: "image + a pool of 1 per repo · ~25s starts"}, {Value: "lean", Label: "Lean", Desc: "image only · ~1-2 min starts, image storage only"}, {Value: "minimal", Label: "Minimal", Desc: "nothing stored · full setup every start, $0 idle"}, }, - Detail: "A profile is a preset for bake reminders and ready sessions. Change either row and the profile reads custom.\nFast: new sessions claim a parked, set-up session (~25s); each ready session costs its parked disk.\nLean: new sessions boot from the repo's image and re-run setup warm.\nMinimal: nothing is stored; every session runs the full setup.", + Detail: "A profile is a preset for bake reminders and pool size. Change either row and the profile reads custom.\nFast: new sessions claim a parked, set-up session from the pool (~25s); each pooled session costs its parked disk.\nLean: new sessions boot from the repo's image and re-run setup warm.\nMinimal: nothing is stored; every session runs the full setup.", }, { - Key: "speed.ready_sessions", Group: "speed", Label: "ready sessions", Hint: "parked and set up, per baked repo", + Key: "speed.pool_size", Group: "speed", Label: "pool size", Hint: "parked, set-up sessions per baked repo", Kind: KindChoice, NoCustom: true, Default: "1", Options: []Option{ {Value: "0", Label: "none"}, @@ -604,7 +615,7 @@ var Settings = []Field{ {Value: "2", Desc: "two quick starts back to back"}, {Value: "3"}, }, - Detail: "How many parked, setup-complete sessions pier keeps for each repo that has a session image. They're stopped VMs: each costs only its disk while waiting, and runs only while being refilled.\nOverride one repo on the Repos tab.", + Detail: "How many parked, setup-complete sessions pier keeps in each baked repo's pool. A new session claims one instead of launching, so it's usable in ~25s with services up. They're stopped VMs: each costs only its disk while waiting, and runs only while being filled.\nOverride one repo on the Repos tab or with `pier pool set `.", }, { Key: "speed.bake_reminders", Group: "speed", Label: "bake reminders", Hint: "suggest `pier bake` when it would help", @@ -663,8 +674,8 @@ func Get(c Config, key string) string { return strconv.Itoa(c.GCP.DiskGiB) case "speed.profile": return c.Profile() - case "speed.ready_sessions": - return strconv.Itoa(c.Speed.ReadySessions) + case "speed.pool_size": + return strconv.Itoa(c.Speed.PoolSize) case "speed.bake_reminders": return strconv.FormatBool(c.Speed.BakeReminders) case "speed.image_repo": @@ -777,12 +788,12 @@ func Set(c *Config, key, val string) error { } case "speed.profile": return c.ApplyProfile(strings.ToLower(strings.TrimSpace(val))) - case "speed.ready_sessions": + case "speed.pool_size": n, err := strconv.Atoi(strings.TrimSpace(val)) if err != nil || n < 0 || n > 8 { - return fmt.Errorf("speed.ready_sessions: want 0-8 (got %q)", val) + return fmt.Errorf("speed.pool_size: want 0-8 (got %q)", val) } - c.Speed.ReadySessions = n + c.Speed.PoolSize = n case "speed.bake_reminders", "speed.image_repo": var b bool switch strings.ToLower(strings.TrimSpace(val)) { diff --git a/internal/driver/payload/freshen.go b/internal/driver/payload/freshen.go index 674fcd5..37475a1 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -26,7 +26,7 @@ set -euo pipefail # this session's. Let that restore finish first (a server it started after # the kill below would outlive it), then drop the server and the saved layout # so the next park snapshots this session's own. Setup does not run: the -# ready session ran it at fill, and its containers came back at this boot. +# pooled session ran it at fill, and its containers came back at this boot. if [ -f /etc/systemd/system/pier-restore.service ]; then for _ in $(seq 30); do [ -e /run/pier/restored ] && break; sleep 0.5; done fi diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index c55844f..0f0f142 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -126,7 +126,7 @@ tmux has-session -t main 2>/dev/null || sudo -u agent tmux new-session -d -s mai ` // setupWindow runs the repo's .pier/setup.sh in its own tmux window, once, -// when a session is built (a ready session ran it at fill, so its claim +// when a session is built (a pooled session ran it at fill, so its claim // doesn't). The outcome must be impossible to miss — a failed setup used to // vanish with its window: ~/.pier-setup.status holds "running" then the exit // code (the supervisor beacons it to ls and the app), the log's last line diff --git a/internal/pool/claim.go b/internal/pool/claim.go index df8197e..ac6e9d0 100644 --- a/internal/pool/claim.go +++ b/internal/pool/claim.go @@ -59,12 +59,12 @@ func Claim(ctx context.Context, p Params, sessions []driver.Session, name, branc if member == nil { return nil, nil } - progress(fmt.Sprintf("claimed ready session %s — resuming", member.Name)) + progress(fmt.Sprintf("claimed pooled session %s — resuming", member.Name)) if err := freshen(ctx, p, member, name, branch, baseRef); err != nil { progress("freshen failed (" + err.Error() + ")") - progress("destroying that ready session — its state is untrusted — and creating fresh") + progress("destroying that pooled session — its state is untrusted — and creating fresh") if derr := p.Driver.Destroy(context.WithoutCancel(ctx), member.ID); derr != nil { - progress("destroy failed too (" + derr.Error() + ") — check `pier repos` for a leftover ready session") + progress("destroy failed too (" + derr.Error() + ") — check `pier pool` for a leftover") } return nil, nil } diff --git a/internal/pool/fill.go b/internal/pool/fill.go index 236b484..b5a0290 100644 --- a/internal/pool/fill.go +++ b/internal/pool/fill.go @@ -31,30 +31,30 @@ func Fill(ctx context.Context, p Params) error { // two disagree about a member only across a re-bake racing a create. Known, // accepted: the claimer fails loudly and falls back to a fresh create. for _, m := range plan.Stale { - progress("recycling stale ready session " + m.Name) + progress("recycling stale pooled session " + m.Name) if err := p.Driver.Destroy(ctx, m.ID); err != nil { return err } } if plan.Fill == 0 { - progress(fmt.Sprintf("%s has its ready sessions (%d ready, %d filling)", repo, len(plan.Ready), plan.InFlight)) + progress(fmt.Sprintf("%s's pool is full (%d ready, %d filling)", repo, len(plan.Ready), plan.InFlight)) return nil } // Quota gate, conservative: at least 2 vCPUs per member. Pools must not // eat the headroom real sessions need; an unreadable quota is not fatal — // the create itself fails loudly on the provider's limit. if q, err := p.Driver.Headroom(ctx); err == nil && q.Limit > 0 && q.Limit-q.Used < 2*plan.Fill { - return fmt.Errorf("filling %d ready session(s) needs more vCPU headroom than %s has — raise the quota or lower the ready count", + return fmt.Errorf("filling %d pooled session(s) needs more vCPU headroom than %s has — raise the quota or shrink the pool", plan.Fill, q.Detail) } hasSetup := len(SetupScript(p.RepoRoot)) > 0 for i := range plan.Fill { - progress(fmt.Sprintf("filling ready session %d/%d", i+1, plan.Fill)) + progress(fmt.Sprintf("filling pool session %d/%d", i+1, plan.Fill)) if err := fillOne(ctx, p, hasSetup); err != nil { return err } } - progress(fmt.Sprintf("%s has its ready sessions (%d ready)", repo, len(plan.Ready)+plan.Fill)) + progress(fmt.Sprintf("%s's pool is full (%d ready)", repo, len(plan.Ready)+plan.Fill)) return nil } @@ -81,7 +81,7 @@ func fillOne(ctx context.Context, p Params, hasSetup bool) error { if err := waitSetup(ctx, p, sess.ID); err != nil { // An unfinished setup is exactly what the pool exists to avoid: // destroy rather than park a member that would claim cold. - p.progress()("destroying the failed ready session") + p.progress()("destroying the failed pool session") _ = p.Driver.Destroy(context.WithoutCancel(ctx), sess.ID) return err } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ad05597..1e6f726 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -35,8 +35,9 @@ type Backend interface { SetupLog(ctx context.Context, s pier.Session, lines int) (string, error) SpawnCreate(repoRoot, branch string) (string, error) SpawnBake(repoRoot string) (string, error) - SetReady(ctx context.Context, repoRoot string, n int, progress pier.Progress) (string, error) - DrainReady(ctx context.Context, repo string, progress pier.Progress) (int, error) + SpawnFill(repoRoot string) (string, error) + SetPoolSize(ctx context.Context, repoRoot string, n int, progress pier.Progress) (string, error) + DrainPool(ctx context.Context, repo string, progress pier.Progress) (int, error) Settings() []pier.SettingValue Set(key, value string) error MachineCatalog(currentType string) []pier.Machine @@ -274,7 +275,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.loaded, m.authRequired = true, false m.all = msg.all - m.sessions, m.ready = pier.SplitReady(msg.all) + m.sessions, m.ready = pier.SplitPool(msg.all) m.repos = m.be.Repos(msg.all, m.opts.RepoRoot) m.sessIdx = clamp(m.sessIdx, len(m.sessions)) m.repoIdx = clamp(m.repoIdx, len(m.repos)) @@ -666,39 +667,49 @@ func (m model) keyRepos(k tea.KeyMsg) (tea.Model, tea.Cmd) { }), nil case "+", "=": if !r.Current { - m.fail(errors.New("refills need the repo's checkout — run pier from inside " + r.Name)) + m.fail(errors.New("the pool fills from the repo's checkout — run pier from inside " + r.Name)) return m, nil } - return m.setReady(r, r.ReadyTarget+1) + return m.setPoolSize(r, r.PoolSize+1) case "-", "_": - if r.ReadyTarget == 0 { + if r.PoolSize == 0 { return m, nil } - if !r.Current && r.ReadyTarget > 1 { - m.fail(errors.New("change " + r.Name + "'s count from inside the repo; x removes them from anywhere")) + if !r.Current { + m.fail(errors.New("set " + r.Name + "'s pool size from inside the repo; x drains it from anywhere")) + return m, nil + } + return m.setPoolSize(r, r.PoolSize-1) + case "f": + if !r.Current { + m.fail(errors.New("the pool fills from the repo's checkout — run pier from inside " + r.Name)) + return m, nil + } + if r.PoolSize == 0 { + m.note(r.Name + " has no pool — + sets its size") return m, nil } - return m.setReady(r, r.ReadyTarget-1) + root := m.opts.RepoRoot + return m, act(func() doneMsg { + log, err := m.be.SpawnFill(root) + return doneMsg{note: "filling " + r.Name + "'s pool · log " + tilde(log), err: err, refresh: true, watch: true} + }) case "x": - if r.Ready+r.Filling+r.Stale == 0 && r.ReadyTarget == 0 { - m.note(r.Name + " has no ready sessions") + if r.Ready+r.Filling+r.Stale == 0 { + m.note(r.Name + "'s pool is empty") return m, nil } - return m.confirm("stop keeping ready sessions for "+r.Name+" and remove the parked ones?", func() tea.Cmd { - root := r.Name - if r.Current { - root = m.opts.RepoRoot - } + return m.confirm("drain "+r.Name+"'s pool — destroy its parked sessions? (the pool size stays; the next create refills it)", func() tea.Cmd { return act(func() doneMsg { - _, err := m.be.SetReady(context.Background(), root, 0, nil) - return doneMsg{note: "no ready sessions for " + r.Name, err: err, refresh: true} + n, err := m.be.DrainPool(context.Background(), r.Name, nil) + return doneMsg{note: "drained " + strconv.Itoa(n) + " from " + r.Name + "'s pool", err: err, refresh: true} }) }), nil } return m, nil } -func (m model) setReady(r pier.Repo, n int) (tea.Model, tea.Cmd) { +func (m model) setPoolSize(r pier.Repo, n int) (tea.Model, tea.Cmd) { if n > 8 { return m, nil } @@ -708,10 +719,10 @@ func (m model) setReady(r pier.Repo, n int) (tea.Model, tea.Cmd) { } cost := m.be.DiskMonthlyUSD() return m, act(func() doneMsg { - _, err := m.be.SetReady(context.Background(), root, n, nil) - note := "no ready sessions for " + r.Name + _, err := m.be.SetPoolSize(context.Background(), root, n, nil) + note := "pool off for " + r.Name if n > 0 { - note = "keeping " + strconv.Itoa(n) + " ready for " + r.Name + " · each ~$" + strconv.Itoa(int(cost+0.5)) + "/mo parked · filling in the background" + note = "pool size " + strconv.Itoa(n) + " for " + r.Name + " · each ~$" + strconv.Itoa(int(cost+0.5)) + "/mo parked · filling in the background" } return doneMsg{note: note, err: err, refresh: true, watch: n > 0} }) @@ -812,7 +823,7 @@ func (m model) save(val string) (tea.Model, tea.Cmd) { return reopenedMsg{be, err} } case strings.HasPrefix(f.Key, "speed."): - // Ready-session targets derive from these: rebuild the Repos rows. + // Pool sizes derive from these: rebuild the Repos rows. m.repos = m.be.Repos(m.all, m.opts.RepoRoot) } return m, nil diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e614239..cfc4ef3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -23,6 +23,7 @@ type fake struct { removed []string kept []string spawned []string + filled []string ready map[string]int setErr error } @@ -41,7 +42,7 @@ func newFake() *fake { }, repos: []pier.Repo{ {Name: "flb-estimation", Current: true, Image: "ami-1", Baked: &config.ImageInfo{BakedAt: now.Add(-62 * 24 * time.Hour), RepoIncluded: true}, - ReadyTarget: 1, Ready: 1, Sessions: 2, MonthlyUSD: 9.6, + PoolSize: 1, Ready: 1, Sessions: 2, MonthlyUSD: 9.6, Reminders: []pier.Reminder{{Message: "flb-estimation's session image is 62 days old. Rebake so new sessions start with current dependencies.", Action: "pier bake"}}}, {Name: "shop", Sessions: 1}, }, @@ -81,11 +82,15 @@ func (f *fake) SpawnCreate(root, branch string) (string, error) { return "/tmp/create-" + branch + ".log", nil } func (f *fake) SpawnBake(string) (string, error) { return "/tmp/bake.log", nil } -func (f *fake) SetReady(_ context.Context, root string, n int, _ pier.Progress) (string, error) { +func (f *fake) SpawnFill(root string) (string, error) { + f.filled = append(f.filled, root) + return "/tmp/pool.log", nil +} +func (f *fake) SetPoolSize(_ context.Context, root string, n int, _ pier.Progress) (string, error) { f.ready[root] = n return "", nil } -func (f *fake) DrainReady(context.Context, string, pier.Progress) (int, error) { return 0, nil } +func (f *fake) DrainPool(context.Context, string, pier.Progress) (int, error) { return 0, nil } func (f *fake) Settings() []pier.SettingValue { var out []pier.SettingValue for _, fl := range config.Settings { @@ -156,16 +161,16 @@ func settle(t *testing.T, m model, cmd tea.Cmd) model { return m } -// Ready sessions are inventory, not work: they never sit in the session +// Pooled sessions are inventory, not work: they never sit in the session // table, but the header counts them. -func TestReadySessionsStayOutOfTheTable(t *testing.T) { +func TestPooledSessionsStayOutOfTheTable(t *testing.T) { m := loaded(t, newFake(), 140, 32) v := m.View() if strings.Contains(v, "pool-flb-1") { t.Error("a ready session must not render as a session row") } - if !strings.Contains(v, "1 ready") || !strings.Contains(v, "checkout-flow") { - t.Errorf("header must count ready sessions and the table must list real ones:\n%s", v) + if !strings.Contains(v, "1 pooled") || !strings.Contains(v, "checkout-flow") { + t.Errorf("header must count pooled sessions and the table must list real ones:\n%s", v) } } @@ -254,14 +259,14 @@ func TestTabsAndRemindersBadge(t *testing.T) { t.Fatal("tab must move to Repos") } v := m.View() - for _, want := range []string{"flb-estimation", "prebuilt", "62 days old", "1/1"} { + for _, want := range []string{"flb-estimation", "prebuilt", "62 days old", "1/1", "POOL"} { if !strings.Contains(v, want) { t.Errorf("Repos tab missing %q:\n%s", want, v) } } } -func TestReadyCountChangesOnlyFromTheRepo(t *testing.T) { +func TestPoolKeys(t *testing.T) { f := newFake() m := loaded(t, f, 140, 32) m, _ = press(t, m, "tab") @@ -272,7 +277,18 @@ func TestReadyCountChangesOnlyFromTheRepo(t *testing.T) { } m, _ = press(t, m, "down", "+") if !m.statusBad { - t.Error("+ on another repo must explain that refills need its checkout") + t.Error("+ on another repo must explain that fills need its checkout") + } + m, _ = press(t, m, "up", "f") + if _, cmd := press(t, m, "f"); cmd != nil { + settle(t, m, cmd) + } + if len(f.filled) == 0 { + t.Error("f on this repo must fill its pool now") + } + m, _ = press(t, m, "x") + if m.ov != ovConfirm || !strings.Contains(m.confirmQ, "drain flb-estimation") { + t.Errorf("x must ask before draining, got %q", m.confirmQ) } } @@ -281,7 +297,7 @@ func TestSettingsGroupedAndSavable(t *testing.T) { m := loaded(t, f, 120, 40) m, _ = press(t, m, "s") v := m.View() - for _, want := range []string{"Cloud", "New sessions", "Idle & cost", "Speed", "park after", "ready sessions"} { + for _, want := range []string{"Cloud", "New sessions", "Idle & cost", "Speed", "park after", "pool size"} { if !strings.Contains(v, want) { t.Errorf("settings missing %q:\n%s", want, v) } @@ -300,8 +316,8 @@ func TestSettingsGroupedAndSavable(t *testing.T) { t.Fatal("enter on a choice must open the picker") } m, _ = press(t, m, "down", "enter") - if f.cfg.Profile() != "lean" || f.cfg.Speed.ReadySessions != 0 { - t.Errorf("choosing Lean must apply the preset, got %s ready=%d", f.cfg.Profile(), f.cfg.Speed.ReadySessions) + if f.cfg.Profile() != "lean" || f.cfg.Speed.PoolSize != 0 { + t.Errorf("choosing Lean must apply the preset, got %s ready=%d", f.cfg.Profile(), f.cfg.Speed.PoolSize) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 72e681e..4b198ba 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -94,7 +94,7 @@ func (m model) header() string { meta = append(meta, fmt.Sprintf("%d parked", parked)) } if len(m.ready) > 0 { - meta = append(meta, fmt.Sprintf("%d ready", len(m.ready))) + meta = append(meta, fmt.Sprintf("%d pooled", len(m.ready))) } if hourly > 0 { meta = append(meta, fmt.Sprintf("~$%.2f/h", hourly)) @@ -287,7 +287,7 @@ func (m model) repoList(w, h int) string { nameW = max(nameW, lipgloss.Width(r.Name)) } nameW = min(nameW, max(inner/3, 12)) - lines = append(lines, sDim.Render(fit(" REPO", nameW+2)+" "+fit("IMAGE", 18)+" "+fit("READY", 7)+" IDLE COST")) + lines = append(lines, sDim.Render(fit(" REPO", nameW+2)+" "+fit("IMAGE", 18)+" "+fit("POOL", 7)+" IDLE COST")) for i, r := range m.repos { mark := " " if len(r.Reminders) > 0 { @@ -299,7 +299,7 @@ func (m model) repoList(w, h int) string { mark = sWarn.Render("›") } } - row := mark + " " + fit(r.Name, nameW) + " " + fit(imageCell(r), 18) + " " + fit(readyCell(r), 7) + " " + sFaint.Render(money(r.MonthlyUSD)) + row := mark + " " + fit(r.Name, nameW) + " " + fit(imageCell(r), 18) + " " + fit(poolCell(r), 7) + " " + sFaint.Render(money(r.MonthlyUSD)) if i == m.repoIdx { row = selected(row, inner) } @@ -325,11 +325,11 @@ func imageCell(r pier.Repo) string { return kind + sDim.Render(" · "+pier.Age(r.Baked.BakedAt, time.Now())) } -func readyCell(r pier.Repo) string { - if r.ReadyTarget == 0 && r.Ready+r.Filling == 0 { +func poolCell(r pier.Repo) string { + if r.PoolSize == 0 && r.Ready+r.Filling == 0 { return sDim.Render("off") } - c := fmt.Sprintf("%d/%d", r.Ready, r.ReadyTarget) + c := fmt.Sprintf("%d/%d", r.Ready, r.PoolSize) if r.Filling > 0 { c += sAccent.Render("+") } @@ -364,8 +364,8 @@ func (m model) repoDetail(w, h int) string { } lines = append(lines, field("image", what+sDim.Render(" · baked "+ago(r.Baked.BakedAt)))) } - ready := fmt.Sprintf("keep %d", r.ReadyTarget) - if r.ReadyTarget == 0 { + ready := fmt.Sprintf("size %d", r.PoolSize) + if r.PoolSize == 0 { ready = "off" } var parts []string @@ -381,7 +381,7 @@ func (m model) repoDetail(w, h int) string { if len(parts) > 0 { ready += sDim.Render(" · " + strings.Join(parts, ", ")) } - lines = append(lines, field("ready", ready)) + lines = append(lines, field("pool", ready)) lines = append(lines, field("sessions", strconv.Itoa(r.Sessions))) lines = append(lines, field("idle cost", money(r.MonthlyUSD)+sDim.Render(" while nothing runs"))) lines = append(lines, "") @@ -399,10 +399,10 @@ func (m model) repoDetail(w, h int) string { } var help string if r.Current { - help = "b bakes the image (background). +/- sets ready sessions: parked, set-up copies that make the next session start in ~25s, each " + - money(m.be.DiskMonthlyUSD()) + " while waiting. x removes them." + help = "b bakes the image (background). The pool holds parked, set-up sessions a new one claims in ~25s, each " + + money(m.be.DiskMonthlyUSD()) + " while waiting: +/- sets its size, f fills it now, x drains it." } else { - help = "Bake and refill from inside this repo. x removes its ready sessions from anywhere." + help = "Bake, size and fill this repo's pool from inside it. x drains its pool from anywhere." } for _, l := range wrap(help, iw) { lines = append(lines, sDim.Render(l)) @@ -530,7 +530,7 @@ func (m model) footer() string { case m.tab == tabSessions: k = []string{"enter", "attach", "n", "new", "l", "logs", "m", "resize", "p", "keep", "d", "delete", "tab", "repos"} case m.tab == tabRepos: - k = []string{"b", "bake", "+/-", "ready", "x", "remove ready", "tab", "settings"} + k = []string{"b", "bake", "+/-", "pool size", "f", "fill", "x", "drain", "tab", "settings"} default: k = []string{"enter", "change", "↑↓", "move", "tab", "sessions"} } @@ -630,8 +630,9 @@ func (m model) helpView() string { "", sec("Repos"), row("b", "bake this repo's session image"), - row("+ -", "ready sessions: parked, set-up copies for ~25s starts"), - row("x", "remove a repo's ready sessions"), + row("+ -", "pool size: parked, set-up sessions new ones claim in ~25s"), + row("f", "fill the pool now"), + row("x", "drain a repo's pool"), "", sDim.Render(" Sessions park themselves when you're away: only the disk costs money."), sDim.Render(" The CLI does all of this too — `pier help`."), diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index a81558e..9d2fde8 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -401,13 +401,13 @@ func profileLabel(p string) string { func profileNote(cfg config.Config, p string, disk int) string { switch p { case "fast": - return fmt.Sprintf("image + 1 ready session per repo · new sessions in ~25s · ~$%.0f/mo per repo idle", diskMonthly(cfg, disk)+2) + return fmt.Sprintf("image + a pool of 1 per repo · new sessions in ~25s · ~$%.0f/mo per repo idle", diskMonthly(cfg, disk)+2) case "lean": return "image per repo · new sessions in ~1-2 min · ~$2/mo per repo idle" case "minimal": return "nothing stored · every session runs the full setup · $0 idle" } - return fmt.Sprintf("%d ready session(s) per repo, bake reminders %s", cfg.Speed.ReadySessions, onOff(cfg.Speed.BakeReminders)) + return fmt.Sprintf("a pool of %d per repo, bake reminders %s", cfg.Speed.PoolSize, onOff(cfg.Speed.BakeReminders)) } func onOff(b bool) string { diff --git a/pkg/pier/account.go b/pkg/pier/account.go index eede10a..2617a3e 100644 --- a/pkg/pier/account.go +++ b/pkg/pier/account.go @@ -11,15 +11,15 @@ import ( ) // Doctor runs the cloud driver's environment and account checks, plus -// pier's own: ready sessions nothing will claim, and ones stuck filling. +// pier's own: pooled sessions nothing will claim, and ones stuck filling. func (c *Client) Doctor(ctx context.Context) []Check { checks := c.drv.Doctor(ctx) sessions, err := c.drv.List(ctx) if err != nil { return checks } - // Orphans are parked money nothing will ever claim: repos whose ready - // sessions were turned off (or another machine's config). Members + // Orphans are parked money nothing will ever claim: repos whose pool + // was turned off (or another machine's config). Members // unparked past the fill grace are worse — a refill died before its // member parked, and it bills at full rate until the next refill // reconciles it away. @@ -36,21 +36,21 @@ func (c *Client) Doctor(ctx context.Context) []Check { } for repo, n := range orphans { checks = append(checks, Check{ - Name: "ready sessions " + repo, - Detail: fmt.Sprintf("%d ready session(s) but none configured — `pier ready 0` in %s removes them", n, repo), + Name: "pool " + repo, + Detail: fmt.Sprintf("%d pooled session(s) but no pool configured — `pier pool drain %s` removes them", n, repo), }) } for repo, n := range stuck { checks = append(checks, Check{ - Name: "ready sessions " + repo, - Detail: fmt.Sprintf("%d stuck filling for 2h+ — running at full price; `pier ready 0` in %s removes them", n, repo), + Name: "pool " + repo, + Detail: fmt.Sprintf("%d stuck filling for 2h+ — running at full price; `pier pool drain %s` removes them", n, repo), }) } return checks } -// Teardown destroys every ready session, then all pier groundwork and -// images in the account, and forgets the images and ready-session settings. +// Teardown destroys every pooled session, then all pier groundwork and +// images in the account, and forgets the images and pool settings. // Live sessions block it: the driver refuses while instances exist. func (c *Client) Teardown(ctx context.Context, progress Progress) error { start := time.Now() diff --git a/pkg/pier/client.go b/pkg/pier/client.go index 0d41e0c..34c8a7f 100644 --- a/pkg/pier/client.go +++ b/pkg/pier/client.go @@ -1,6 +1,6 @@ // Package pier is the one API every pier frontend uses: the CLI, the TUI, // and (through `pier api`) the Mac app. It owns every capability — sessions, -// repos and their images, ready sessions, settings, setup checks — and never +// repos and their images, pooled sessions, settings, setup checks — and never // prints, exits, or reads stdin. Frontends render what it returns; long // operations report progress as Events through a callback. // diff --git a/pkg/pier/create.go b/pkg/pier/create.go index d561fc9..1cad9cd 100644 --- a/pkg/pier/create.go +++ b/pkg/pier/create.go @@ -21,25 +21,25 @@ type CreateRequest struct { // Idle and Cap override the configured park timeouts for this session // (nil = config; 0 = never). Idle, Cap *time.Duration - // NoReady skips claiming one of the repo's ready sessions. - NoReady bool + // NoPool skips claiming one of the repo's pooled sessions. + NoPool bool Progress Progress } // CreateResult is a finished create. type CreateResult struct { Session Session - Claimed bool // came from a ready session rather than a fresh launch - // RefillLog is set when a background refill of the repo's ready - // sessions started; RefillErr when one should have but couldn't. - RefillLog string - RefillErr error + Claimed bool // came from a pooled session rather than a fresh launch + // FillLog is set when a background refill of the repo's ready + // sessions started; FillErr when one should have but couldn't. + FillLog string + FillErr error } -// Create makes a session: it claims one of the repo's ready sessions when +// Create makes a session: it claims one of the repo's pooled sessions when // there is one (resume, branch, secrets, setup re-run — seconds), and // otherwise launches fresh from the repo's image. Either way the repo's -// ready sessions refill in the background. A create that fails leaves a +// pooled sessions refill in the background. A create that fails leaves a // failed row behind (its instance is already gone), so it never vanishes // without trace. func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, error) { @@ -77,7 +77,7 @@ func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, e " — .pier/bake.sh toolchains will be missing and .pier/setup.sh may fail; `pier bake` fixes it") } - poolable := c.cfg.PoolSize(repo) > 0 && !req.NoReady + poolable := c.cfg.PoolSize(repo) > 0 && !req.NoPool var res CreateResult if poolable { pp, err := c.poolParams(req.RepoRoot, req.Progress, start) @@ -87,14 +87,14 @@ func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, e pp.IdleTimeout, pp.UnattendedCap = idle, cap_ sess, err := pool.Claim(ctx, pp, sessions, req.Branch, req.Branch, base) if err != nil { - // A ready session accelerates, never gates: whatever broke the + // A pooled session accelerates, never gates: whatever broke the // claim, the fresh create below gives the same answer or a session. req.Progress.emit(start, EventWarn, "claim failed: "+err.Error()) } if sess != nil { res.Session, res.Claimed = *sess, true } else { - step("no ready session to claim — creating fresh") + step("nothing in the pool to claim — creating fresh") } } if !res.Claimed { @@ -110,9 +110,9 @@ func (c *Client) Create(ctx context.Context, req CreateRequest) (CreateResult, e res.Session = *sess } if poolable { - // Top the ready sessions back up — after a claim (one was consumed) + // Top the pooled sessions back up — after a claim (one was consumed) // and after a fallback create (there were too few) alike. - res.RefillLog, res.RefillErr = c.SpawnRefill(req.RepoRoot) + res.FillLog, res.FillErr = c.SpawnFill(req.RepoRoot) } return res, nil } diff --git a/pkg/pier/repos.go b/pkg/pier/repos.go index e509a38..a300e09 100644 --- a/pkg/pier/repos.go +++ b/pkg/pier/repos.go @@ -29,10 +29,10 @@ type Repo struct { // Baked is what the image was baked from; nil for images baked before // pier recorded it (or no image). Baked *config.ImageInfo - // ReadyTarget is how many ready sessions pier keeps; Ready are parked and + // PoolSize is how many pooled sessions pier keeps; Ready are parked and // claimable, Filling are being set up, Stale will recycle. - ReadyTarget, Ready, Filling, Stale int - ReadyAges []string + PoolSize, Ready, Filling, Stale int + PoolAges []string Sessions int // live sessions of this repo MonthlyUSD float64 // idle cost estimate: ready disks + image storage Reminders []Reminder @@ -44,8 +44,8 @@ type Reminder struct { Action string `json:"action"` // the command that addresses it } -// Repos returns every repo pier knows about — baked, with ready sessions or -// ready-session settings, with live sessions, or the one this process runs +// Repos returns every repo pier knows about — baked, with pooled sessions or +// pool settings, with live sessions, or the one this process runs // from — sorted by name. sessions is a Sessions() result, so the page and // the census never disagree. func (c *Client) Repos(sessions []Session, curRoot string) []Repo { @@ -77,12 +77,12 @@ func (c *Client) Repos(sessions []Session, curRoot string) []Repo { now := time.Now() var out []Repo for name := range names { - st := FoldReady(sessions, name, cur, curGen, maxAge, now) + st := FoldPool(sessions, name, cur, curGen, maxAge, now) r := Repo{ Name: name, Current: name == cur, Image: c.cfg.BakedImage(name), - ReadyTarget: c.cfg.PoolSize(name), - Ready: st.Ready, Filling: st.Filling, Stale: st.Stale, ReadyAges: st.Ages, + PoolSize: c.cfg.PoolSize(name), + Ready: st.Ready, Filling: st.Filling, Stale: st.Stale, PoolAges: st.Ages, } if info, ok := c.cfg.Images[name]; ok && r.Image != "" { r.Baked = &info @@ -123,20 +123,20 @@ func imageMonthlyUSD(image string) float64 { return 2 } -// ReadyStats is one repo's ready-session census. -type ReadyStats struct { +// PoolStats is one repo's pool census. +type PoolStats struct { Ready, Filling, Stale int - Ages []string // ready sessions' ages, as listed + Ages []string // pooled sessions' ages, as listed } -// FoldReady folds one repo's unclaimed ready sessions out of a session list, +// FoldPool folds one repo's unclaimed pooled sessions out of a session list, // judging them the way the pool's reconcile would: dead, wrong-generation, // over the recycle age, or unparked past the fill grace all count stale — a // member whose fill died hours ago must not read "filling" forever. curGen // gates the generation check: only the repo the process runs from can // compute its current generation. maxAge 0 skips the age check. -func FoldReady(sessions []Session, repo, curRepo, curGen string, maxAge time.Duration, now time.Time) ReadyStats { - var st ReadyStats +func FoldPool(sessions []Session, repo, curRepo, curGen string, maxAge time.Duration, now time.Time) PoolStats { + var st PoolStats for _, s := range sessions { if s.PoolGen == "" || s.Repo != repo { continue @@ -254,7 +254,7 @@ type BakeRequest struct { type BakeResult struct { Image string RepoIncluded bool - // ReadyStale is set when the repo's ready sessions were built on the + // ReadyStale is set when the repo's pooled sessions were built on the // previous image; they recycle on the next claim or refill. ReadyStale bool } @@ -343,10 +343,10 @@ func (c *Client) poolGen(repoRoot string) string { return pp.Gen } -// SetReady saves how many ready sessions to keep for the repo at repoRoot, -// then reconciles: 0 destroys its ready sessions now, more starts a +// SetReady saves how many pooled sessions to keep for the repo at repoRoot, +// then reconciles: 0 destroys its pooled sessions now, more starts a // background refill. Returns the refill log path ("" for 0). -func (c *Client) SetReady(ctx context.Context, repoRoot string, n int, progress Progress) (string, error) { +func (c *Client) SetPoolSize(ctx context.Context, repoRoot string, n int, progress Progress) (string, error) { repo := filepath.Base(repoRoot) next, err := config.Update(func(cfg *config.Config) error { cfg.SetPoolSize(repo, n) @@ -357,15 +357,15 @@ func (c *Client) SetReady(ctx context.Context, repoRoot string, n int, progress } c.cfg = next if n == 0 { - _, err := c.DrainReady(ctx, repo, progress) + _, err := c.DrainPool(ctx, repo, progress) return "", err } - return c.SpawnRefill(repoRoot) + return c.SpawnFill(repoRoot) } -// DrainReady destroys a repo's unclaimed ready sessions (any repo; no local +// DrainReady destroys a repo's unclaimed pooled sessions (any repo; no local // checkout needed) and returns how many went down. -func (c *Client) DrainReady(ctx context.Context, repo string, progress Progress) (int, error) { +func (c *Client) DrainPool(ctx context.Context, repo string, progress Progress) (int, error) { sessions, err := c.drv.List(ctx) if err != nil { return 0, err @@ -373,8 +373,8 @@ func (c *Client) DrainReady(ctx context.Context, repo string, progress Progress) return pool.Drain(ctx, c.drv, sessions, repo, progress.stepper(time.Now())) } -// Refill tops the repo's ready sessions up to target, in the foreground. -func (c *Client) Refill(ctx context.Context, repoRoot string, progress Progress) error { +// Refill tops the repo's pooled sessions up to target, in the foreground. +func (c *Client) FillPool(ctx context.Context, repoRoot string, progress Progress) error { pp, err := c.poolParams(repoRoot, progress, time.Now()) if err != nil { return err @@ -382,23 +382,21 @@ func (c *Client) Refill(ctx context.Context, repoRoot string, progress Progress) return pool.Fill(ctx, pp) } -// RefillLogPath is where a background refill of repo writes. -func RefillLogPath(repo string) string { - return filepath.Join(config.Dir(), "logs", "ready-"+repo+".log") +// FillLogPath is where a background refill of repo writes. +func PoolLogPath(repo string) string { + return filepath.Join(config.Dir(), "logs", "pool-"+repo+".log") } // SpawnRefill re-execs the binary's hidden refill command as a detached // child working in repoRoot, so a refill survives the frontend exiting. -func (c *Client) SpawnRefill(repoRoot string) (string, error) { - logPath := RefillLogPath(filepath.Base(repoRoot)) +func (c *Client) SpawnFill(repoRoot string) (string, error) { + logPath := PoolLogPath(filepath.Base(repoRoot)) // Append, never truncate: a claim can spawn a refill while the previous // one is still writing here, and clobbering a live writer's log turns // both runs into confetti. Refills are rare enough that growth is noise. - return logPath, spawnDetached(repoRoot, logPath, true, RefillCommand) + return logPath, spawnDetached(repoRoot, logPath, true, "pool", "fill") } -// RefillCommand is the hidden subcommand SpawnRefill re-execs. -const RefillCommand = "__refill" // BakeLogPath is where a background bake of repo writes. func BakeLogPath(repo string) string { diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index a62fbd9..3dfcc5d 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -23,7 +23,7 @@ import ( // Sessions is the one list every surface shows: the cloud's sessions with // their supervisor beacons read (working/idle, strain, setup state), plus a // failed row per create that died before becoming a session. Unclaimed -// ready sessions are included — SplitReady separates them. +// pooled sessions are included — SplitPool separates them. func (c *Client) Sessions(ctx context.Context) ([]Session, error) { sessions, err := c.drv.List(ctx) if err != nil { @@ -37,10 +37,10 @@ func (c *Client) Sessions(ctx context.Context) ([]Session, error) { return merged, nil } -// SplitReady separates real sessions from unclaimed ready sessions (warm +// SplitPool separates real sessions from unclaimed pooled sessions (warm // pool members): those are inventory, not work, and never sit in the // session table. -func SplitReady(all []Session) (sessions, ready []Session) { +func SplitPool(all []Session) (sessions, ready []Session) { for _, s := range all { if s.PoolGen != "" { ready = append(ready, s) @@ -56,7 +56,7 @@ func SplitReady(all []Session) (sessions, ready []Session) { func (c *Client) enrich(ctx context.Context, sessions []Session) { var wg sync.WaitGroup for i := range sessions { - // Ready sessions run headless between create and park; their beacon + // Pooled sessions run headless between create and park; their beacon // detail is nobody's business until they're claimed. if sessions[i].State != driver.StateRunning || sessions[i].PoolGen != "" { continue @@ -130,7 +130,7 @@ func mergeTombstones(sessions []Session, recs []tombstone.Record) (merged []Sess } // Match resolves a user-typed session name: an exact name wins, otherwise a -// unique substring. Ready sessions are never matched by name. +// unique substring. Pooled sessions are never matched by name. func (c *Client) Match(ctx context.Context, query string) (Session, error) { sessions, err := c.Sessions(ctx) if err != nil { diff --git a/pkg/pier/sessions_test.go b/pkg/pier/sessions_test.go index d818fa3..60ecd69 100644 --- a/pkg/pier/sessions_test.go +++ b/pkg/pier/sessions_test.go @@ -58,10 +58,10 @@ func TestRetryAttachOnlyForQuickSSHTransportFailure(t *testing.T) { } } -// FoldReady classifies members the way reconcile would; the generation check +// FoldPool classifies members the way reconcile would; the generation check // applies only to the repo whose local checkout produced curGen — other // repos' gens are unknowable here. -func TestFoldReady(t *testing.T) { +func TestFoldPool(t *testing.T) { now := time.Now() ss := []Session{ {Repo: "shop", PoolGen: "aaa", State: StateParked, Created: now.Add(-time.Hour)}, @@ -72,10 +72,10 @@ func TestFoldReady(t *testing.T) { {Repo: "shop", State: StateRunning, Created: now}, // a real session {Repo: "tools", PoolGen: "zzz", State: StateParked, Created: now}, } - if st := FoldReady(ss, "shop", "shop", "aaa", 0, now); st.Ready != 1 || st.Filling != 1 || st.Stale != 2 { + if st := FoldPool(ss, "shop", "shop", "aaa", 0, now); st.Ready != 1 || st.Filling != 1 || st.Stale != 2 { t.Errorf("shop = %+v, want 1 ready, 1 filling, 2 stale", st) } - if st := FoldReady(ss, "tools", "shop", "aaa", 0, now); st.Ready != 1 || st.Stale != 0 { + if st := FoldPool(ss, "tools", "shop", "aaa", 0, now); st.Ready != 1 || st.Stale != 0 { t.Errorf("tools = %+v, want its member ready (gen unknowable from here)", st) } @@ -86,10 +86,10 @@ func TestFoldReady(t *testing.T) { {Repo: "tools", PoolGen: "zzz", State: StateParked, Created: now.Add(-30 * 24 * time.Hour)}, {Repo: "tools", PoolGen: "zzz", State: StateCreating, Created: now.Add(-3 * time.Hour)}, } - if st := FoldReady(old, "tools", "", "", 14*24*time.Hour, now); st.Ready != 0 || st.Filling != 0 || st.Stale != 2 { + if st := FoldPool(old, "tools", "", "", 14*24*time.Hour, now); st.Ready != 0 || st.Filling != 0 || st.Stale != 2 { t.Errorf("old members = %+v, want both stale (over-age + grace-blown)", st) } - if st := FoldReady(old[:1], "tools", "", "", 0, now); st.Ready != 1 { + if st := FoldPool(old[:1], "tools", "", "", 0, now); st.Ready != 1 { t.Errorf("maxAge 0 = %+v, want the age check skipped", st) } } diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index f42d504..208a423 100644 --- a/skills/pier-onboard/SKILL.md +++ b/skills/pier-onboard/SKILL.md @@ -78,7 +78,7 @@ fail** — start with `set -euo pipefail`, don't swallow errors. **It must be safe to re-run on a warm disk.** `pier bake` runs it once on a checkout and images the result, and every new session from that image runs it again, over the dependencies, build caches and volumes the earlier run -left. A claimed ready session and a woken parked session never re-run it: +left. A claimed pooled session and a woken parked session never re-run it: their containers and data are already there. Use installs that are incremental when nothing changed (`pnpm install`, `poetry sync`, `docker compose build`, `docker compose up -d`), and never fail on "already exists". @@ -145,8 +145,8 @@ committed (but a new, not-yet-committed `.pier/setup.sh` still travels). watch `pier ls` — `(setup running)` should clear; if it shows `(setup failed)`, attach and read `~/.pier-setup.log`. - If `.pier/setup.sh` is heavy (long installs, docker pulls, migrations), - mention ready sessions: once the repo is baked, pier keeps parked, - setup-complete sessions to claim (one by default; `pier ready ` or the + mention pooled sessions: once the repo is baked, pier keeps parked, + setup-complete sessions to claim (one by default; `pier pool set ` or the app's Repos tab changes it), so new sessions skip the wait (~$3-4/mo each, - disk only). Changing `.pier/setup.sh` later is safe — ready sessions + disk only). Changing `.pier/setup.sh` later is safe — pooled sessions notice and recycle on the next claim or refill. From 2ef4b6b627194b19d2be38b0d12c2ae8c36b8f21 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 13:58:46 +0300 Subject: [PATCH 15/24] fix: steady session states, and claims read starting until usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row shows one word per session — starting, running, parked, deleting or create failed. The supervisor's activity reading (attached, working, idle) changes every few seconds, so it moved to the app's detail pane, and "attached" appears only when the supervisor actually reports a terminal; an unreadable status no longer reads as attached. `pier ls --json` keeps its raw states. A claim used to list its session as parked while resuming the stopped pool member. The claim now clears the ready marker at its commit point and MarkReady sets it once the session is usable, so it reads starting in between, like a fresh create; a claim that died midway reads parked again after 15 minutes. Background refreshes no longer flash the spinner. --- README.md | 5 +++-- cmd/pier/main.go | 29 +++++++++++-------------- cmd/pier/main_test.go | 2 +- internal/driver/awsec2/create.go | 3 +-- internal/driver/awsec2/driver.go | 6 ++++++ internal/driver/awsec2/pool.go | 11 ++++++++-- internal/driver/driver.go | 12 +++++++++++ internal/driver/gcpgce/create.go | 3 +-- internal/driver/gcpgce/driver.go | 6 ++++++ internal/driver/gcpgce/pool.go | 8 ++++++- internal/pool/claim.go | 6 +++++- internal/tui/style.go | 16 +++++++------- internal/tui/tui.go | 3 ++- internal/tui/tui_test.go | 23 ++++++++++++++++++++ internal/tui/view.go | 10 ++++----- pkg/pier/create.go | 2 +- pkg/pier/repos.go | 13 ++++++------ pkg/pier/sessions.go | 36 ++++++++++++++++++++++++++++++++ 18 files changed, 142 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5560958..ab69843 100644 --- a/README.md +++ b/README.md @@ -517,8 +517,9 @@ and auto-scaling is a surprise-cost footgun. The cloud says "running" long before a session is usable, so pier doesn't: -- A session lists as `creating` until the bootstrap's last act marks the - instance ready (a tag on AWS, a label on GCP). +- A session lists as `starting` until the bootstrap's last act marks the + instance ready (a tag on AWS, a label on GCP). A session claimed from the + pool reads `starting` too, until it's resumed and on your branch. - Attaching early gets a plain "still setting up", not a raw connection error. - Creates from the app run in the background and the row flips when ready. - A deleted session lists as `deleting` until the cloud actually removes it. diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 7601a24..4e328a2 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -489,15 +489,10 @@ func cmdLS(args []string) { } } -// stateLabel renders the state plus the supervisor's strain and setup flags. +// stateLabel renders the row's state plus the supervisor's strain and setup +// flags. func stateLabel(s pier.Session) string { - if s.State == pier.StateFailed { - // "create failed", never a bare "failed": the neighbouring rows say - // "setup failed" for a live session whose setup script died, and the - // two mean very different things — one has a VM, this one does not. - return "create failed" - } - l := string(s.State) + l := pier.Label(s) if s.Strained { l += " (strained)" } @@ -753,15 +748,15 @@ func cmdResize(args []string) { // --- repos ------------------------------------------------------------------------ type repoJSON struct { - Name string `json:"name"` - Image string `json:"image"` - BakedAt *time.Time `json:"baked_at"` - PoolSize int `json:"ready_target"` - Ready int `json:"ready"` - Filling int `json:"filling"` - Sessions int `json:"sessions"` - MonthlyUSD float64 `json:"monthly_usd"` - Reminders []pier.Reminder `json:"reminders"` + Name string `json:"name"` + Image string `json:"image"` + BakedAt *time.Time `json:"baked_at"` + PoolSize int `json:"ready_target"` + Ready int `json:"ready"` + Filling int `json:"filling"` + Sessions int `json:"sessions"` + MonthlyUSD float64 `json:"monthly_usd"` + Reminders []pier.Reminder `json:"reminders"` } func cmdRepos(args []string) { diff --git a/cmd/pier/main_test.go b/cmd/pier/main_test.go index 2f7bf90..ee05824 100644 --- a/cmd/pier/main_test.go +++ b/cmd/pier/main_test.go @@ -17,7 +17,7 @@ func TestStateLabelDistinguishesCreateFromSetupFailure(t *testing.T) { t.Errorf(`want "create failed", got %q`, failed) } setup := stateLabel(driver.Session{State: driver.StateIdle, Setup: "failed"}) - if !strings.Contains(setup, "idle") || !strings.Contains(setup, "setup failed") { + if !strings.Contains(setup, "running") || !strings.Contains(setup, "setup failed") { t.Errorf("a live session with a dead setup script must still read as running, got %q", setup) } } diff --git a/internal/driver/awsec2/create.go b/internal/driver/awsec2/create.go index b52a156..f803592 100644 --- a/internal/driver/awsec2/create.go +++ b/internal/driver/awsec2/create.go @@ -128,8 +128,7 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv // without it reads as still-creating everywhere. A failed tag write // fails the create (defer cleans up) rather than leave a session that // looks stuck forever. - if _, err := d.aws(ctx, "ec2", "create-tags", "--resources", id, - "--tags", "Key="+TagReady+",Value=1"); err != nil { + if err := d.MarkReady(ctx, id); err != nil { return nil, fmt.Errorf("marking session ready: %w", err) } diff --git a/internal/driver/awsec2/driver.go b/internal/driver/awsec2/driver.go index 685bff3..13612e7 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -174,6 +174,12 @@ func (d *Driver) List(ctx context.Context) ([]driver.Session, error) { default: s.State = driver.StateDead } + // A claimed session is stopped, untagged-ready and fresh while its + // claim resumes it: it's starting, not parked. + if s.State == driver.StateParked && !ready && s.PoolGen == "" && + !s.Created.IsZero() && time.Since(s.Created) < driver.ClaimWindow { + s.State = driver.StateCreating + } // Launch time is the fallback for pre-tag sessions; it resets on // every resume, so the created tag wins whenever present. if s.Created.IsZero() { diff --git a/internal/driver/awsec2/pool.go b/internal/driver/awsec2/pool.go index 686aa70..b8b2526 100644 --- a/internal/driver/awsec2/pool.go +++ b/internal/driver/awsec2/pool.go @@ -53,8 +53,15 @@ func (d *Driver) Claim(ctx context.Context, id string, spec driver.ClaimSpec) er return err } // Deleting the pool tag is the commit point: the member now lists as a - // regular session. + // regular session — starting, since the ready tag goes with it until the + // claim's MarkReady. _, err = d.aws(ctx, "ec2", "delete-tags", "--resources", id, - "--tags", "Key="+TagPool, "Key="+TagClaim) + "--tags", "Key="+TagPool, "Key="+TagClaim, "Key="+TagReady) + return err +} + +func (d *Driver) MarkReady(ctx context.Context, id string) error { + _, err := d.aws(ctx, "ec2", "create-tags", "--resources", id, + "--tags", "Key="+TagReady+",Value=1") return err } diff --git a/internal/driver/driver.go b/internal/driver/driver.go index ceac2e9..85e5969 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -45,6 +45,7 @@ type Session struct { Driver string State State Strained bool // sustained cpu/mem pressure (supervisor beacon) — resize hint + Attached bool // a terminal is attached right now (supervisor beacon; false when unknown) Setup string // repo setup script: "" | "running" | "failed" (supervisor beacon) InstanceType string // provider machine type; feeds the TUI resize picker Created time.Time // session creation, not last boot — AGE must never go backward @@ -82,6 +83,11 @@ type CreateSpec struct { Progress func(step string) } +// ClaimWindow bounds how long a claimed-but-not-ready session lists as +// starting. A claim that died before MarkReady (the laptop went away) must +// not leave a parked session reading "starting" forever. +const ClaimWindow = 15 * time.Minute + // ErrClaimLost: another claimer won this pool member — move on to the next // candidate. var ErrClaimLost = errors.New("pool member claimed by another process") @@ -170,6 +176,12 @@ type Driver interface { // the instance itself. Claim(ctx context.Context, id string, spec ClaimSpec) error + // MarkReady writes the ready tag/label: the session is attachable. It's + // a create's last act, and a claim's — Claim clears it at its commit + // point, so a claimed session lists as starting, not parked, while it + // resumes and freshens. + MarkReady(ctx context.Context, id string) error + // Resize changes the instance size (vertical scaling). Providers only // allow this on stopped instances, and park is exactly a stop — so a // running session rides one park+resume cycle (~40s down); a parked one diff --git a/internal/driver/gcpgce/create.go b/internal/driver/gcpgce/create.go index 0885aa9..b33c6b7 100644 --- a/internal/driver/gcpgce/create.go +++ b/internal/driver/gcpgce/create.go @@ -114,8 +114,7 @@ func (d *Driver) Create(ctx context.Context, spec driver.CreateSpec) (sess *driv // without it reads as still-creating everywhere. A failed label write // fails the create (defer cleans up) rather than leave a session that // looks stuck forever. - if _, err := d.gcloud(ctx, "compute", "instances", "add-labels", id, - "--zone", d.Zone, "--labels", LabelReady+"=1"); err != nil { + if err := d.MarkReady(ctx, id); err != nil { return nil, fmt.Errorf("marking session ready: %w", err) } diff --git a/internal/driver/gcpgce/driver.go b/internal/driver/gcpgce/driver.go index bb9b3b4..d561fe6 100644 --- a/internal/driver/gcpgce/driver.go +++ b/internal/driver/gcpgce/driver.go @@ -232,6 +232,12 @@ func (d *Driver) List(ctx context.Context) ([]driver.Session, error) { s.Created = ts } } + // A claimed session is stopped, unlabeled-ready and fresh while its + // claim resumes it: it's starting, not parked. + if s.State == driver.StateParked && in.Labels[LabelReady] != "1" && s.PoolGen == "" && + !s.Created.IsZero() && time.Since(s.Created) < driver.ClaimWindow { + s.State = driver.StateCreating + } s.CostNote = costNote(s.State, s.InstanceType) sessions = append(sessions, s) } diff --git a/internal/driver/gcpgce/pool.go b/internal/driver/gcpgce/pool.go index 6f75aa0..76d1863 100644 --- a/internal/driver/gcpgce/pool.go +++ b/internal/driver/gcpgce/pool.go @@ -56,6 +56,12 @@ func (d *Driver) Claim(ctx context.Context, id string, spec driver.ClaimSpec) er // Removing the pool label is the commit point: the member now lists as a // regular session. _, err = d.gcloud(ctx, "compute", "instances", "remove-labels", id, - "--zone", d.Zone, "--labels", LabelPool+","+LabelClaim) + "--zone", d.Zone, "--labels", LabelPool+","+LabelClaim+","+LabelReady) + return err +} + +func (d *Driver) MarkReady(ctx context.Context, id string) error { + _, err := d.gcloud(ctx, "compute", "instances", "add-labels", id, + "--zone", d.Zone, "--labels", LabelReady+"=1") return err } diff --git a/internal/pool/claim.go b/internal/pool/claim.go index ac6e9d0..577fc4b 100644 --- a/internal/pool/claim.go +++ b/internal/pool/claim.go @@ -60,7 +60,11 @@ func Claim(ctx context.Context, p Params, sessions []driver.Session, name, branc return nil, nil } progress(fmt.Sprintf("claimed pooled session %s — resuming", member.Name)) - if err := freshen(ctx, p, member, name, branch, baseRef); err != nil { + err = freshen(ctx, p, member, name, branch, baseRef) + if err == nil { + err = p.Driver.MarkReady(ctx, member.ID) + } + if err != nil { progress("freshen failed (" + err.Error() + ")") progress("destroying that pooled session — its state is untrusted — and creating fresh") if derr := p.Driver.Destroy(context.WithoutCancel(ctx), member.ID); derr != nil { diff --git a/internal/tui/style.go b/internal/tui/style.go index 0fa2f49..376c081 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -114,18 +114,16 @@ func keys(pairs ...string) string { var spinFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} -// badge is a session's state as a colored dot and word, with the -// supervisor's strain and setup flags appended. +// badge is a session's state as a colored dot and word — one of starting, +// running, parked, deleting, create failed — with the supervisor's strain +// and setup flags appended. The finer activity reading lives in the detail +// pane: it changes every few seconds and would make rows flicker. func badge(s pier.Session, frame int) (dot, word string) { switch s.State { - case pier.StateWorking: - dot, word = sOK.Render("●"), sOK.Render("working") - case pier.StateRunning: - dot, word = sAccent.Render("●"), sAccent.Render("attached") - case pier.StateIdle: - dot, word = sWarn.Render("●"), sWarn.Render("idle") + case pier.StateRunning, pier.StateWorking, pier.StateIdle: + dot, word = sOK.Render("●"), sOK.Render("running") case pier.StateCreating: - dot, word = sAccent.Render(spinFrames[frame%len(spinFrames)]), sAccent.Render("creating") + dot, word = sAccent.Render(spinFrames[frame%len(spinFrames)]), sAccent.Render("starting") case pier.StateParked: dot, word = sDim.Render("○"), sDim.Render("parked") case pier.StateDeleting: diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1e6f726..4f27e34 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -289,8 +289,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case pollMsg: + // Background refreshes stay quiet: the spinner is for the first load + // and for refreshes the user asked for. m.polling = false - m.loading = true return m, m.fetch case doneMsg: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index cfc4ef3..c359065 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -407,3 +407,26 @@ func TestWrapLines(t *testing.T) { } } } + +// Rows show one stable word per session; the supervisor's activity reading +// (attached/working/idle) is detail, and a background poll shows no spinner. +func TestRowsDontFlicker(t *testing.T) { + f := newFake() + m := loaded(t, f, 140, 32) + v := m.View() + for _, line := range strings.Split(v, "\n") { + list, _, _ := strings.Cut(line, "││") // the list panel is the left one + for _, word := range []string{"working", "idle", "attached"} { + if strings.Contains(list, " "+word) { + t.Errorf("the session list must not show the activity word %q: %q", word, list) + } + } + } + if !strings.Contains(v, "running") { + t.Error("running sessions must read running") + } + next, _ := m.Update(pollMsg{}) + if next.(model).loading { + t.Error("a background poll must not show the spinner") + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 4b198ba..f7543b9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -238,12 +238,10 @@ func (m model) sessionDetail(w, h int) string { } func stateNote(s pier.Session) string { - switch s.State { - case pier.StateWorking: - return sDim.Render(" · agent busy, you're detached") - case pier.StateIdle: - return sDim.Render(" · parks when the idle timer runs out") - case pier.StateParked: + if a := pier.Activity(s); a != "" { + return sDim.Render(" · " + a) + } + if s.State == pier.StateParked { return sDim.Render(" · disk only, nothing running") } return "" diff --git a/pkg/pier/create.go b/pkg/pier/create.go index 1cad9cd..e04e239 100644 --- a/pkg/pier/create.go +++ b/pkg/pier/create.go @@ -22,7 +22,7 @@ type CreateRequest struct { // (nil = config; 0 = never). Idle, Cap *time.Duration // NoPool skips claiming one of the repo's pooled sessions. - NoPool bool + NoPool bool Progress Progress } diff --git a/pkg/pier/repos.go b/pkg/pier/repos.go index a300e09..2d7984f 100644 --- a/pkg/pier/repos.go +++ b/pkg/pier/repos.go @@ -32,10 +32,10 @@ type Repo struct { // PoolSize is how many pooled sessions pier keeps; Ready are parked and // claimable, Filling are being set up, Stale will recycle. PoolSize, Ready, Filling, Stale int - PoolAges []string - Sessions int // live sessions of this repo - MonthlyUSD float64 // idle cost estimate: ready disks + image storage - Reminders []Reminder + PoolAges []string + Sessions int // live sessions of this repo + MonthlyUSD float64 // idle cost estimate: ready disks + image storage + Reminders []Reminder } // Reminder is one piece of rebake guidance. pier never acts on it. @@ -80,9 +80,9 @@ func (c *Client) Repos(sessions []Session, curRoot string) []Repo { st := FoldPool(sessions, name, cur, curGen, maxAge, now) r := Repo{ Name: name, Current: name == cur, - Image: c.cfg.BakedImage(name), + Image: c.cfg.BakedImage(name), PoolSize: c.cfg.PoolSize(name), - Ready: st.Ready, Filling: st.Filling, Stale: st.Stale, PoolAges: st.Ages, + Ready: st.Ready, Filling: st.Filling, Stale: st.Stale, PoolAges: st.Ages, } if info, ok := c.cfg.Images[name]; ok && r.Image != "" { r.Baked = &info @@ -397,7 +397,6 @@ func (c *Client) SpawnFill(repoRoot string) (string, error) { return logPath, spawnDetached(repoRoot, logPath, true, "pool", "fill") } - // BakeLogPath is where a background bake of repo writes. func BakeLogPath(repo string) string { return filepath.Join(config.Dir(), "logs", "bake-"+repo+".log") diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index 3dfcc5d..27b3ba0 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -97,6 +97,8 @@ func (c *Client) enrich(ctx context.Context, sessions []Session) { s.State = driver.StateWorking case "idle": s.State = driver.StateIdle + case "attached": + s.Attached = true } s.Strained = st.Strained s.Setup = st.Setup @@ -396,3 +398,37 @@ func freePort() (int, error) { defer l.Close() return l.Addr().(*net.TCPAddr).Port, nil } + +// Label is the one word a session's row shows: starting, running, parked, +// deleting or create failed. The supervisor's finer activity reading +// (attached, working, idle) changes every few seconds and is detail, not +// state — Activity has it. +func Label(s Session) string { + switch s.State { + case driver.StateCreating: + return "starting" + case driver.StateRunning, driver.StateWorking, driver.StateIdle: + return "running" + case driver.StateFailed: + // "create failed", never a bare "failed": a running session whose + // setup script died reads "setup failed", and the two mean very + // different things — one has a VM, this one does not. + return "create failed" + } + return string(s.State) +} + +// Activity describes what a running session is doing, when the supervisor +// has said: "you're attached", "agent working, you're detached", "quiet, +// parks itself when the idle timer runs out". "" when unknown. +func Activity(s Session) string { + switch { + case s.Attached: + return "you're attached" + case s.State == driver.StateWorking: + return "agent working, you're detached" + case s.State == driver.StateIdle: + return "quiet — parks itself when the idle timer runs out" + } + return "" +} From 9a62276c065f3e50e1c5d8111481bf8480d0c0cb Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 13:59:01 +0300 Subject: [PATCH 16/24] docs: show the steady states in the README examples --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab69843..84dedb0 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,7 @@ resize` fixes it without losing anything: ``` $ pier ls NAME REPO STATE AGE COST -checkout-flow shop working (strained) 2h ~$0.04/h +checkout-flow shop running (strained) 2h ~$0.04/h $ pier resize checkout-flow t4g.xlarge ``` From bd74fa6b9dce3a57b24b988de47e73709deea933 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 14:18:36 +0300 Subject: [PATCH 17/24] fix: faster first load, attach that stays in the app, no phantom attaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public-IP lookup that gates direct ssh depended on one service; where a network filters it (secure gateways and VPNs do), every command waited out its timeout and fell back to the tunnel. Three services are now raced with a two-second cap. The list call also returns each instance's address and security group, so the probes that follow it skip their own describe call, and the app shows the list as soon as it lands and fills in each session's status after. Attaching from the app stays on screen ("connecting…") until the VM answers, then hands over the terminal; failures show in the app. A session whose ssh key isn't on this machine fails at once with a clear error instead of ssh retrying for minutes. sshd never timed out a dead connection, so a dropped attach left a phantom tmux client: the session read as attached and could never park. Sessions now drop dead connections after about a minute. The new-session input is a plain text field. --- internal/driver/awsec2/cli.go | 14 ++++ internal/driver/awsec2/direct.go | 99 ++++++++++++++++++++++----- internal/driver/awsec2/direct_test.go | 40 +++++++++++ internal/driver/awsec2/driver.go | 15 +++- internal/driver/driver.go | 5 ++ internal/driver/gcpgce/cli.go | 18 +++++ internal/driver/gcpgce/driver.go | 3 + internal/driver/gcpgce/driver_test.go | 23 +++++++ internal/driver/payload/scripts.go | 8 +++ internal/tui/tui.go | 66 +++++++++++++++--- internal/tui/tui_test.go | 21 +++++- internal/tui/view.go | 4 +- pkg/pier/events.go | 3 + pkg/pier/sessions.go | 23 ++++++- 14 files changed, 309 insertions(+), 33 deletions(-) diff --git a/internal/driver/awsec2/cli.go b/internal/driver/awsec2/cli.go index 29aa83f..e6b5d87 100644 --- a/internal/driver/awsec2/cli.go +++ b/internal/driver/awsec2/cli.go @@ -9,6 +9,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/usepier/pier/internal/driver" ) // aws runs the AWS CLI (the tool already requires it for the SSM plugin, so @@ -53,6 +55,15 @@ func LoginExpired(err error) bool { // inbound ports, slow) whenever direct can't work. aws.direct = false // forces the tunnel. +// needKey fails fast with ErrNoKey when the session's key isn't here: ssh +// would otherwise retry an unreachable-looking host for minutes. +func (d *Driver) needKey(id string) error { + if _, err := os.Stat(d.keyPath(id)); err != nil { + return fmt.Errorf("%w (%s) — it lives only on the machine that created the session", driver.ErrNoKey, d.keyPath(id)) + } + return nil +} + func (d *Driver) keyPath(id string) string { return filepath.Join(d.StateDir, "keys", id+".pem") } @@ -105,6 +116,9 @@ func (d *Driver) sshRun(ctx context.Context, id, script string) (string, error) // sshRunOpts is sshRun with extra ssh flags — the bootstrap passes -A when // the workspace fetch rides the laptop's ssh agent. func (d *Driver) sshRunOpts(ctx context.Context, id string, extra []string, script string) (string, error) { + if err := d.needKey(id); err != nil { + return "", err + } args := append(append(d.sshOpts(ctx, id), extra...), "agent@"+id, script) out, err := exec.CommandContext(ctx, "ssh", args...).CombinedOutput() if err != nil { diff --git a/internal/driver/awsec2/direct.go b/internal/driver/awsec2/direct.go index 8c718b6..c94fb9b 100644 --- a/internal/driver/awsec2/direct.go +++ b/internal/driver/awsec2/direct.go @@ -68,14 +68,46 @@ func (d *Driver) directIP(ctx context.Context, id string) string { return ip } -func (d *Driver) probeDirect(ctx context.Context, id string) (string, time.Duration) { +// instanceInfo is what the probe needs about an instance, as a List saw it. +type instanceInfo struct { + ip, sg, launch string + at time.Time +} + +// noteInstance records what a List returned, so probes right after it (the +// beacon reads that follow every list) skip their own describe call. +func (d *Driver) noteInstance(id, ip, sg, launch string) { + d.dmu.Lock() + defer d.dmu.Unlock() + if d.known == nil { + d.known = map[string]instanceInfo{} + } + d.known[id] = instanceInfo{ip: ip, sg: sg, launch: launch, at: time.Now()} +} + +// instanceFields returns [ip, sg, launch] for id: from the last List when it +// is fresh, otherwise from one describe call. +func (d *Driver) instanceFields(ctx context.Context, id string) ([]string, error) { + d.dmu.Lock() + info, ok := d.known[id] + d.dmu.Unlock() + if ok && time.Since(info.at) < 30*time.Second && info.ip != "" { + return []string{info.ip, info.sg, info.launch}, nil + } out, err := d.aws(ctx, "ec2", "describe-instances", "--instance-ids", id, "--query", "Reservations[0].Instances[0].[PublicIpAddress,SecurityGroups[0].GroupId,LaunchTime]", "--output", "text") + if err != nil { + return nil, err + } + return strings.Fields(out), nil +} + +func (d *Driver) probeDirect(ctx context.Context, id string) (string, time.Duration) { + f, err := d.instanceFields(ctx, id) if err != nil { return "", directTTL } - f := strings.Fields(out) if len(f) != 3 || f[0] == "None" || f[1] == "None" { // No public address (yet): still pending, parked, or a private // subnet. Cheap to re-check. @@ -99,7 +131,7 @@ func (d *Driver) probeDirect(ctx context.Context, id string) (string, time.Durat if booting || strings.Contains(err.Error(), "refused") { return "", directBootTTL } - d.directNotice(id, "direct connect: "+ip+":22 does not answer from this network — using the ssm tunnel") + d.directNotice(id, "direct connect: "+ip+":22 does not answer from this network — using the slower ssm tunnel (a VPN such as Cloudflare WARP changes the address AWS sees; turning it off restores the direct path)") return "", directTTL } c.Close() @@ -112,6 +144,7 @@ func (d *Driver) probeDirect(ctx context.Context, id string) (string, time.Durat func (d *Driver) dropProbe(id string) { d.dmu.Lock() delete(d.dprobe, id) + delete(d.known, id) // the list's copy carries the old address too d.dmu.Unlock() } @@ -225,9 +258,20 @@ func (d *Driver) ensureDirectRule(ctx context.Context, sg string) error { return nil } -// callerPublicIP asks checkip.amazonaws.com — over IPv4, so the answer is -// the address the security group will see when ssh dials the instance's -// IPv4. +// ipServices answer with the caller's public IPv4 as plain text. Several, +// raced: any one of them can be filtered or slow on a given network (VPNs +// and secure gateways block some), and the answer gates every direct +// connection — a single slow service made every command wait out its timeout +// and then fall back to the tunnel. +var ipServices = []string{ + "https://checkip.amazonaws.com", + "https://api.ipify.org", + "https://ipv4.icanhazip.com", +} + +// callerPublicIP asks the ipServices in parallel, over IPv4 (the address the +// security group sees when ssh dials the instance's IPv4), and takes the +// first plausible answer. func (d *Driver) callerPublicIP(ctx context.Context) (string, error) { d.dmu.Lock() if d.myIP != "" && time.Now().Before(d.myIPUntil) { @@ -238,34 +282,53 @@ func (d *Driver) callerPublicIP(ctx context.Context) (string, error) { d.dmu.Unlock() client := &http.Client{ - Timeout: 5 * time.Second, + Timeout: 2 * time.Second, Transport: &http.Transport{ DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { - return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "tcp4", addr) + return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "tcp4", addr) }, }, } - req, err := http.NewRequestWithContext(ctx, "GET", "https://checkip.amazonaws.com", nil) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + answers := make(chan string, len(ipServices)) + for _, u := range ipServices { + go func(u string) { + answers <- lookupIP(ctx, client, u) + }(u) + } + for range ipServices { + if ip := <-answers; ip != "" { + d.dmu.Lock() + d.myIP, d.myIPUntil = ip, time.Now().Add(directTTL) + d.dmu.Unlock() + return ip, nil + } + } + return "", fmt.Errorf("public IP lookup: none of %d services answered", len(ipServices)) +} + +// lookupIP asks one service; "" when it fails or answers something that +// isn't an IPv4 address. +func lookupIP(ctx context.Context, client *http.Client, url string) string { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return "", err + return "" } resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("public IP lookup: %w", err) + return "" } defer resp.Body.Close() b, err := io.ReadAll(io.LimitReader(resp.Body, 64)) - if err != nil { - return "", fmt.Errorf("public IP lookup: %w", err) + if err != nil || resp.StatusCode != http.StatusOK { + return "" } s := strings.TrimSpace(string(b)) if ip := net.ParseIP(s); ip == nil || ip.To4() == nil { - return "", fmt.Errorf("public IP lookup: implausible answer %q", s) + return "" } - d.dmu.Lock() - d.myIP, d.myIPUntil = s, time.Now().Add(directTTL) - d.dmu.Unlock() - return s, nil + return s } // shortUser is the caller's IAM name (the ARN's last segment), sanitized to diff --git a/internal/driver/awsec2/direct_test.go b/internal/driver/awsec2/direct_test.go index d47ac0b..f9a1a61 100644 --- a/internal/driver/awsec2/direct_test.go +++ b/internal/driver/awsec2/direct_test.go @@ -3,6 +3,11 @@ package awsec2 import ( "context" "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -44,6 +49,8 @@ func TestSSHOptsTransports(t *testing.T) { func TestAttachCommandFallsBackForUnknownTerminal(t *testing.T) { d := &Driver{StateDir: t.TempDir()} + os.MkdirAll(filepath.Join(d.StateDir, "keys"), 0o700) + os.WriteFile(d.keyPath("i-0abc"), []byte("k"), 0o600) cmd, err := d.AttachCommand(context.Background(), "i-0abc") if err != nil { t.Fatal(err) @@ -154,3 +161,36 @@ func TestSanitizeRuleName(t *testing.T) { } } } + +// One filtered or slow IP service must not make every connection wait out +// its timeout: the lookup races them and takes the first good answer. +func TestCallerPublicIPRacesServices(t *testing.T) { + hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(10 * time.Second): + } + })) + defer hang.Close() + junk := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "not an ip") + })) + defer junk.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "203.0.113.7\n") + })) + defer good.Close() + old := ipServices + ipServices = []string{hang.URL, junk.URL, good.URL} + t.Cleanup(func() { ipServices = old }) + + d := &Driver{} + start := time.Now() + ip, err := d.callerPublicIP(context.Background()) + if err != nil || ip != "203.0.113.7" { + t.Fatalf("want the good service's answer, got %q %v", ip, err) + } + if took := time.Since(start); took > time.Second { + t.Errorf("a hanging service must not hold the lookup up, took %s", took) + } +} diff --git a/internal/driver/awsec2/driver.go b/internal/driver/awsec2/driver.go index 13612e7..48c18ac 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -12,6 +12,7 @@ package awsec2 import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -78,6 +79,7 @@ type Driver struct { // direct-connect probe state (direct.go) dmu sync.Mutex dprobe map[string]directProbe + known map[string]instanceInfo // from the last List, for the probe myIP string myIPUntil time.Time ensured string // the cidr whose SG rule this process reconciled @@ -112,6 +114,8 @@ type ec2Instance struct { State string `json:"state"` Launch string `json:"launch"` IType string `json:"itype"` + IP string `json:"ip"` + SG string `json:"sg"` Tags []struct { Key string `json:"Key"` Value string `json:"Value"` @@ -126,7 +130,7 @@ func (d *Driver) List(ctx context.Context) ([]driver.Session, error) { out, err := d.aws(ctx, "ec2", "describe-instances", "--filters", "Name=tag:"+TagManaged+",Values=1", "Name=tag:"+TagUser+",Values="+me, "Name=instance-state-name,Values=pending,running,stopping,stopped", - "--query", "Reservations[].Instances[].{id:InstanceId,state:State.Name,launch:LaunchTime,itype:InstanceType,tags:Tags}", + "--query", "Reservations[].Instances[].{id:InstanceId,state:State.Name,launch:LaunchTime,itype:InstanceType,tags:Tags,ip:PublicIpAddress,sg:SecurityGroups[0].GroupId}", "--output", "json") if err != nil { return nil, err @@ -137,6 +141,9 @@ func (d *Driver) List(ctx context.Context) ([]driver.Session, error) { } var sessions []driver.Session for _, in := range raw { + // The direct-connect probe needs exactly what this call just + // returned; hand it over rather than describing each instance again. + d.noteInstance(in.ID, in.IP, in.SG, in.Launch) s := driver.Session{ID: in.ID, User: me, Driver: d.Name(), InstanceType: in.IType} ready := false for _, t := range in.Tags { @@ -321,6 +328,9 @@ func (d *Driver) Destroy(ctx context.Context, id string) error { // The one wait it does make is short and bounded: for the boot-time tmux // restore of a woken session to finish (see cmd/pier-supervisor/tmux.go). func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error) { + if err := d.needKey(id); err != nil { + return nil, err + } const remote = `[ -S "$SSH_AUTH_SOCK" ] && ln -sf "$SSH_AUTH_SOCK" ~/.ssh/agent.sock [ -e "$HOME/.pier-bootstrapped" ] || { echo "pier: this session is still setting up — attach again when it shows running in pier ls" >&2; exit 1; } # SSH forwards the client's TERM, but newer terminals (for example Ghostty) @@ -394,6 +404,9 @@ func (d *Driver) waitSSH(ctx context.Context, id string, timeout time.Duration) if err == nil { return nil } + if errors.Is(err, driver.ErrNoKey) { + return err // no key, no connection — retrying changes nothing + } if ctx.Err() != nil { return ctx.Err() } diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 85e5969..302fd7c 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -88,6 +88,11 @@ type CreateSpec struct { // not leave a parked session reading "starting" forever. const ClaimWindow = 15 * time.Minute +// ErrNoKey: this machine holds no ssh key for the session. Each session's +// key lives only on the laptop that created it, so another machine (or a +// cleared ~/.config/pier/keys) can't reach it — and retrying won't help. +var ErrNoKey = errors.New("no ssh key for this session on this machine") + // ErrClaimLost: another claimer won this pool member — move on to the next // candidate. var ErrClaimLost = errors.New("pool member claimed by another process") diff --git a/internal/driver/gcpgce/cli.go b/internal/driver/gcpgce/cli.go index db69ec3..2d7c3a6 100644 --- a/internal/driver/gcpgce/cli.go +++ b/internal/driver/gcpgce/cli.go @@ -3,6 +3,7 @@ package gcpgce import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -10,6 +11,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/usepier/pier/internal/driver" ) var ( @@ -99,6 +102,15 @@ func (d *Driver) user(ctx context.Context) (string, error) { // ~/.ssh/google_compute_engine key, and a passphrase on that key breaks // every non-interactive use. +// needKey fails fast with ErrNoKey when the session's key isn't here: ssh +// would otherwise retry an unreachable-looking host for minutes. +func (d *Driver) needKey(id string) error { + if _, err := os.Stat(d.keyPath(id)); err != nil { + return fmt.Errorf("%w (%s) — it lives only on the machine that created the session", driver.ErrNoKey, d.keyPath(id)) + } + return nil +} + func (d *Driver) keyPath(id string) string { return filepath.Join(d.StateDir, "keys", id+".pem") } @@ -135,6 +147,9 @@ func (d *Driver) sshRun(ctx context.Context, id, script string) (string, error) // sshRunOpts is sshRun with extra ssh flags — the bootstrap passes -A when // the workspace fetch rides the laptop's ssh agent. func (d *Driver) sshRunOpts(ctx context.Context, id string, extra []string, script string) (string, error) { + if err := d.needKey(id); err != nil { + return "", err + } args := append(append(d.sshOpts(id), extra...), "agent@"+id, script) out, err := execCommandContext(ctx, "ssh", args...).CombinedOutput() if err != nil { @@ -201,6 +216,9 @@ func (d *Driver) waitSSH(ctx context.Context, id string, timeout time.Duration) if err == nil { return nil } + if errors.Is(err, driver.ErrNoKey) { + return err // no key, no connection — retrying changes nothing + } if ctx.Err() != nil { return ctx.Err() } diff --git a/internal/driver/gcpgce/driver.go b/internal/driver/gcpgce/driver.go index d561fe6..bc4014a 100644 --- a/internal/driver/gcpgce/driver.go +++ b/internal/driver/gcpgce/driver.go @@ -383,6 +383,9 @@ func (d *Driver) Destroy(ctx context.Context, id string) error { // it does make is short and bounded: for the boot-time tmux restore of a // woken session to finish (see cmd/pier-supervisor/tmux.go). func (d *Driver) AttachCommand(ctx context.Context, id string) (*exec.Cmd, error) { + if err := d.needKey(id); err != nil { + return nil, err + } const remote = `[ -S "$SSH_AUTH_SOCK" ] && ln -sf "$SSH_AUTH_SOCK" ~/.ssh/agent.sock [ -e "$HOME/.pier-bootstrapped" ] || { echo "pier: this session is still setting up — attach again when it shows running in pier ls" >&2; exit 1; } # SSH forwards the client's TERM, but newer terminals (for example Ghostty) diff --git a/internal/driver/gcpgce/driver_test.go b/internal/driver/gcpgce/driver_test.go index b524d8c..5ccd8e1 100644 --- a/internal/driver/gcpgce/driver_test.go +++ b/internal/driver/gcpgce/driver_test.go @@ -3,6 +3,7 @@ package gcpgce import ( "context" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -87,8 +88,28 @@ func TestArchAndRegion(t *testing.T) { } } +// withKey gives a test driver a key file for id, as a real create leaves. +func withKey(t *testing.T, d *Driver, id string) { + t.Helper() + os.MkdirAll(filepath.Join(d.StateDir, "keys"), 0o700) + os.WriteFile(d.keyPath(id), []byte("k"), 0o600) +} + +// A session whose key isn't on this machine fails at once with ErrNoKey, +// instead of ssh retrying an unreachable-looking host. +func TestMissingKeyFailsFast(t *testing.T) { + d := &Driver{Project: "p", Zone: "z", StateDir: t.TempDir()} + if _, err := d.AttachCommand(context.Background(), "pier-x-abc123"); !errors.Is(err, driver.ErrNoKey) { + t.Errorf("attach without a key must return ErrNoKey, got %v", err) + } + if _, err := d.Exec(context.Background(), "pier-x-abc123", "true"); !errors.Is(err, driver.ErrNoKey) { + t.Errorf("exec without a key must return ErrNoKey, got %v", err) + } +} + func TestAttachCommandFallsBackForUnknownTerminal(t *testing.T) { d := &Driver{Project: "p", Zone: "z", StateDir: t.TempDir()} + withKey(t, d, "pier-x-abc123") cmd, err := d.AttachCommand(context.Background(), "pier-x-abc123") if err != nil { t.Fatal(err) @@ -244,6 +265,7 @@ func TestHelperProcess(t *testing.T) { case "ssh-keygen": if len(args) > 8 && args[1] == "-t" { keyPath := args[8] + os.WriteFile(keyPath, []byte("fake-private-key"), 0600) os.WriteFile(keyPath+".pub", []byte("fake-pub-key"), 0644) } case "ssh", "scp": @@ -425,6 +447,7 @@ func TestLifecycle(t *testing.T) { }) id := "pier-session-123" + withKey(t, d, id) // Park if err := d.Park(context.Background(), id); err != nil { diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index 0f0f142..0945501 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -43,6 +43,12 @@ runcmd: # out instead of failing the install (the block deliberately has no set # -e, so a lost race would otherwise skip a harness silently). echo 'DPkg::Lock::Timeout "120";' > /etc/apt/apt.conf.d/90pier + # sshd never times out a dead connection by default, so a dropped attach + # (a laptop sleeping, a VPN reconnecting) left a phantom tmux client: + # the session read as attached and could never park. Drop dead + # connections after ~1 min of silence. + printf 'ClientAliveInterval 15\nClientAliveCountMax 4\n' > /etc/ssh/sshd_config.d/10-pier.conf + systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true install -d -m 700 -o agent -g agent /home/agent/.ssh grep -qxF '{{PUBKEY}}' /home/agent/.ssh/authorized_keys 2>/dev/null || echo '{{PUBKEY}}' >> /home/agent/.ssh/authorized_keys chown agent:agent /home/agent/.ssh/authorized_keys && chmod 600 /home/agent/.ssh/authorized_keys @@ -152,6 +158,8 @@ func sessionUp(runSetup bool) string { # (user-data rewrites older rcs too, but on a baked image cloud-init is still # running when this does, and tmux must not start with the old line). sed -i 's#^cd ~/work/\* 2>/dev/null || true$#if [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi#' "$HOME/.bashrc" 2>/dev/null || true +# Sessions and pooled sessions made before sshd dropped dead connections. +[ -f /etc/ssh/sshd_config.d/10-pier.conf ] || { printf 'ClientAliveInterval 15\nClientAliveCountMax 4\n' | sudo tee /etc/ssh/sshd_config.d/10-pier.conf >/dev/null && { sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null || true; }; } ` + tmuxEnsure if runSetup { s += setupWindow diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4f27e34..e4dc7a6 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -22,7 +22,8 @@ import ( // can drive the app without a cloud. type Backend interface { Cloud() string - Sessions(ctx context.Context) ([]pier.Session, error) + ListSessions(ctx context.Context) ([]pier.Session, error) + Enrich(ctx context.Context, sessions []pier.Session) []pier.Session Repos(sessions []pier.Session, curRoot string) []pier.Repo Headroom(ctx context.Context) (pier.Quota, error) Remove(ctx context.Context, s pier.Session) error @@ -153,9 +154,12 @@ func newModel(opts Options, be Backend) model { // --- messages --------------------------------------------------------------------- +// sessionsMsg carries a list; enriched says whether the per-session status +// reads are in (the list shows first, statuses fill in after). type sessionsMsg struct { - all []pier.Session - err error + all []pier.Session + err error + enriched bool } type quotaMsg string type tickMsg struct{} @@ -197,8 +201,18 @@ func pollCmd() tea.Cmd { func (m model) fetch() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - all, err := m.be.Sessions(ctx) - return sessionsMsg{all, err} + all, err := m.be.ListSessions(ctx) + return sessionsMsg{all: all, err: err} +} + +// enrich reads the running sessions' statuses for a list already on screen. +func (m model) enrich(all []pier.Session) tea.Cmd { + be := m.be + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return sessionsMsg{all: be.Enrich(ctx, all), enriched: true} + } } func (m model) fetchQuota() tea.Msg { @@ -274,6 +288,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.loaded, m.authRequired = true, false + if !msg.enriched { + // Keep what the last status read said until the new one lands, + // so rows don't blink between states on every refresh. + msg.all = carryStatus(m.all, msg.all) + } m.all = msg.all m.sessions, m.ready = pier.SplitPool(msg.all) m.repos = m.be.Repos(msg.all, m.opts.RepoRoot) @@ -282,11 +301,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.watch > 0 { m.watch-- } + var cmds []tea.Cmd + if !msg.enriched { + cmds = append(cmds, m.enrich(msg.all)) + } if (m.inFlight() || m.watch > 0) && !m.polling { m.polling = true - return m, pollCmd() + cmds = append(cmds, pollCmd()) } - return m, nil + return m, tea.Batch(cmds...) case pollMsg: // Background refreshes stay quiet: the spinner is for the first load @@ -377,6 +400,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// carryStatus copies the previous status read onto a fresh list, for the +// sessions still in the same cloud state. +func carryStatus(prev, next []pier.Session) []pier.Session { + old := map[string]pier.Session{} + for _, s := range prev { + old[s.ID] = s + } + for i, s := range next { + p, ok := old[s.ID] + if !ok || s.ID == "" || s.State != pier.StateRunning { + continue + } + switch p.State { + case pier.StateRunning, pier.StateWorking, pier.StateIdle: + next[i].State, next[i].Attached = p.State, p.Attached + next[i].Strained, next[i].Setup = p.Strained, p.Setup + } + } + return next +} + func clamp(i, n int) int { if i >= n { i = n - 1 @@ -544,7 +588,13 @@ func (m model) startAttach(s pier.Session) (tea.Model, tea.Cmd) { m.note("resuming " + s.Name + " — about 20-60s, tmux comes back as you left it") return m, func() tea.Msg { return resumedMsg{s, m.be.Resume(context.Background(), s)} } } - return m.execAttach(s) + // Prove the VM answers while the app is still on screen: handing the + // terminal to an ssh that's still connecting (or will fail) looks like + // pier quit, and its errors land outside the app. + m.note("connecting to " + s.Name + "…") + return m, func() tea.Msg { + return reachableMsg{s, m.be.WaitReachable(context.Background(), s, 90*time.Second)} + } } func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c359065..02507e5 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -50,10 +50,11 @@ func newFake() *fake { } func (f *fake) Cloud() string { return "AWS eu-central-1" } -func (f *fake) Sessions(context.Context) ([]pier.Session, error) { +func (f *fake) ListSessions(context.Context) ([]pier.Session, error) { return f.sessions, nil } -func (f *fake) Repos([]pier.Session, string) []pier.Repo { return f.repos } +func (f *fake) Enrich(_ context.Context, s []pier.Session) []pier.Session { return s } +func (f *fake) Repos([]pier.Session, string) []pier.Repo { return f.repos } func (f *fake) Headroom(context.Context) (pier.Quota, error) { return pier.Quota{Detail: "10/32 vCPU"}, nil } @@ -118,7 +119,7 @@ func loaded(t *testing.T, f *fake, w, h int) model { m := newModel(Options{Open: func() (Backend, error) { return f, nil }, RepoRoot: "/code/flb-estimation", Version: "test"}, f) next, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: h}) m = next.(model) - all, _ := f.Sessions(context.Background()) + all, _ := f.ListSessions(context.Background()) next, _ = m.Update(sessionsMsg{all: all}) return next.(model) } @@ -430,3 +431,17 @@ func TestRowsDontFlicker(t *testing.T) { t.Error("a background poll must not show the spinner") } } + +// Attaching a running session first proves the VM answers, with the app +// still on screen — handing the terminal to a still-connecting ssh looks +// like pier quit. +func TestAttachConnectsBeforeHandingOverTheTerminal(t *testing.T) { + m := loaded(t, newFake(), 120, 30) + m, cmd := press(t, m, "enter") + if cmd == nil || !strings.Contains(m.status, "connecting to checkout-flow") { + t.Fatalf("enter must show it's connecting, got status %q", m.status) + } + if _, ok := cmd().(reachableMsg); !ok { + t.Error("the terminal must only be handed over once the VM answers") + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index f7543b9..863303f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -488,8 +488,8 @@ func (m model) sheet() string { var line string switch m.ov { case ovNew: - title = "New session from " + m.repo - line = sAccent.Render("branch ❯ ") + m.input + sAccent.Render("▌") + title = "New session" + line = sAccent.Render("❯ ") + m.input + sAccent.Render("▌") case ovConfirm: title = "Confirm" line = sWarn.Render(m.confirmQ) + " " + keys("y", "yes", "n", "no") diff --git a/pkg/pier/events.go b/pkg/pier/events.go index 7485ab6..ead42cd 100644 --- a/pkg/pier/events.go +++ b/pkg/pier/events.go @@ -3,6 +3,8 @@ package pier import ( "errors" "time" + + "github.com/usepier/pier/internal/driver" ) // EventKind classifies a progress event so frontends style it without @@ -49,4 +51,5 @@ var ( ErrCreateFailed = errors.New("session never finished creating") ErrDeleting = errors.New("session is being deleted") ErrParked = errors.New("session is parked") + ErrNoKey = driver.ErrNoKey ) diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index 27b3ba0..6e2dd30 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -25,11 +25,21 @@ import ( // failed row per create that died before becoming a session. Unclaimed // pooled sessions are included — SplitPool separates them. func (c *Client) Sessions(ctx context.Context) ([]Session, error) { + sessions, err := c.ListSessions(ctx) + if err != nil { + return nil, err + } + return c.Enrich(ctx, sessions), nil +} + +// ListSessions is Sessions without the per-session status reads: one cloud +// call plus the local failed-create records. A frontend shows it at once and +// fills in Enrich's answer when it lands. +func (c *Client) ListSessions(ctx context.Context) ([]Session, error) { sessions, err := c.drv.List(ctx) if err != nil { return nil, err } - c.enrich(ctx, sessions) merged, revived := mergeTombstones(sessions, tombstone.List(config.Dir())) for _, name := range revived { tombstone.Dismiss(config.Dir(), name) @@ -37,6 +47,14 @@ func (c *Client) Sessions(ctx context.Context) ([]Session, error) { return merged, nil } +// Enrich returns a copy of sessions with each running session's supervisor +// status read: activity, strain, setup state. +func (c *Client) Enrich(ctx context.Context, sessions []Session) []Session { + out := append([]Session(nil), sessions...) + c.enrich(ctx, out) + return out +} + // SplitPool separates real sessions from unclaimed pooled sessions (warm // pool members): those are inventory, not work, and never sit in the // session table. @@ -260,6 +278,9 @@ func (c *Client) WaitReachable(ctx context.Context, s Session, timeout time.Dura if err == nil { return nil } + if errors.Is(err, driver.ErrNoKey) { + return err // waiting won't conjure the key + } if ctx.Err() != nil { return ctx.Err() } From 5ef63f719aa28a4a29c19dc83b61e2efd6700569 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 14:37:00 +0300 Subject: [PATCH 18/24] fix(aws): describe the tunnel fallback without naming a product --- internal/driver/awsec2/direct.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/driver/awsec2/direct.go b/internal/driver/awsec2/direct.go index c94fb9b..9d56f0b 100644 --- a/internal/driver/awsec2/direct.go +++ b/internal/driver/awsec2/direct.go @@ -131,7 +131,7 @@ func (d *Driver) probeDirect(ctx context.Context, id string) (string, time.Durat if booting || strings.Contains(err.Error(), "refused") { return "", directBootTTL } - d.directNotice(id, "direct connect: "+ip+":22 does not answer from this network — using the slower ssm tunnel (a VPN such as Cloudflare WARP changes the address AWS sees; turning it off restores the direct path)") + d.directNotice(id, "direct connect: "+ip+":22 does not answer from this network — using the slower ssm tunnel (a VPN or secure gateway can change the address the VM sees)") return "", directTTL } c.Close() From 87bb3803b6ea7c45ddfe2db2726ce784e38023f3 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 16:24:34 +0300 Subject: [PATCH 19/24] feat(settings): pick the app's accent color One setting in a new Appearance group: teal (the default, the terminal's own cyan), navy, violet, emerald, orange, crimson, pink, amber or graphite. It recolors the app's borders, tabs, keys and highlights at once and the CLI's accents too. Dark accents draw the active tab's text in white. --- cmd/pier/main.go | 1 + internal/config/config.go | 32 +++++++++++++++++++++++++ internal/config/config_test.go | 13 ++++++++++ internal/tui/style.go | 27 ++++++++++++++------- internal/tui/tui.go | 9 +++++++ internal/tui/tui_test.go | 37 ++++++++++++++++++++++++++++ internal/tui/view.go | 8 +++++-- internal/ui/ui.go | 44 ++++++++++++++++++++++++++++++++++ internal/ui/ui_test.go | 14 +++++++++++ 9 files changed, 175 insertions(+), 10 deletions(-) diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 4e328a2..f56b1bc 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -192,6 +192,7 @@ func open() *pier.Client { if err != nil { fatal(err) } + ui.SetAccent(c.Config().Theme) return c } diff --git a/internal/config/config.go b/internal/config/config.go index 2b969a5..b6c3688 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -21,6 +22,7 @@ type Config struct { GCP GCP `toml:"gcp"` Pool Pool `toml:"pool"` Speed Speed `toml:"speed"` + Theme string `toml:"theme,omitempty"` // accent color; see Themes Secrets Secrets `toml:"secrets"` // Images records what each repo's session image was baked from — when, // and from which .pier scripts — so pier can remind (never decide) when a @@ -492,12 +494,25 @@ var ( diskOpts = []Option{{Value: "20", Label: "20 GiB"}, {Value: "40", Label: "40 GiB"}, {Value: "80", Label: "80 GiB"}, {Value: "160", Label: "160 GiB"}} ) +// Themes are the accent color names, in picker order. The colors live with +// the UI (internal/ui); config only validates the name. +var Themes = []string{"teal", "navy", "violet", "emerald", "orange", "crimson", "pink", "amber", "graphite"} + +func themeOptions() []Option { + opts := make([]Option, len(Themes)) + for i, t := range Themes { + opts[i] = Option{Value: t} + } + return opts +} + // Groups orders the settings page's sections. Every field belongs to one. var Groups = []struct{ Key, Title string }{ {"cloud", "Cloud"}, {"sessions", "New sessions"}, {"idle", "Idle & cost"}, {"speed", "Speed"}, + {"app", "Appearance"}, } // Settings lists the settable fields in display order, grouped by what the @@ -641,6 +656,12 @@ var Settings = []Field{ Options: []Option{{Value: "14d"}, {Value: "30d"}, {Value: "60d"}, {Value: "never"}}, Detail: "Once a repo's image is this old, pier suggests a rebake so new sessions start with current dependencies.", }, + { + Key: "theme", Group: "app", Label: "accent color", Hint: "the app's highlight color", + Kind: KindChoice, NoCustom: true, Default: "teal", Empty: "teal", + Options: themeOptions(), + Detail: "The color pier uses for its borders, tabs, keys and highlights, in the app and the CLI.", + }, } // Get returns the current value of a settable key ("" for unknown keys). @@ -672,6 +693,11 @@ func Get(c Config, key string) string { return c.GCP.MachineType case "gcp.disk_gib": return strconv.Itoa(c.GCP.DiskGiB) + case "theme": + if c.Theme == "" { + return "teal" + } + return c.Theme case "speed.profile": return c.Profile() case "speed.pool_size": @@ -786,6 +812,12 @@ func Set(c *Config, key, val string) error { default: return fmt.Errorf("aws.direct: want true or false (got %q)", val) } + case "theme": + v := strings.ToLower(strings.TrimSpace(val)) + if !slices.Contains(Themes, v) { + return fmt.Errorf("theme: want one of %s (got %q)", strings.Join(Themes, ", "), val) + } + c.Theme = v case "speed.profile": return c.ApplyProfile(strings.ToLower(strings.TrimSpace(val))) case "speed.pool_size": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d66f7c3..397824d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -275,3 +275,16 @@ func TestConfig_ClearBakes(t *testing.T) { }) } } + +func TestThemeSetting(t *testing.T) { + c := Default() + if err := Set(&c, "theme", "Violet"); err != nil || c.Theme != "violet" { + t.Fatalf("want violet, got %q %v", c.Theme, err) + } + if err := Set(&c, "theme", "chartreuse"); err == nil { + t.Error("an unknown color must be rejected") + } + if Get(c, "theme") != "violet" { + t.Error("Get must read the theme back") + } +} diff --git a/internal/tui/style.go b/internal/tui/style.go index 376c081..e590ffa 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/usepier/pier/internal/ui" "github.com/usepier/pier/pkg/pier" ) @@ -15,14 +16,14 @@ import ( // a soft highlight bar for the selected row (opencode); a colored state dot // per session (herdr). Everything else stays quiet. var ( - cAccent = lipgloss.Color("6") - cDim = lipgloss.Color("8") - cFaint = lipgloss.Color("7") - cOK = lipgloss.Color("2") - cWarn = lipgloss.Color("3") - cBad = lipgloss.Color("1") - cStrain = lipgloss.Color("208") - cSelBg = lipgloss.AdaptiveColor{Light: "254", Dark: "236"} + cAccent lipgloss.TerminalColor = lipgloss.Color("6") + cDim = lipgloss.Color("8") + cFaint = lipgloss.Color("7") + cOK = lipgloss.Color("2") + cWarn = lipgloss.Color("3") + cBad = lipgloss.Color("1") + cStrain = lipgloss.Color("208") + cSelBg = lipgloss.AdaptiveColor{Light: "254", Dark: "236"} sAccent = lipgloss.NewStyle().Foreground(cAccent) sBold = lipgloss.NewStyle().Bold(true) @@ -40,6 +41,16 @@ var ( sKey = lipgloss.NewStyle().Foreground(cAccent).Bold(true) ) +// applyTheme recolors every accent style for a theme name (ui.Themes). +func applyTheme(theme string) { + ui.SetAccent(theme) + cAccent = ui.AccentColor + sAccent = lipgloss.NewStyle().Foreground(cAccent) + sBrand = lipgloss.NewStyle().Foreground(cAccent).Bold(true) + sTabOn = lipgloss.NewStyle().Foreground(ui.OnAccent).Background(cAccent).Bold(true).Padding(0, 1) + sKey = lipgloss.NewStyle().Foreground(cAccent).Bold(true) +} + // panel draws a rounded box w wide and h tall with title set into the top // border and an optional right-aligned note beside it. Lines are clipped to // the inner width and padded; missing lines render blank. A focused panel diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e4dc7a6..1d24951 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -146,6 +146,11 @@ type model struct { func newModel(opts Options, be Backend) model { m := model{opts: opts, be: be, loading: true, settings: be.Settings()} + for _, f := range m.settings { + if f.Key == "theme" { + applyTheme(f.Value) + } + } if opts.RepoRoot != "" { m.repo = filepath.Base(opts.RepoRoot) } @@ -867,6 +872,10 @@ func (m model) save(val string) (tea.Model, tea.Cmd) { m.settings = m.be.Settings() m.setIdx = clamp(m.setIdx, len(m.settings)) m.note(f.Label + " saved — applies to new sessions") + if f.Key == "theme" { + applyTheme(val) + m.note("accent color saved") + } switch { case f.Key == "driver", strings.HasPrefix(f.Key, "aws."), strings.HasPrefix(f.Key, "gcp."): return m, func() tea.Msg { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 02507e5..111ce0b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -12,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/ui" "github.com/usepier/pier/pkg/pier" ) @@ -445,3 +446,39 @@ func TestAttachConnectsBeforeHandingOverTheTerminal(t *testing.T) { t.Error("the terminal must only be handed over once the VM answers") } } + +// The accent color is one setting: picking it saves it and recolors the app +// right away. +func TestAccentColorSetting(t *testing.T) { + f := newFake() + m := loaded(t, f, 120, 40) + m, _ = press(t, m, "s") + if !strings.Contains(m.View(), "Appearance") { + t.Fatal("settings must have the Appearance group") + } + for i, s := range m.settings { + if s.Key == "theme" { + m.setIdx = i + } + } + m, _ = press(t, m, "enter") + if m.ov != ovPick || len(m.pickOpts) != len(ui.ThemeNames) { + t.Fatalf("the accent color must offer every color, got ov=%v %d", m.ov, len(m.pickOpts)) + } + m, _ = press(t, m, "down", "enter") // teal → navy + if f.cfg.Theme != "navy" { + t.Errorf("want the navy theme saved, got %q", f.cfg.Theme) + } + if cAccent != ui.Themes["navy"] { + t.Error("the app must recolor at once") + } + applyTheme("teal") +} + +// Settings validates theme names; the UI owns their colors. They must list +// the same names in the same order. +func TestThemeNamesAgree(t *testing.T) { + if strings.Join(config.Themes, ",") != strings.Join(ui.ThemeNames, ",") { + t.Errorf("config themes %v != ui themes %v", config.Themes, ui.ThemeNames) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 863303f..2227ed2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -10,6 +10,7 @@ import ( "github.com/usepier/pier/internal/config" "github.com/usepier/pier/internal/tombstone" + "github.com/usepier/pier/internal/ui" "github.com/usepier/pier/pkg/pier" ) @@ -596,9 +597,12 @@ func (m model) pickView() string { if l == "" { l = o.Value } - row := fit(l, labelW) + " " + sDim.Render(o.Desc) + if f.Key == "theme" { + l = lipgloss.NewStyle().Foreground(ui.Themes[o.Value]).Render("■") + " " + l + } + row := fit(l, labelW+2) + " " + sDim.Render(o.Desc) if o.Value == f.Value { - row = fit(l, labelW) + " " + sDim.Render(o.Desc) + sAccent.Render(" ✓") + row = fit(l, labelW+2) + " " + sDim.Render(o.Desc) + sAccent.Render(" ✓") } if i == m.pickIdx { row = selected(row, w) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 328b998..97b4196 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -29,6 +29,50 @@ var ( MarginLeft(1) ) +// ThemeNames lists the accent colors in the order the settings picker shows +// them. Teal is the terminal's own cyan (it follows the user's palette); the +// rest are fixed tones. +var ThemeNames = []string{"teal", "navy", "violet", "emerald", "orange", "crimson", "pink", "amber", "graphite"} + +// Themes maps each accent color's name to its color. +var Themes = map[string]lipgloss.TerminalColor{ + "teal": lipgloss.Color("6"), + "navy": lipgloss.Color("#2B4FC5"), + "violet": lipgloss.Color("#6D28D9"), + "emerald": lipgloss.Color("#10B981"), + "orange": lipgloss.Color("#F97316"), + "crimson": lipgloss.Color("#E11D48"), + "pink": lipgloss.Color("#EC4899"), + "amber": lipgloss.Color("#F59E0B"), + "graphite": lipgloss.Color("#A1A1AA"), +} + +// darkThemes need light text where the accent is a background (the active +// tab); black on them is unreadable. +var darkThemes = map[string]bool{"navy": true, "violet": true, "crimson": true} + +// OnAccent is the text color for text drawn on the accent. +var OnAccent lipgloss.TerminalColor = lipgloss.Color("0") + +// AccentColor is the active accent; SetAccent changes it. +var AccentColor lipgloss.TerminalColor = lipgloss.Color("6") + +// SetAccent switches the accent to a theme by name (unknown names keep the +// default) and restyles the shared accent styles. +func SetAccent(theme string) { + c, ok := Themes[theme] + if !ok { + c = Themes["teal"] + } + AccentColor = c + OnAccent = lipgloss.Color("0") + if darkThemes[theme] { + OnAccent = lipgloss.Color("15") + } + Accent = lipgloss.NewStyle().Foreground(c) + Title = lipgloss.NewStyle().Foreground(c).Bold(true) +} + // Step renders a progress-step line: accent chevron, indented under the // command's bold header — the shared shape for every long-running command // (create, bake, resize). diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 0ab1403..bf3d7e8 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -2,6 +2,7 @@ package ui import ( "path/filepath" + "strings" "testing" ) @@ -21,3 +22,16 @@ func TestTilde(t *testing.T) { t.Errorf("sibling prefix: got %q", got) } } + +// The picker's names and the colors must agree, in the documented order. +func TestThemesAreComplete(t *testing.T) { + want := []string{"teal", "navy", "violet", "emerald", "orange", "crimson", "pink", "amber", "graphite"} + if strings.Join(ThemeNames, ",") != strings.Join(want, ",") { + t.Errorf("theme order: got %v", ThemeNames) + } + for _, n := range ThemeNames { + if Themes[n] == nil { + t.Errorf("no color for %s", n) + } + } +} From 17f1803d69373aa73bfef558f82ed5142bed96d7 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 16:54:43 +0300 Subject: [PATCH 20/24] fix(attach): show a connecting screen during the handover to ssh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaching hands the terminal to ssh, and until the session drew itself the user's own shell showed through — it read as if pier had quit. The handover now opens a blank screen reading "connecting to …" that tmux draws over. The CLI restores its screen after the detach; the app redraws itself. The attach's exit status is unchanged. --- cmd/pier/main.go | 1 + internal/tui/tui.go | 1 + pkg/pier/sessions.go | 21 +++++++++++++++++++++ pkg/pier/sessions_test.go | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/cmd/pier/main.go b/cmd/pier/main.go index f56b1bc..44351cd 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -364,6 +364,7 @@ func attach(c *pier.Client, s pier.Session) { fatal(err) } cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd = pier.ConnectingScreen(cmd, s.Name, true) start := time.Now() runErr := cmd.Run() if runErr == nil { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1d24951..dc1f804 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -610,6 +610,7 @@ func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { return m, nil } m.attachStart = time.Now() + cmd = pier.ConnectingScreen(cmd, s.Name, false) return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return attachDoneMsg{s, err} }) } diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index 6e2dd30..492acfd 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -257,6 +257,27 @@ func (c *Client) AttachCommand(ctx context.Context, s Session) (*exec.Cmd, error return c.drv.AttachCommand(ctx, s.ID) } +// ConnectingScreen wraps an attach command so the handover shows a blank +// screen reading "connecting to …" until the session draws, instead +// of revealing the user's own shell while ssh connects — that gap reads as +// if pier quit. The screen is the terminal's alternate buffer, which tmux +// draws into anyway. restore leaves it after ssh exits, for a CLI returning +// to its prompt; a full-screen frontend that redraws itself passes false. +// The command's exit status is preserved. +func ConnectingScreen(cmd *exec.Cmd, name string, restore bool) *exec.Cmd { + script := `printf '\033[?1049h\033[H\033[2J\n connecting to %s…\n' "$1"; shift; ` + if restore { + script += `"$@"; c=$?; printf '\033[?1049l'; exit $c` + } else { + script += `exec "$@"` + } + args := append([]string{"-c", script, "sh", name, cmd.Path}, cmd.Args[1:]...) + w := exec.Command("/bin/sh", args...) + w.Env, w.Dir = cmd.Env, cmd.Dir + w.Stdin, w.Stdout, w.Stderr = cmd.Stdin, cmd.Stdout, cmd.Stderr + return w +} + // RetryAttach reports whether a finished attach attempt deserves one // bounded wait-for-reachability and retry: a fresh or just-resumed VM // reports cloud-running before its transport answers, so ssh exits 255 diff --git a/pkg/pier/sessions_test.go b/pkg/pier/sessions_test.go index 60ecd69..8e567a7 100644 --- a/pkg/pier/sessions_test.go +++ b/pkg/pier/sessions_test.go @@ -1,7 +1,9 @@ package pier import ( + "errors" "os/exec" + "strings" "testing" "time" @@ -93,3 +95,20 @@ func TestFoldPool(t *testing.T) { t.Errorf("maxAge 0 = %+v, want the age check skipped", st) } } + +// The connecting screen must not change what the attach exits with: the +// one bounded retry keys off ssh's 255. +func TestConnectingScreenKeepsTheExitStatus(t *testing.T) { + for _, restore := range []bool{true, false} { + cmd := ConnectingScreen(exec.Command("sh", "-c", "exit 255"), "s", restore) + err := cmd.Run() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 255 { + t.Errorf("restore=%v: want exit 255 through the wrapper, got %v", restore, err) + } + } + out, err := ConnectingScreen(exec.Command("echo", "hi"), "demo", true).Output() + if err != nil || !strings.Contains(string(out), "connecting to demo") || !strings.Contains(string(out), "hi") { + t.Errorf("want the connecting line then the command's output, got %q %v", out, err) + } +} From b1724d5a6cea387aed21291dc0ac75cf531ea269 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 17:56:00 +0300 Subject: [PATCH 21/24] style(settings): lighten the violet accent --- internal/ui/ui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 97b4196..4640b5d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -38,7 +38,7 @@ var ThemeNames = []string{"teal", "navy", "violet", "emerald", "orange", "crimso var Themes = map[string]lipgloss.TerminalColor{ "teal": lipgloss.Color("6"), "navy": lipgloss.Color("#2B4FC5"), - "violet": lipgloss.Color("#6D28D9"), + "violet": lipgloss.Color("#7C3AED"), "emerald": lipgloss.Color("#10B981"), "orange": lipgloss.Color("#F97316"), "crimson": lipgloss.Color("#E11D48"), From 6d09e7da2d564af3da7fca2705e3568e3198af48 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 20:18:10 +0300 Subject: [PATCH 22/24] feat(attach)!: the session uses your browser while you're attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A headless session can't finish a browser login: the login page has nowhere to open, and the provider's redirect to localhost reaches the laptop, where nothing listens. While someone is attached, both now work. The session's xdg-open and $BROWSER hand URLs to the supervisor, which sends them over a socket the attach forwards, and they open in the laptop's browser (http and https only). A side connection mirrors the ports the session listens on to the same localhost ports, so callbacks and dev servers are reachable. Any agent or CLI's browser login — an MCP server's OAuth, `gh auth login` — works from inside the session. The pool's generation now includes the in-VM supervisor: a claim doesn't reinstall it, so a pooled session filled by an older pier would carry the older VM-side behavior into the session. BREAKING CHANGE: `pier mcp login` is removed; log in from inside the session instead. --- README.md | 22 ++-- cmd/pier-supervisor/main.go | 3 + cmd/pier-supervisor/open.go | 55 +++++++++ cmd/pier/main.go | 70 +---------- docs/SPEC.md | 16 ++- internal/driver/awsec2/driver.go | 19 --- internal/driver/driver.go | 5 - internal/driver/gcpgce/driver.go | 19 --- internal/driver/payload/files.go | 3 +- internal/driver/payload/payload.go | 2 +- internal/driver/payload/scripts.go | 23 +++- internal/pool/pool.go | 8 +- internal/pool/pool_test.go | 17 +-- internal/tui/tui.go | 9 +- internal/tui/tui_test.go | 4 +- pkg/pier/attach.go | 185 +++++++++++++++++++++++++++++ pkg/pier/attach_test.go | 68 +++++++++++ pkg/pier/repos.go | 18 ++- pkg/pier/sessions.go | 74 +----------- 19 files changed, 397 insertions(+), 223 deletions(-) create mode 100644 cmd/pier-supervisor/open.go create mode 100644 pkg/pier/attach.go create mode 100644 pkg/pier/attach_test.go diff --git a/README.md b/README.md index 84dedb0..4c3228b 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,6 @@ pier repos [--json] every repo: session image, pool, idle cost, tips pier bake build this repo's session image (repo prebuilt by default) --toolchain-only keep repo state out of the image for this bake pier pool this repo's pool (set | fill | drain [repo]) -pier mcp login one-time browser approvals for OAuth MCP servers pier proxy every running session as .pier (macOS) pier port

manual port forward (8080:3000 = local:session) pier setup [--skills] first-time setup; --skills only refreshes the agent skill @@ -371,11 +370,20 @@ all. MCP servers travel with their config, including auth when it's static (env vars, API-key headers). OAuth-backed remotes keep rotating tokens in the OS -keychain and can't be copied, so they need one browser approval per session: +keychain and can't be copied — two holders of one refresh token revoke each +other — so they log in once per session, from inside it, the normal way +(for example claude's `/mcp`). While you're attached, the session uses your +browser: -``` -pier mcp login # sweeps whatever still needs auth -``` +- anything in the session that opens a URL (an agent's OAuth login, `gh auth + login`, "open in browser") opens it in your laptop's browser; +- ports the session listens on (an OAuth callback, a dev server) are + reachable at the same port on your laptop. + +That makes every in-session browser login work, for any agent or CLI. The +token lands on the session's disk and survives parking. Where a provider +offers a personal API key, declaring the server with it skips the login +entirely: it travels into every session. Headless Chromium ships in the default image, so browser MCPs and skills (screenshots, web automation) work out of the box. @@ -538,8 +546,8 @@ The cloud says "running" long before a session is usable, so pier doesn't: running doesn't survive. An agent mid-turn stops at its last saved message, and a dev server comes back as its command typed at the prompt, one Enter away. Hibernate (park with RAM) is on the roadmap. -- **OAuth MCPs need a browser approval per session.** Keychain-held tokens - can't be copied safely. This floor is real. +- **OAuth MCPs log in once per session,** from inside it while attached. + Keychain-held tokens can't be copied safely. - **ssh-key-only GitHub auth pushes while attached.** The forwarded agent disconnects with you. Any token lifts this. - **`pier proxy` is macOS-only** for now. `pier port` works everywhere. diff --git a/cmd/pier-supervisor/main.go b/cmd/pier-supervisor/main.go index 49cb1ff..c7988f3 100644 --- a/cmd/pier-supervisor/main.go +++ b/cmd/pier-supervisor/main.go @@ -74,6 +74,9 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "restore" { os.Exit(restoreMain()) } + if len(os.Args) > 1 && os.Args[1] == "open" { + os.Exit(openMain(os.Args[2:])) + } // Bring back the containers the session had running before it parked, // alongside the loop: docker may still be starting. diff --git a/cmd/pier-supervisor/open.go b/cmd/pier-supervisor/open.go new file mode 100644 index 0000000..d8a55d7 --- /dev/null +++ b/cmd/pier-supervisor/open.go @@ -0,0 +1,55 @@ +package main + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// A session has no browser. Anything in it that opens a URL — an agent's +// OAuth login, `gh auth login`, a dev server's "open in browser" — goes +// through xdg-open or $BROWSER, both of which point here. While someone is +// attached, their attach connection forwards a unix socket back to their +// laptop, and the URL opens in their own browser; otherwise the URL is +// printed so it can be copied. + +// openSocket is where an attach forwards the laptop's opener. +func openSocket(home string) string { return filepath.Join(home, ".pier", "open.sock") } + +func openMain(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: xdg-open ") + return 1 + } + url := args[0] + home, _ := os.UserHomeDir() + if err := sendOpen(openSocket(home), url); err != nil { + fmt.Fprintln(os.Stderr, "open this in your browser: "+url) + } + return 0 +} + +// sendOpen hands one URL to the laptop's opener and waits for its answer. +func sendOpen(sock, url string) error { + c, err := net.DialTimeout("unix", sock, 2*time.Second) + if err != nil { + return err + } + defer c.Close() + _ = c.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := fmt.Fprintln(c, url); err != nil { + return err + } + reply, err := bufio.NewReader(c).ReadString('\n') + if err != nil { + return err + } + if strings.TrimSpace(reply) != "ok" { + return fmt.Errorf("opener: %s", strings.TrimSpace(reply)) + } + return nil +} diff --git a/cmd/pier/main.go b/cmd/pier/main.go index 44351cd..bd95300 100644 --- a/cmd/pier/main.go +++ b/cmd/pier/main.go @@ -104,7 +104,6 @@ var helpSections = []helpSection{ items: []helpItem{ {"pier proxy", "sessions as .pier, ports mirrored to localhost (macOS)"}, {"pier port ", "forward ports by hand (8080:3000 = local:session)"}, - {"pier mcp login [server]", "one-time browser approval for OAuth MCP servers"}, }, }, { @@ -133,8 +132,6 @@ func main() { cmdAttach(args[1:]) case "logs": cmdLogs(args[1:]) - case "mcp": - cmdMCP(args[1:]) case "proxy": cmdProxy() case "port": @@ -304,15 +301,6 @@ func cmdNew(args []string) { fmt.Println(ui.Dim.Render("attach with: pier attach " + sess.Name)) return } - // OAuth-backed MCPs need one browser approval each (tokens can't be - // copied — they rotate); offer the sweep now, while a human is present. - if names := pier.OAuthMCPServers(req.RepoRoot); len(names) > 0 && stdinIsTTY() { - if confirm(fmt.Sprintf("mcp %s: run the one-time browser logins now?", strings.Join(names, ", ")), true) { - loginAll(c, sess) - } else { - fmt.Println(ui.Dim.Render("later: pier mcp login " + sess.Name)) - } - } attach(c, sess) } @@ -359,7 +347,7 @@ func attach(c *pier.Client, s pier.Session) { fmt.Println(ui.Dim.Render("attaching — detach with C-b d (the session keeps running)")) retried := false for { - cmd, err := c.AttachCommand(context.Background(), s) + cmd, stopCompanion, err := c.Attach(context.Background(), s) if err != nil { fatal(err) } @@ -367,6 +355,7 @@ func attach(c *pier.Client, s pier.Session) { cmd = pier.ConnectingScreen(cmd, s.Name, true) start := time.Now() runErr := cmd.Run() + stopCompanion() if runErr == nil { return } @@ -584,61 +573,6 @@ func cmdLogs(args []string) { fmt.Println(out) } -// cmdMCP: `pier mcp login [server]` — one-time OAuth for MCP -// servers whose tokens live in the laptop's keychain and can't be copied -// (they rotate; two machines sharing one revoke each other). The callback -// port rides the session's tunnel, so the browser approval on the laptop -// completes the flow inside the VM. Without a server name it sweeps -// everything still unauthenticated. -func cmdMCP(args []string) { - if len(args) < 2 || args[0] != "login" { - fatal(fmt.Errorf("usage: pier mcp login [server]")) - } - c := open() - s := match(c, args[1]) - requireReady(s) - resumeIfParked(c, s) - if len(args) == 2 { - loginAll(c, s) - return - } - if err := loginOne(c, s, args[2]); err != nil { - fatal(fmt.Errorf("mcp login: %w", err)) - } -} - -// loginAll runs the browser flow for every OAuth-backed MCP server still -// lacking a token, sequentially — one command, N approvals, and re-running -// it is free: already-authenticated servers are skipped. -func loginAll(c *pier.Client, s pier.Session) { - pending, err := c.PendingMCP(context.Background(), s) - if err != nil { - fatal(err) - } - if len(pending) == 0 { - fmt.Println(ui.OK.Render("✓") + " every MCP server in " + s.Name + " is authenticated") - return - } - fmt.Printf("%s %s\n", ui.Bold.Render(fmt.Sprintf("%d server(s) need a one-time browser approval:", len(pending))), - strings.Join(pending, ", ")) - for i, server := range pending { - fmt.Printf("\n%s %s\n", ui.Accent.Render(fmt.Sprintf("[%d/%d]", i+1, len(pending))), ui.Bold.Render(server)) - if err := loginOne(c, s, server); err != nil { - fmt.Fprintln(os.Stderr, ui.Warn.Render(" ! "+server+" didn't finish — retry later with `pier mcp login "+s.Name+" "+server+"`")) - } - } -} - -func loginOne(c *pier.Client, s pier.Session, server string) error { - fmt.Println(ui.Dim.Render(" open the URL below and approve — the callback tunnels into the session; the token survives parking")) - cmd, err := c.MCPLoginCommand(context.Background(), s, server) - if err != nil { - return err - } - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - return cmd.Run() -} - // cmdProxy: `pier proxy` — every running session gets a hostname // (.pier) with its listening ports mirrored automatically; live // connections keep the session from parking. Runs in the foreground until diff --git a/docs/SPEC.md b/docs/SPEC.md index 7b0a742..e759a13 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -461,13 +461,13 @@ written back. Sources: stay home. MCP servers whose auth lives in the macOS Keychain (OAuth-based remotes) carry their declaration but not their tokens — they rotate on refresh, so copying them would let two machines revoke each other. Instead: - `pier mcp login ` asks the session which servers still lack a - token (seeded config minus its credential store) and runs `claude mcp - login` for each, sequentially, with the OAuth callback port forwarded - through the SSM tunnel — one browser approval per server on the laptop - completes each flow inside the session; tokens persist on its disk across - park/resume, and re-runs skip what's done. The interactive create offers - this sweep right before the first attach. (Remotes that accept static keys + they log in from inside the session instead: while someone is attached, + the attach connection forwards a unix socket the session's `xdg-open` and + `$BROWSER` (pier-supervisor open) hand URLs to, so they open in the + laptop's browser, and a side connection mirrors the session's listening + ports to the same localhost ports, so OAuth callbacks land. Any agent, + any CLI — nothing agent-specific. Tokens persist on the session disk + across park/resume. (Remotes that accept static keys can be declared locally with an `Authorization` header, which travels whole — zero approvals.) @@ -527,8 +527,6 @@ pier the app: Sessions · Repos · Settings tabs (new = backg pier [base] create from cwd repo (branch off base, default HEAD) and attach pier ls list own sessions pier attach reattach; resumes if parked -pier mcp login [server] browser-auth every MCP server that still needs it, sequentially - (callback rides the tunnel; server arg = redo just that one) pier proxy every running session as .pier, listening ports mirrored live onto a per-session loopback IP and onto localhost, HTTP accelerated (§6.1; macOS, one sudo) diff --git a/internal/driver/awsec2/driver.go b/internal/driver/awsec2/driver.go index 48c18ac..55f0f54 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -351,25 +351,6 @@ exec tmux new-session -A -s main` return cmd, nil } -// MCPLoginCommand: interactive `claude mcp login` with the OAuth callback -// port forwarded through the SSM tunnel. The auth URL prints in the user's -// terminal (BROWSER=echo keeps headless claude from skipping straight to -// paste mode); opening it on the laptop completes the provider's -// localhost redirect INTO the VM's waiting listener — one browser approval, -// no URL copy-paste. The token then lives on the session disk, so this is -// once per session, surviving park/resume. Same local/remote port: the -// redirect URL embeds the port claude registered on the VM. -func (d *Driver) MCPLoginCommand(ctx context.Context, id, server string, port int) (*exec.Cmd, error) { - remote := fmt.Sprintf( - "set -a; . ~/.config/pier/env 2>/dev/null; set +a; BROWSER=echo exec claude mcp login '%s' --callback-port %d", - server, port) - args := append(d.sshOpts(ctx, id), - "-t", "-L", fmt.Sprintf("%d:localhost:%d", port, port), "agent@"+id, remote) - cmd := exec.CommandContext(ctx, "ssh", args...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - return cmd, nil -} - // PortForwardCommand: plain ssh -L forwards, -N so no remote shell is // taken. Runs until interrupted. Over the SSM tunnel this is not fast // (~100KB/s-1MB/s raw; sshOpts' -C buys ~4x on dev-server text) — with diff --git a/internal/driver/driver.go b/internal/driver/driver.go index 302fd7c..d3e2e55 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -205,11 +205,6 @@ type Driver interface { // session-manager-plugin, or `gcloud compute ssh --tunnel-through-iap`. AttachCommand(ctx context.Context, id string) (*exec.Cmd, error) - // MCPLoginCommand runs `claude mcp login ` inside the session - // with the OAuth callback port tunneled back to the laptop, so - // browser-based MCP auth completes with one approval — no URL copying. - MCPLoginCommand(ctx context.Context, id, server string, port int) (*exec.Cmd, error) - // PortForwardCommand holds local→session port forwards open until the // process is interrupted: the app in the session on your laptop's // browser, its database in your local psql. pairs are {local, remote}. diff --git a/internal/driver/gcpgce/driver.go b/internal/driver/gcpgce/driver.go index bc4014a..4af0dea 100644 --- a/internal/driver/gcpgce/driver.go +++ b/internal/driver/gcpgce/driver.go @@ -406,25 +406,6 @@ exec tmux new-session -A -s main` return cmd, nil } -// MCPLoginCommand: interactive `claude mcp login` with the OAuth callback -// port forwarded through the IAP tunnel. The auth URL prints in the user's -// terminal (BROWSER=echo keeps headless claude from skipping straight to -// paste mode); opening it on the laptop completes the provider's -// localhost redirect INTO the VM's waiting listener — one browser approval, -// no URL copy-paste. The token then lives on the session disk, so this is -// once per session, surviving park/resume. Same local/remote port: the -// redirect URL embeds the port claude registered on the VM. -func (d *Driver) MCPLoginCommand(ctx context.Context, id, server string, port int) (*exec.Cmd, error) { - remote := fmt.Sprintf( - "set -a; . ~/.config/pier/env 2>/dev/null; set +a; BROWSER=echo exec claude mcp login '%s' --callback-port %d", - server, port) - args := append(d.sshOpts(id), - "-t", "-L", fmt.Sprintf("%d:localhost:%d", port, port), "agent@"+id, remote) - cmd := execCommandContext(ctx, "ssh", args...) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr - return cmd, nil -} - // PortForwardCommand: plain ssh -L forwards, -N so no remote shell is // taken. Runs until interrupted. The IAP tunnel is not line-rate; sshOpts' // -C buys a few x on dev-server text. diff --git a/internal/driver/payload/files.go b/internal/driver/payload/files.go index 1323d42..96bc875 100644 --- a/internal/driver/payload/files.go +++ b/internal/driver/payload/files.go @@ -60,7 +60,8 @@ func claudeSeed(home, srcRepo, workdir string) []byte { // OAuthRemotes names the seeded remote MCP servers with no static auth // header. Their OAuth tokens live in the OS keychain and rotate on refresh, // so copying them would let two machines revoke each other — each session -// instead needs one `pier mcp login ` round. Static-auth servers (env +// instead needs one login from inside the session (the attach carries the +// browser round trip). Static-auth servers (env // blocks, Authorization headers) travel whole and never appear here. func OAuthRemotes(home, srcRepo string) []string { b, err := os.ReadFile(filepath.Join(home, ".claude.json")) diff --git a/internal/driver/payload/payload.go b/internal/driver/payload/payload.go index d74c46a..c6915f6 100644 --- a/internal/driver/payload/payload.go +++ b/internal/driver/payload/payload.go @@ -167,7 +167,7 @@ func buildCommon(ctx context.Context, dir string, spec driver.CreateSpec, manife } home, _ := os.UserHomeDir() if names := OAuthRemotes(home, spec.Repo); len(names) > 0 { - p.Notes = append(p.Notes, "mcp "+strings.Join(names, ", ")+": one-time oauth — `pier mcp login "+spec.Name+"` when it's up") + p.Notes = append(p.Notes, "mcp "+strings.Join(names, ", ")+": log in from inside the session (e.g. claude's /mcp) — while you're attached it opens in your browser here") } return p, mode, sha, origin, nil } diff --git a/internal/driver/payload/scripts.go b/internal/driver/payload/scripts.go index 0945501..1aed1a9 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -47,7 +47,10 @@ runcmd: # (a laptop sleeping, a VPN reconnecting) left a phantom tmux client: # the session read as attached and could never park. Drop dead # connections after ~1 min of silence. - printf 'ClientAliveInterval 15\nClientAliveCountMax 4\n' > /etc/ssh/sshd_config.d/10-pier.conf + # StreamLocalBindUnlink: an attach forwards the laptop's browser opener + # as a unix socket; a reconnecting attach must be able to replace the + # previous connection's socket. + printf 'ClientAliveInterval 15\nClientAliveCountMax 4\nStreamLocalBindUnlink yes\n' > /etc/ssh/sshd_config.d/10-pier.conf systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true install -d -m 700 -o agent -g agent /home/agent/.ssh grep -qxF '{{PUBKEY}}' /home/agent/.ssh/authorized_keys 2>/dev/null || echo '{{PUBKEY}}' >> /home/agent/.ssh/authorized_keys @@ -72,7 +75,7 @@ runcmd: # across every launch. This also keeps sessions from baked images current — # they update themselves instead of pinning the bake-time version. The # /usr/local/bin symlink keeps claude on PATH for non-login shells (ssh - # exec channels, like pier mcp login rides). + # exec channels). if ! command -v claude >/dev/null; then sudo -Hu agent bash -c 'curl -fsSL --retry 3 https://claude.ai/install.sh | bash' ln -sf /home/agent/.local/bin/claude /usr/local/bin/claude @@ -158,8 +161,20 @@ func sessionUp(runSetup bool) string { # (user-data rewrites older rcs too, but on a baked image cloud-init is still # running when this does, and tmux must not start with the old line). sed -i 's#^cd ~/work/\* 2>/dev/null || true$#if [ "$PWD" = "$HOME" ]; then cd ~/work/* 2>/dev/null; fi#' "$HOME/.bashrc" 2>/dev/null || true -# Sessions and pooled sessions made before sshd dropped dead connections. -[ -f /etc/ssh/sshd_config.d/10-pier.conf ] || { printf 'ClientAliveInterval 15\nClientAliveCountMax 4\n' | sudo tee /etc/ssh/sshd_config.d/10-pier.conf >/dev/null && { sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null || true; }; } +# sshd: drop dead connections (a dropped attach must not leave a phantom +# tmux client), and let an attach replace the browser-opener socket a +# previous connection forwarded. Written here too so sessions from older +# images and pooled sessions get it. +if ! grep -q StreamLocalBindUnlink /etc/ssh/sshd_config.d/10-pier.conf 2>/dev/null; then + printf 'ClientAliveInterval 15\nClientAliveCountMax 4\nStreamLocalBindUnlink yes\n' | sudo tee /etc/ssh/sshd_config.d/10-pier.conf >/dev/null + sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null || true +fi +mkdir -p "$HOME/.pier" # where an attach forwards the browser opener +# The session's browser is the attached laptop's: xdg-open and $BROWSER hand +# URLs to the supervisor, which sends them back over the attach connection. +printf '#!/bin/sh\nexec /usr/local/bin/pier-supervisor open "$@"\n' | sudo tee /usr/local/bin/xdg-open >/dev/null +sudo chmod 755 /usr/local/bin/xdg-open +grep -q 'BROWSER=/usr/local/bin/xdg-open' "$HOME/.bashrc" 2>/dev/null || echo 'export BROWSER=/usr/local/bin/xdg-open' >> "$HOME/.bashrc" ` + tmuxEnsure if runSetup { s += setupWindow diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 15c9171..4ba4f5a 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -67,13 +67,15 @@ func (p *Params) progress() func(string) { } // Generation fingerprints everything that makes a warm member equivalent to -// a fresh create: a member built under a different driver, image, shape, or +// a fresh create — including build, the pier that fills it (its in-VM +// supervisor): a claim doesn't reinstall anything, so a member filled by an +// older pier would bring its older VM-side behavior into the session: a member built under a different driver, image, shape, or // setup script is stale and gets recycled rather than claimed. 12 hex chars — // lowercase, so it is valid as an EC2 tag value and a GCE label value alike. -func Generation(driverName, image, instanceType string, diskGiB int, setupScript []byte) string { +func Generation(driverName, image, instanceType string, diskGiB int, setupScript []byte, build string) string { setup := sha256.Sum256(setupScript) h := sha256.New() - fmt.Fprintf(h, "%s|%s|%s|%d|%x", driverName, image, instanceType, diskGiB, setup) + fmt.Fprintf(h, "%s|%s|%s|%d|%x|%s", driverName, image, instanceType, diskGiB, setup, build) return hex.EncodeToString(h.Sum(nil))[:12] } diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go index bf54efd..810570b 100644 --- a/internal/pool/pool_test.go +++ b/internal/pool/pool_test.go @@ -13,20 +13,21 @@ import ( // The generation is the staleness contract: any input that changes what a // fresh create would produce must change it, and nothing else may. func TestGeneration(t *testing.T) { - base := Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci")) - if again := Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci")); again != base { + base := Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci"), "b1") + if again := Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci"), "b1"); again != base { t.Errorf("generation is not stable: %s vs %s", base, again) } if len(base) != 12 || base != strings.ToLower(base) { t.Errorf("generation %q must be 12 lowercase hex chars (GCE label + EC2 tag safe)", base) } for name, gen := range map[string]string{ - "driver": Generation("gcp-gce", "ami-1", "t4g.medium", 40, []byte("npm ci")), - "image": Generation("aws-ec2", "ami-2", "t4g.medium", 40, []byte("npm ci")), - "type": Generation("aws-ec2", "ami-1", "t4g.large", 40, []byte("npm ci")), - "disk": Generation("aws-ec2", "ami-1", "t4g.medium", 80, []byte("npm ci")), - "setup": Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci && make")), - "nosetup": Generation("aws-ec2", "ami-1", "t4g.medium", 40, nil), + "driver": Generation("gcp-gce", "ami-1", "t4g.medium", 40, []byte("npm ci"), "b1"), + "image": Generation("aws-ec2", "ami-2", "t4g.medium", 40, []byte("npm ci"), "b1"), + "type": Generation("aws-ec2", "ami-1", "t4g.large", 40, []byte("npm ci"), "b1"), + "disk": Generation("aws-ec2", "ami-1", "t4g.medium", 80, []byte("npm ci"), "b1"), + "setup": Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci && make"), "b1"), + "nosetup": Generation("aws-ec2", "ami-1", "t4g.medium", 40, nil, "b1"), + "build": Generation("aws-ec2", "ami-1", "t4g.medium", 40, []byte("npm ci"), "b2"), } { if gen == base { t.Errorf("changing %s did not change the generation", name) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index dc1f804..d8aa55c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -31,7 +31,7 @@ type Backend interface { Resume(ctx context.Context, s pier.Session) error Resize(ctx context.Context, s pier.Session, machineType string) error Machines(s pier.Session) []pier.Machine - AttachCommand(ctx context.Context, s pier.Session) (*exec.Cmd, error) + Attach(ctx context.Context, s pier.Session) (*exec.Cmd, func(), error) WaitReachable(ctx context.Context, s pier.Session, timeout time.Duration) error SetupLog(ctx context.Context, s pier.Session, lines int) (string, error) SpawnCreate(repoRoot, branch string) (string, error) @@ -603,7 +603,7 @@ func (m model) startAttach(s pier.Session) (tea.Model, tea.Cmd) { } func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { - cmd, err := m.be.AttachCommand(context.Background(), s) + cmd, stop, err := m.be.Attach(context.Background(), s) if err != nil { m.attaching = "" m.fail(err) @@ -611,7 +611,10 @@ func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { } m.attachStart = time.Now() cmd = pier.ConnectingScreen(cmd, s.Name, false) - return m, tea.ExecProcess(cmd, func(err error) tea.Msg { return attachDoneMsg{s, err} }) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { + stop() // the browser opener and port mirror end with the attach + return attachDoneMsg{s, err} + }) } func (m model) confirm(q string, do func() tea.Cmd) model { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 111ce0b..0e690d4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -72,8 +72,8 @@ func (f *fake) Resize(context.Context, pier.Session, string) error { return nil func (f *fake) Machines(pier.Session) []pier.Machine { return []pier.Machine{{Type: "t4g.medium", CPU: "2", Mem: "4", Cost: "~$0.03/h"}, {Type: "t4g.xlarge", CPU: "4", Mem: "16", Cost: "~$0.13/h"}} } -func (f *fake) AttachCommand(context.Context, pier.Session) (*exec.Cmd, error) { - return exec.Command("true"), nil +func (f *fake) Attach(context.Context, pier.Session) (*exec.Cmd, func(), error) { + return exec.Command("true"), func() {}, nil } func (f *fake) WaitReachable(context.Context, pier.Session, time.Duration) error { return nil } func (f *fake) SetupLog(context.Context, pier.Session, int) (string, error) { diff --git a/pkg/pier/attach.go b/pkg/pier/attach.go new file mode 100644 index 0000000..d87fb4b --- /dev/null +++ b/pkg/pier/attach.go @@ -0,0 +1,185 @@ +package pier + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "time" +) + +// While someone is attached, the session behaves as if it ran on their +// laptop for the two things a headless VM can't do itself: a URL opened in +// the session (an agent's OAuth login, a CLI's web login, "open in browser") +// opens in the laptop's browser, and a port the session starts listening on +// (an OAuth callback, a dev server) is reachable at the same port on the +// laptop. Together they make any in-session browser login work — for every +// agent and CLI, since they hook the standard opener and plain ports. + +// remoteOpenSocket is where the session's opener (xdg-open, $BROWSER → +// pier-supervisor open) looks for the attached laptop. +const remoteOpenSocket = "/home/agent/.pier/open.sock" + +// mirrorPoll is how often the attach companion re-reads the session's ports. +var mirrorPoll = 2 * time.Second + +// Attach prepares one attach: the ssh+tmux command to run in the foreground, +// with the companion that runs alongside it. stop ends the companion; call +// it when the command exits. +func (c *Client) Attach(ctx context.Context, s Session) (cmd *exec.Cmd, stop func(), err error) { + cmd, err = c.drv.AttachCommand(ctx, s.ID) + if err != nil { + return nil, nil, err + } + cctx, cancel := context.WithCancel(ctx) + var wg sync.WaitGroup + stops := []func(){} + + // The opener: a local socket the attach connection forwards into the + // session. A failure to listen leaves the attach working, just without + // browser opens. + if sock, closeOpener, err := listenOpener(); err == nil { + stops = append(stops, closeOpener) + cmd.Args = slices.Insert(cmd.Args, 1, "-R", remoteOpenSocket+":"+sock) + } + + // The port mirror rides its own multiplexed connection, so forwards can + // come and go while the attach runs. + if opts, dest, err := c.drv.SSHTarget(ctx, s.ID); err == nil { + wg.Add(1) + go func() { + defer wg.Done() + mirrorPorts(cctx, opts, dest) + }() + } + + var once sync.Once + stop = func() { + once.Do(func() { + cancel() + wg.Wait() + for _, f := range stops { + f() + } + }) + } + return cmd, stop, nil +} + +// listenOpener serves URL opens from the session: one http(s) URL per +// connection, opened with the laptop's own opener. +func listenOpener() (sock string, closeFn func(), err error) { + sock = filepath.Join(os.TempDir(), "pier-open-"+randHex(6)+".sock") + l, err := net.Listen("unix", sock) + if err != nil { + return "", nil, err + } + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + go serveOpen(conn) + } + }() + return sock, func() { l.Close(); os.Remove(sock) }, nil +} + +func serveOpen(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + line, err := bufio.NewReader(conn).ReadString('\n') + if err != nil && line == "" { + return + } + if err := openURL(strings.TrimSpace(line)); err != nil { + fmt.Fprintln(conn, err.Error()) + return + } + fmt.Fprintln(conn, "ok") +} + +// openURL opens an http(s) URL in this machine's browser. Nothing else is +// accepted: the session asks for a web page, not for a program to run. +func openURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("not an http(s) URL") + } + opener := "xdg-open" + if runtime.GOOS == "darwin" { + opener = "open" + } + return openCommand(opener, u.String()).Start() +} + +// openCommand builds the opener process; a var so tests don't open a +// browser. +var openCommand = exec.Command + +// mirrorPorts forwards every port the session listens on to the same port +// on this machine until ctx ends. Ports already taken here (another +// session's, a local server's) are left alone; privileged ports are skipped. +func mirrorPorts(ctx context.Context, opts []string, dest string) { + ctl := filepath.Join(os.TempDir(), "pier-mux-"+randHex(6)) + master := exec.CommandContext(ctx, "ssh", slices.Concat(opts, []string{"-M", "-S", ctl, "-N", dest})...) + if err := master.Start(); err != nil { + return + } + defer func() { + _ = exec.Command("ssh", "-S", ctl, "-O", "exit", dest).Run() + _ = master.Wait() + os.Remove(ctl) + }() + for waited := time.Duration(0); ; waited += 200 * time.Millisecond { + if _, err := os.Stat(ctl); err == nil { + break + } + if waited > 60*time.Second || ctx.Err() != nil { + return + } + time.Sleep(200 * time.Millisecond) + } + forwarded := map[int]bool{} + for { + out, err := exec.CommandContext(ctx, "ssh", "-S", ctl, dest, "cat /run/pier/status.json").Output() + if err == nil { + var st struct { + Listening []int `json:"listening"` + } + if json.Unmarshal(out, &st) == nil { + for _, p := range st.Listening { + if p < 1024 || forwarded[p] { + continue + } + forwarded[p] = true // tried once: a taken port stays skipped + spec := fmt.Sprintf("127.0.0.1:%d:localhost:%d", p, p) + _ = exec.CommandContext(ctx, "ssh", "-S", ctl, "-O", "forward", "-L", spec, dest).Run() + } + } + } + select { + case <-ctx.Done(): + return + case <-time.After(mirrorPoll): + } + } +} + +func randHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/pkg/pier/attach_test.go b/pkg/pier/attach_test.go new file mode 100644 index 0000000..aa59080 --- /dev/null +++ b/pkg/pier/attach_test.go @@ -0,0 +1,68 @@ +package pier + +import ( + "bufio" + "fmt" + "net" + "os/exec" + "strings" + "testing" + "time" +) + +// The session asks for web pages, never programs: only http(s) URLs open. +func TestOpenURLOnlyOpensWebPages(t *testing.T) { + var opened []string + old := openCommand + openCommand = func(name string, args ...string) *exec.Cmd { + opened = append(opened, args...) + return exec.Command("true") + } + t.Cleanup(func() { openCommand = old }) + if err := openURL("https://github.com/login/oauth/authorize?client_id=x"); err != nil { + t.Fatal(err) + } + for _, bad := range []string{"file:///etc/passwd", "javascript:alert(1)", "/usr/bin/true", "https://", ""} { + if openURL(bad) == nil { + t.Errorf("%q must be refused", bad) + } + } + if len(opened) != 1 { + t.Errorf("want exactly the one web page opened, got %v", opened) + } +} + +// A URL the session sends over the forwarded socket opens here, and the +// session hears "ok" back. +func TestOpenerRoundTrip(t *testing.T) { + got := make(chan string, 1) + old := openCommand + openCommand = func(name string, args ...string) *exec.Cmd { + got <- args[len(args)-1] + return exec.Command("true") + } + t.Cleanup(func() { openCommand = old }) + sock, closeFn, err := listenOpener() + if err != nil { + t.Fatal(err) + } + defer closeFn() + c, err := net.Dial("unix", sock) + if err != nil { + t.Fatal(err) + } + fmt.Fprintln(c, "http://localhost:8123/callback?code=abc") + reply, _ := bufio.NewReader(c).ReadString('\n') + c.Close() + if strings.TrimSpace(reply) != "ok" { + t.Errorf("want ok, got %q", reply) + } + select { + case u := <-got: + if u != "http://localhost:8123/callback?code=abc" { + t.Errorf("opened %q", u) + } + case <-time.After(2 * time.Second): + t.Error("the URL never opened") + } +} diff --git a/pkg/pier/repos.go b/pkg/pier/repos.go index 2d7984f..52235a5 100644 --- a/pkg/pier/repos.go +++ b/pkg/pier/repos.go @@ -324,7 +324,7 @@ func (c *Client) poolParams(repoRoot string, progress Progress, start time.Time) return pool.Params{ Driver: c.drv, RepoRoot: repoRoot, - Gen: pool.Generation(c.drv.Name(), image, c.MachineType(), c.DiskGiB(), pool.SetupScript(repoRoot)), + Gen: pool.Generation(c.drv.Name(), image, c.MachineType(), c.DiskGiB(), pool.SetupScript(repoRoot), c.buildFingerprint()), Size: c.cfg.PoolSize(repo), MaxAge: maxAge, Image: image, @@ -483,3 +483,19 @@ func HourlyUSD(s Session) float64 { f, _ := strconv.ParseFloat(m[1], 64) return f } + +// buildFingerprint identifies the in-VM half of this pier: a hash of the +// supervisor binaries it installs. Pooled sessions filled by a different +// build are stale — a claim doesn't reinstall the supervisor. +func (c *Client) buildFingerprint() string { + if c.opts.SupervisorBin == nil { + return "" + } + h := sha256.New() + for _, arch := range []string{"arm64", "amd64"} { + if b, err := c.opts.SupervisorBin(arch); err == nil { + h.Write(b) + } + } + return hex.EncodeToString(h.Sum(nil))[:12] +} diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index 492acfd..ef97cdb 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -5,10 +5,8 @@ import ( "encoding/json" "errors" "fmt" - "net" "os" "os/exec" - "regexp" "strconv" "strings" "sync" @@ -16,7 +14,6 @@ import ( "github.com/usepier/pier/internal/config" "github.com/usepier/pier/internal/driver" - "github.com/usepier/pier/internal/driver/payload" "github.com/usepier/pier/internal/tombstone" ) @@ -349,64 +346,6 @@ func (c *Client) FollowLogCommand(ctx context.Context, s Session) (*exec.Cmd, er return exec.Command("ssh", append(opts, dest, remote)...), nil } -var mcpServerName = regexp.MustCompile(`^[A-Za-z0-9._:@-]+$`) - -// PendingMCP asks the session which OAuth-backed MCP servers still lack a -// token (seeded ~/.claude.json minus ~/.claude/.credentials.json). -func (c *Client) PendingMCP(ctx context.Context, s Session) ([]string, error) { - out, err := c.drv.Exec(ctx, s.ID, - "cat ~/.claude.json 2>/dev/null; printf '\\n---PIER-SPLIT---\\n'; cat ~/.claude/.credentials.json 2>/dev/null") - if err != nil { - return nil, err - } - cfgRaw, credRaw, _ := strings.Cut(out, "---PIER-SPLIT---") - var pending []string - for _, n := range payload.OAuthRemoteNames([]byte(cfgRaw), payload.Workspace+"/"+s.Repo) { - if !mcpAuthed(credRaw, n) { - pending = append(pending, n) - } - } - return pending, nil -} - -// OAuthMCPServers names the laptop's OAuth-backed MCP servers that a new -// session of repoRoot would need one browser approval for. -func OAuthMCPServers(repoRoot string) []string { - home, _ := os.UserHomeDir() - return payload.OAuthRemotes(home, repoRoot) -} - -// MCPLoginCommand prepares the browser OAuth flow for one MCP server, with -// the callback port tunneled into the session. -func (c *Client) MCPLoginCommand(ctx context.Context, s Session, server string) (*exec.Cmd, error) { - if !mcpServerName.MatchString(server) { - return nil, fmt.Errorf("implausible server name %q", server) - } - port, err := freePort() - if err != nil { - return nil, err - } - return c.drv.MCPLoginCommand(ctx, s.ID, server, port) -} - -// mcpAuthed checks the session's credential store for a token under this -// server. Claude keys mcpOAuth by server name (possibly suffixed); an -// unrecognized format fails open — worst case one redundant approval. -func mcpAuthed(credJSON, server string) bool { - var c struct { - McpOAuth map[string]json.RawMessage `json:"mcpOAuth"` - } - if json.Unmarshal([]byte(credJSON), &c) != nil { - return false - } - for k := range c.McpOAuth { - if k == server || strings.HasPrefix(k, server+"|") || strings.HasPrefix(k, server+":") { - return true - } - } - return false -} - // PortForwardCommand prepares ssh -L forwards held open until interrupted. // pairs are {local, remote}. func (c *Client) PortForwardCommand(ctx context.Context, s Session, pairs [][2]int) (*exec.Cmd, error) { @@ -429,18 +368,6 @@ func ParsePortPair(s string) ([2]int, error) { } return p, nil } - -// freePort grabs an OS-assigned port. Local and remote must match: the OAuth -// redirect URL embeds the one port claude registers inside the VM. -func freePort() (int, error) { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - defer l.Close() - return l.Addr().(*net.TCPAddr).Port, nil -} - // Label is the one word a session's row shows: starting, running, parked, // deleting or create failed. The supervisor's finer activity reading // (attached, working, idle) changes every few seconds and is detail, not @@ -474,3 +401,4 @@ func Activity(s Session) string { } return "" } + From db8dac18fe8aee3d41375b5acbb589c1585133f3 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 20:32:26 +0300 Subject: [PATCH 23/24] style: gofmt --- pkg/pier/sessions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/pier/sessions.go b/pkg/pier/sessions.go index ef97cdb..49d3b1a 100644 --- a/pkg/pier/sessions.go +++ b/pkg/pier/sessions.go @@ -368,6 +368,7 @@ func ParsePortPair(s string) ([2]int, error) { } return p, nil } + // Label is the one word a session's row shows: starting, running, parked, // deleting or create failed. The supervisor's finer activity reading // (attached, working, idle) changes every few seconds and is detail, not @@ -401,4 +402,3 @@ func Activity(s Session) string { } return "" } - From 5541483b309fbb4e16d0f29fb9b118d17c2f9696 Mon Sep 17 00:00:00 2001 From: Kerem Ali Kaynak Date: Mon, 28 Sep 2026 20:35:41 +0300 Subject: [PATCH 24/24] docs: settle the README and SPEC on this PR's behavior --- README.md | 23 +++++++++++++---------- docs/SPEC.md | 14 ++++++++++---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4c3228b..070a9a8 100644 --- a/README.md +++ b/README.md @@ -480,9 +480,12 @@ per repo), as a dot on the Repos tab, and in `pier repos`. with a state dot, a detail pane, attach, logs, resize, keep, delete), a **Repos** tab (session image, pool, idle cost and reminders per repo), and **Settings**, one page grouped into Cloud, New sessions, Idle & -cost, and Speed, showing only the active cloud's fields with a line on what -each changes. The header keeps running and parked counts and the live -hourly cost in view; `?` lists the keys. +cost, Speed, and Appearance (the accent color: teal, navy, violet, emerald, +orange, crimson, pink, amber or graphite), showing only the active cloud's +fields with a line on what each changes. The header keeps running and +parked counts and the live hourly cost in view; `?` lists the keys. +Attaching stays in the app until the session answers, then hands over to +it. Every frontend runs on the same API (`pkg/pier`): the CLI, the app, and the coming Mac app. @@ -540,12 +543,13 @@ The cloud says "running" long before a session is usable, so pier doesn't: GitHub push their history through it (a 300 MB history takes about 5 minutes, once per create). The create output says which transfer mode you got and why. -- **Parking loses processes.** Files, git state, and installed tools - survive, and on wake your tmux layout comes back: windows, panes, cwds, - scrollback, and claude/codex relaunched on their conversations. What was - running doesn't survive. An agent mid-turn stops at its last saved message, - and a dev server comes back as its command typed at the prompt, one Enter - away. Hibernate (park with RAM) is on the roadmap. +- **Parking stops processes.** Files, git state, and installed tools + survive, and on wake your tmux layout comes back (windows, panes, cwds, + scrollback, claude/codex relaunched on their conversations) along with the + Docker containers that were running. Processes outside Docker don't + survive: an agent mid-turn stops at its last saved message, and a dev + server started in a pane comes back as its command typed at the prompt, + one Enter away. - **OAuth MCPs log in once per session,** from inside it while attached. Keychain-held tokens can't be copied safely. - **ssh-key-only GitHub auth pushes while attached.** The forwarded agent @@ -662,7 +666,6 @@ with your code, at storage prices when you're not using them. ## Roadmap -- **Hibernate/suspend parking.** Keep RAM, resume mid-agent-run. - **Linux `pier proxy`.** - **Custom base images.** Bring your own golden image under pier's harness layer. diff --git a/docs/SPEC.md b/docs/SPEC.md index e759a13..9161465 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -59,10 +59,15 @@ Honest seam: in v1 a park loses running processes (including the tmux server and any in-flight agent run) — files, git state, and installed tools survive. What a wake brings back is the arrangement (§6 restore-on-wake): the tmux layout, cwds and scrollback, with agents relaunched on their conversations -from the transcripts on disk. What it can't bring back is anything that was +from the transcripts on disk, plus the Docker containers that were running +at the park. What it can't bring back is a process outside Docker that was mid-flight: an agent turn stops at its last saved message, and a dev server -returns as its command typed at the prompt, not running. -v1.1 upgrade: EC2 hibernate / GCE suspend to preserve RAM across park. +started in a pane returns as its command typed at the prompt, not running. +Hibernate/suspend was weighed and dropped: it differs per cloud and per +machine family (encrypted roots, launch-time flags, a 60-day cap) and has +no supported guest-initiated trigger. Instead a wake restores the tmux +layout, relaunches agents on their conversations, and restarts the Docker +containers that were running (§6, §7.4). ## 3. Drivers @@ -549,6 +554,7 @@ pier teardown remove account groundwork driver = "aws-ec2" idle_timeout = "30m" # or "never" unattended_cap = "8h" +theme = "teal" # accent color: teal navy violet emerald orange crimson pink amber graphite [aws] profile = "default" @@ -589,7 +595,7 @@ reminder_age = "30d" # remind when an image gets this old In: AWS + GCP drivers, TUI, wizard (+print-admin, teardown), bake, overlapped create, supervisor parking (+keep/pin), one-way secrets copy, doctor, quota UX, warm pools (opt-in, added post-cut — §7.4). -Out (v1.1+): hibernate/suspend park, k8s driver, `ls --all`, Windows. +Out (v1.1+): k8s driver, `ls --all`, Windows. ## 14. Load-bearing bets → spikes