diff --git a/README.md b/README.md index 77a24b5..070a9a8 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 @@ -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: @@ -84,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) | +| 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 @@ -130,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 +big a pool each baked repo keeps. + +| Profile | New session | Idle cost per repo | +|---|---|---| +| **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 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 for an admin to run once. The wizard then works with what exists. @@ -191,28 +210,26 @@ 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-pool launch fresh instead of claiming a pooled 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 mcp login one-time browser approvals for OAuth MCP servers +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 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 ``` @@ -258,7 +275,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 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, ...) | @@ -273,6 +290,12 @@ docker compose up -d pnpm db:migrate ``` +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 +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 setup window closes. @@ -302,10 +325,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". @@ -347,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. @@ -378,41 +410,85 @@ 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 -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 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). ``` -cd ~/code/shop && pier pool set 2 +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 ``` -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. - -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. +The next `pier ` claims one instead of creating — resume, check out +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. + +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`). The app's Repos tab shows +every repo's pool with its cost: `+`/`-` sets the size, `f` fills, `x` drains. + +### 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` 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, pool, idle cost and reminders per +repo), and **Settings**, one page grouped into Cloud, New sessions, Idle & +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. ### Setup that can't fail silently @@ -420,9 +496,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 @@ -436,12 +512,12 @@ 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 ``` -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 @@ -452,10 +528,11 @@ 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. -- 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. @@ -466,11 +543,15 @@ 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. The tmux server and an in-flight agent run don't. 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. +- **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 disconnects with you. Any token lifts this. - **`pier proxy` is macOS-only** for now. `pier port` works everywhere. @@ -494,7 +575,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 │ @@ -521,6 +602,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). @@ -543,13 +634,18 @@ 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, 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 @@ -570,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/cmd/pier-supervisor/containers.go b/cmd/pier-supervisor/containers.go new file mode 100644 index 0000000..d170530 --- /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 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 +// 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 400f3f4..c7988f3 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,30 @@ type status struct { } 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. + 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{} + lastSnap := time.Time{} for { idleTimeout, cap_ := readConf() @@ -105,6 +130,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 +363,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/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-supervisor/tmux.go b/cmd/pier-supervisor/tmux.go new file mode 100644 index 0000000..aab463a --- /dev/null +++ b/cmd/pier-supervisor/tmux.go @@ -0,0 +1,799 @@ +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" // 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 +) + +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 := readProc(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) + } + // 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 := readProc(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 +} + +// 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 := readProc(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) { + 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) { + 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) + } + kept := claudeKeptFlags(args) + cmd := []string{"claude", "--continue"} + if p.Resume != "" { + cmd = []string{"claude", "--resume", p.Resume} + } + // 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 || codex" + } + 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 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)) + 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 { + // 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) + 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) + } + snapshotContainers(home) +} + +// 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..ec090da --- /dev/null +++ b/cmd/pier-supervisor/tmux_test.go @@ -0,0 +1,477 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strconv" + "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 || 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 || 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}, ""}, + } + 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 || 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 + 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{ + {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"}}, + // 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. + {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) + } +} + +// 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) + } +} + +// 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"), 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) + } + if !restartedThisBoot.Load() { + t.Error("snapshots must resume once the restart ran") + } + 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/cmd/pier/main.go b/cmd/pier/main.go index 5dbe729..bd95300 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,51 @@ type helpSection struct { var helpSections = []helpSection{ { - title: "Create session", - 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", + title: "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-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"}, - {"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, 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 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"}, }, }, { - 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)"}, }, }, { - 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"}, }, }, } @@ -135,8 +132,6 @@ func main() { cmdAttach(args[1:]) case "logs": cmdLogs(args[1:]) - case "mcp": - cmdMCP(args[1:]) case "proxy": cmdProxy() case "port": @@ -147,16 +142,16 @@ func main() { cmdKeep(args[1:]) case "resize": cmdResize(args[1:]) + case "repos": + cmdRepos(args[1:]) + case "bake": + cmdBake(args[1:]) case "pool": cmdPool(args[1:]) case "setup": cmdSetup(args[1:]) - case "skills": - cmdSkills() case "doctor": cmdDoctor() - case "bake": - cmdBake() case "teardown": cmdTeardown() case "version", "-v", "--version": @@ -169,10 +164,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 +176,7 @@ func printUsage() { fmt.Printf(" %s %s\n", ui.Accent.Render(command), item.description) } } + fmt.Println() } func fatal(err error) { @@ -188,69 +184,39 @@ 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) } + ui.SetAccent(c.Config().Theme) + 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) { @@ -283,215 +249,143 @@ func cmdNew(args []string) { printUsage() os.Exit(1) } - branch := pos[0] - base := "HEAD" + req := pier.CreateRequest{Branch: pos[0], NoPool: noPool, Progress: progress} if len(pos) == 2 { - base = pos[1] - } - - 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) - } - // 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 + fatal(fmt.Errorf("--idle: %w", err)) } - 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: func(step string) { fmt.Println(ui.Step(step)) }, - }) + 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.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")) + 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 confirm(fmt.Sprintf("mcp %s: run the one-time browser logins now?", strings.Join(names, ", ")), true) { - loginAll(drv, *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 + } + seen[repo] = time.Now() + if b, err := json.Marshal(seen); err == nil { + _ = os.WriteFile(path, b, 0o600) } - return b + 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, stopCompanion, err := c.Attach(context.Background(), s) if err != nil { 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() + stopCompanion() 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) { @@ -507,7 +401,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 @@ -530,25 +424,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 + // Pooled 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) - } + sessions, pooled := pier.SplitPool(all) poolLine := "" - if members > 0 { - poolLine = ui.Dim.Render(fmt.Sprintf("+ %d warm pool member(s) — `pier pool`", members)) + 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 { @@ -566,11 +452,12 @@ func cmdLS(args []string) { 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 != "" { @@ -579,7 +466,7 @@ func cmdLS(args []string) { 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))) @@ -593,15 +480,10 @@ 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 { - // "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) +// stateLabel renders the row's state plus the supervisor's strain and setup +// flags. +func stateLabel(s pier.Session) string { + l := pier.Label(s) if s.Strained { l += " (strained)" } @@ -614,123 +496,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 @@ -744,250 +546,82 @@ 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) } - } - 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) - 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)) - } -} - -// 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. -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]) - requireReady(s) - resumeIfParked(drv, s) - - if len(args) == 2 { - loginAll(drv, 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 { - 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") - 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") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + _ = cmd.Run() // a followed log ends with ctrl-c; that interrupt is not an error 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(drv, 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) + out, err := c.SetupLog(context.Background(), s, 0) if err != nil { - return err - } - 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 - } + fatal(fmt.Errorf("logs: %w", err)) } - return false + fmt.Println(out) } // 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 @@ -1001,22 +635,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)) } @@ -1024,306 +654,307 @@ 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: func(step string) { fmt.Println(ui.Step(step)) }, - }, nil +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"` } -// 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, 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 + 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\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), poolLabel(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])) +func imageLabel(r pier.Repo) string { + switch { + case r.Image == "": + return "none" + case r.Baked == nil: + return "baked" } - cfg, drv := loadDriver() - root := repoRoot() - if err := applyPoolSize(cfg, drv, root, n, func(s string) { fmt.Println(ui.Step(s)) }); err != nil { - fatal(err) + kind := "prebuilt" + if !r.Baked.RepoIncluded { + kind = "toolchains" } - repo := filepath.Base(root) - if n == 0 { - fmt.Println(ui.OK.Render("pool off for " + repo)) - return + if a := pier.Age(r.Baked.BakedAt, time.Now()); a != "now" { + return kind + ", " + a + " old" } - 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 + ", just baked" } -// 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 - } - 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 +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.PoolSize) + 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("the pool was built on the old image — its sessions recycle on the next claim or `pier pool fill`")) + } } -func poolDrain(args []string) { - cfg, drv := loadDriver() - var repo string - if len(args) > 0 { - repo = args[0] - } else { - repo = filepath.Base(repoRoot()) +// 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 { + poolStatus() + return } - sessions, err := drv.List(context.Background()) - if err != nil { - fatal(err) + 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) + } + 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 + } + // 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]]")) } - count, err := pool.Drain(context.Background(), drv, sessions, repo, func(s string) { fmt.Println(ui.Step(s)) }) +} + +// 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 count == 0 { - fmt.Println(ui.Dim.Render("no warm members 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) } - 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))) + if !any && cur == "" { + fmt.Println(ui.Dim.Render("no pools — `pier pool set ` inside a repo keeps set-up sessions ready to claim")) } } -func poolLogPath(repo string) string { - return filepath.Join(config.Dir(), "logs", "pool-"+repo+".log") -} - -// 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 +func poolHint(r pier.Repo) string { + if r.Image == "" { + return " — pools follow the speed settings for repos with a session image; `pier bake` first" } - logPath := poolLogPath(filepath.Base(repoRoot)) - if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil { - return "", 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 + return " — `pier pool set ` keeps set-up sessions ready to claim" } -// --- 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() @@ -1350,50 +981,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 @@ -1406,75 +1009,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 ~5 min, then an image — ~$1-2/mo storage)")) - if hook != "" { - fmt.Println(ui.Step(".pier/bake.sh found — its toolchains bake in")) - } - // 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, 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 now cold-start in ~1-2 min")) - 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 pooled 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")) @@ -1494,237 +1034,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 nil, err // never a typed-nil *Client inside the interface } - 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 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..ee05824 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. @@ -53,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) } } @@ -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/docs/SPEC.md b/docs/SPEC.md index e04becf..9161465 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -57,7 +57,17 @@ 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. -v1.1 upgrade: EC2 hibernate / GCE suspend to preserve RAM across park. +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, 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 +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 @@ -181,6 +191,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 @@ -283,6 +323,44 @@ 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. + + **Setup runs once per session (2026-09).** Setup runs when a session + 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 + `~/.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` + (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 @@ -329,8 +407,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. **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 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 @@ -386,13 +466,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.) @@ -414,13 +494,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. @@ -434,14 +520,18 @@ 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 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) @@ -449,13 +539,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, 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) pier doctor checks pier teardown remove account groundwork ``` @@ -466,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" @@ -486,10 +575,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) +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] # pooled 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 pool set` # shop = 2 + +# [images.] # written by `pier bake`: baked_at, setup_sha, +# # bake_sha, repo_included — feeds the reminders ``` ## 13. v1 cut line @@ -497,7 +595,7 @@ manifest = [".codex/auth.json", ".codex/config.toml", ".claude/settings.json", " 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 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..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" @@ -20,7 +21,44 @@ type Config struct { AWS AWS `toml:"aws"` 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 + // 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 (pool size and bake +// reminders), plus how images +// are built and when pier suggests rebuilding them. +type Speed struct { + // 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"` + // 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 { @@ -91,6 +129,7 @@ func Default() Config { MachineType: "e2-medium", DiskGiB: 40, }, + Speed: Speed{PoolSize: 1, BakeReminders: true, ImageRepo: true, ReminderAge: "30d"}, } } @@ -103,12 +142,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 } @@ -216,16 +262,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 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 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 { + return n + } + if c.BakedImage(repo) == "" { + return 0 + } + return c.Speed.PoolSize +} -// 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 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) { - 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 +296,90 @@ func (c *Config) SetPoolSize(repo string, n int) { c.Pool.Sizes[repo] = n } +// 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{} + 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 + 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.PoolSize == 1: + return "fast" + case c.Speed.BakeReminders && c.Speed.PoolSize == 0: + return "lean" + case !c.Speed.BakeReminders && c.Speed.PoolSize == 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.PoolSize = true, 1 + case "lean": + c.Speed.BakeReminders, c.Speed.PoolSize = true, 0 + case "minimal": + c.Speed.BakeReminders, c.Speed.PoolSize = 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 +400,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 +451,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 +494,49 @@ 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. +// 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 +// 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", + 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: "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", - }, - { - 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 +547,120 @@ 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 + 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: "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 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: "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.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"}, + {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 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: "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.", + }, + { + 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.", }, } @@ -454,10 +693,36 @@ 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": + return strconv.Itoa(c.Speed.PoolSize) + 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 +812,40 @@ 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": + n, err := strconv.Atoi(strings.TrimSpace(val)) + if err != nil || n < 0 || n > 8 { + return fmt.Errorf("speed.pool_size: want 0-8 (got %q)", val) + } + c.Speed.PoolSize = 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/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/driver/awsec2/bake.go b/internal/driver/awsec2/bake.go index 3ef27f8..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" @@ -14,10 +15,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 { @@ -62,7 +65,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 @@ -81,7 +84,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 } @@ -89,12 +92,28 @@ 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 } - 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 } @@ -105,7 +124,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, @@ -113,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 } @@ -124,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/cli.go b/internal/driver/awsec2/cli.go index bbfc5de..e6b5d87 100644 --- a/internal/driver/awsec2/cli.go +++ b/internal/driver/awsec2/cli.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "fmt" + "io" "os" "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 @@ -52,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") } @@ -104,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 { @@ -112,13 +127,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 +143,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 +174,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/create.go b/internal/driver/awsec2/create.go index e791b3b..f803592 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" @@ -27,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 @@ -44,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. @@ -103,7 +116,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"} @@ -115,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) } @@ -128,6 +140,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 @@ -185,25 +202,50 @@ 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)}, {"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 +254,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 +270,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..b26b003 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,107 @@ 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) + } + }) +} + +// 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) + } +} diff --git a/internal/driver/awsec2/direct.go b/internal/driver/awsec2/direct.go index 57fac77..9d56f0b 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" ) @@ -69,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. @@ -100,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 or secure gateway can change the address the VM sees)") return "", directTTL } c.Close() @@ -113,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() } @@ -226,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) { @@ -239,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 @@ -310,6 +372,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/direct_test.go b/internal/driver/awsec2/direct_test.go index a4b2752..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) @@ -52,6 +59,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 @@ -145,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 1d14e25..55f0f54 100644 --- a/internal/driver/awsec2/driver.go +++ b/internal/driver/awsec2/driver.go @@ -12,7 +12,9 @@ package awsec2 import ( "context" "encoding/json" + "errors" "fmt" + "io" "os" "os/exec" "slices" @@ -60,6 +62,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 @@ -70,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 @@ -104,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"` @@ -118,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 @@ -129,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 { @@ -166,6 +181,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() { @@ -304,13 +325,25 @@ 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) { + 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) # 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...) @@ -318,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 @@ -371,6 +385,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/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 bbbe5fd..d3e2e55 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,16 @@ 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 + +// 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") @@ -101,11 +112,22 @@ 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) + 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 @@ -159,6 +181,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 @@ -177,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}. @@ -196,8 +219,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..c41b941 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 { @@ -52,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 @@ -71,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 } @@ -79,12 +81,35 @@ 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 } - 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 @@ -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/cli.go b/internal/driver/gcpgce/cli.go index 3126988..2d7c3a6 100644 --- a/internal/driver/gcpgce/cli.go +++ b/internal/driver/gcpgce/cli.go @@ -3,12 +3,16 @@ package gcpgce import ( "bytes" "context" + "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strings" "time" + + "github.com/usepier/pier/internal/driver" ) var ( @@ -98,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") } @@ -134,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 { @@ -142,13 +158,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 +174,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 { @@ -200,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() } @@ -207,3 +226,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/create.go b/internal/driver/gcpgce/create.go index 431690b..b33c6b7 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"} @@ -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 7b9994b..4af0dea 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 } @@ -224,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) } @@ -365,14 +379,26 @@ 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) { + 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) # 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...) @@ -380,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/gcpgce/driver_test.go b/internal/driver/gcpgce/driver_test.go index 25ea14c..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) @@ -97,6 +118,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) { @@ -235,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": @@ -416,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/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/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/freshen.go b/internal/driver/payload/freshen.go index 09c865b..37475a1 100644 --- a/internal/driver/payload/freshen.go +++ b/internal/driver/payload/freshen.go @@ -13,13 +13,26 @@ 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 +var 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 next park snapshots this session's own. Setup does not run: the +# 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 +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 @@ -49,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(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.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/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 new file mode 100644 index 0000000..4c26fa0 --- /dev/null +++ b/internal/driver/payload/prebuild.go @@ -0,0 +1,221 @@ +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 — 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/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 +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..2b914fc --- /dev/null +++ b/internal/driver/payload/prebuild_test.go @@ -0,0 +1,206 @@ +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", ".pier/tmux/state.json", ".pier/tmux/main.0.0.txt"} { + 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", ".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) + } + } + 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") + } +} + +// 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"} + 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") { + 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 ad39ace..1aed1a9 100644 --- a/internal/driver/payload/scripts.go +++ b/internal/driver/payload/scripts.go @@ -43,6 +43,15 @@ 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. + # 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 chown agent:agent /home/agent/.ssh/authorized_keys && chmod 600 /home/agent/.ssh/authorized_keys @@ -66,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 @@ -79,7 +88,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 @@ -105,7 +119,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). @@ -119,28 +134,55 @@ 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. +// setupWindow runs the repo's .pier/setup.sh in its own tmux window, once, +// 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 +// 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. 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 ` -const bootstrapTmpl = `#!/usr/bin/env bash +// 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 +# 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 + } + return s +} + +var bootstrapTmpl = `#!/usr/bin/env bash # pier bootstrap — runs once, as agent, on the fresh instance. set -euo pipefail @@ -160,23 +202,64 @@ 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 +# 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 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 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 +271,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 @@ -208,7 +293,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(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. @@ -218,6 +303,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/claim.go b/internal/pool/claim.go index a6ac7a5..577fc4b 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,16 @@ 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)) - if err := freshen(ctx, p, member, name, branch, baseRef); err != nil { + progress(fmt.Sprintf("claimed pooled session %s — resuming", member.Name)) + 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 the member — 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 pool` for a leftover member") + progress("destroy failed too (" + derr.Error() + ") — check `pier pool` for a leftover") } return nil, nil } @@ -122,7 +127,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,12 +135,12 @@ 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 } } } - 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"} @@ -146,12 +151,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 8c9a571..b5a0290 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, @@ -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 pooled 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'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 member(s) needs more vCPU headroom than %s has — raise the quota or shrink the pool", + 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 member %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("pool %s is full (%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 } @@ -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 pool session") _ = p.Driver.Destroy(context.WithoutCancel(ctx), sess.ID) return err } @@ -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..4ba4f5a 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" @@ -35,9 +36,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 @@ -58,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) { @@ -68,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/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..e590ffa --- /dev/null +++ b/internal/tui/style.go @@ -0,0 +1,194 @@ +package tui + +import ( + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "github.com/usepier/pier/internal/ui" + "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.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) + 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) +) + +// 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 +// 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 — 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.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("starting") + 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..d8aa55c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,283 +1,199 @@ -// 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 + 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 + 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 + 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) + SpawnBake(repoRoot string) (string, 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 + 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 ( + 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 ( - modeList mode = iota - modeNew - modeConfirm - modeSettings - modeResize - modeLogs - modePool + 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()} + for _, f := range m.settings { + if f.Key == "theme" { + applyTheme(f.Value) } } - return sessions, members + if opts.RepoRoot != "" { + m.repo = filepath.Base(opts.RepoRoot) + } + return m } +// --- 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 { - sessions []driver.Session + all []pier.Session err error + enriched bool } - 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 +204,691 @@ 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.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 { + 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 + 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) + 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 + var cmds []tea.Cmd + if !msg.enriched { + cmds = append(cmds, m.enrich(msg.all)) } - // 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() + cmds = append(cmds, pollCmd()) } - return m, nil - case quotaMsg: - m.quota = string(msg) - return m, nil - case poolActMsg: - m.loading = false + return m, tea.Batch(cmds...) + + case pollMsg: + // Background refreshes stay quiet: the spinner is for the first load + // and for refreshes the user asked for. + m.polling = false + 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. - if m.authRequired { - switch msg.String() { - case "q", "ctrl+c", "esc": - return m, tea.Quit - 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, 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 } - m.status = "" - switch msg.String() { - case "q", "ctrl+c", "esc": - 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 - 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 + for i, s := range next { + p, ok := old[s.ID] + if !ok || s.ID == "" || s.State != pier.StateRunning { + continue } - return m.openPools() - case "s": - cfg, err := config.Load() - if err != nil { - m.status, m.statusBad = err.Error(), true - return m, nil + 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 } - m.cfg, m.setIdx, m.editing, m.picking = &cfg, 0, false, false - m.mode = modeSettings - case "r": - m.loading = true - return m, tea.Batch(m.fetch, tickCmd()) } - return m, nil + return next } -// 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 - return m, nil +func clamp(i, n int) int { + if i >= n { + i = n - 1 + } + if i < 0 { + i = 0 } - 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} }) + return i } -func (m model) updateSettings(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.editing { - return m.updateSettingEdit(msg) +// --- keys -------------------------------------------------------------------------- + +func (m model) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if k.String() == "ctrl+c" { + return m, tea.Quit } - if m.picking { - return m.updateSettingPick(msg) + 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 } - m.status = "" - switch msg.String() { - case "q", "esc", "ctrl+c": - m.mode = modeList - case "up", "k": - if m.setIdx > 0 { - m.setIdx-- - } - case "down", "j": - if m.setIdx < len(config.Settings)-1 { - m.setIdx++ + + if m.authRequired { + switch k.String() { + case "enter": + return m, tea.ExecProcess(m.be.ReauthCommand(), func(err error) tea.Msg { return reauthMsg{err} }) + case "q", "esc": + return m, tea.Quit } - case "enter": - return m.openSetting() + 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) - } - if len(cat) == 0 { - m.editing, m.setInput = true, cur + switch k.String() { + case "q": + return m, tea.Quit + case "esc": + if m.tab != tabSessions { + m.tab = tabSessions 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.picking, m.pickIdx = true, optIndex(m.pickOpts, cur) - default: - m.editing, m.setInput = true, cur + 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 + m.note("refreshing") + return m, tea.Batch(m.fetch, m.fetchQuota) + case "s": + m.tab = tabSettings + return m, nil } - 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] - } + switch m.tab { + case tabSessions: + return m.keySessions(k) + case tabRepos: + return m.keyRepos(k) default: - if msg.Type == tea.KeyRunes && !strings.ContainsRune(string(msg.Runes), ' ') { - m.setInput += string(msg.Runes) - } + return m.keySettings(k) } - return m, nil } -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 - } - switch msg.String() { - case "q", "esc", "ctrl+c": - m.picking, m.status = false, "" +func (m model) keySessions(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch k.String() { case "up", "k": - if m.pickIdx > 0 { - m.pickIdx-- - } + m.sessIdx = clamp(m.sessIdx-1, len(m.sessions)) + return m, nil case "down", "j": - if m.pickIdx < last { - m.pickIdx++ + 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 } + m.ov, m.input = ovNew, "" + return m, nil + } + s, ok := m.current() + if !ok { + return m, nil + } + switch k.String() { 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.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 + } + 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 } - return m.saveSetting(m.pickOpts[m.pickIdx].Value) + m.ov, m.pickIdx = ovResize, 0 + for i, mc := range m.machines { + if mc.Type == s.InstanceType { + m.pickIdx = i + } + } + return m, nil + case "l": + return m.openLogs(s) } return m, nil } -// 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 +func (m model) current() (pier.Session, bool) { + if len(m.sessions) == 0 { + return pier.Session{}, false + } + return m.sessions[clamp(m.sessIdx, len(m.sessions))], true +} + +// 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 } - // 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) - }) - if err != nil { - m.status, m.statusBad = err.Error(), true + if err := pier.CheckReady(s); err != nil { + 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.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)} } + } + // 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)} + } } -// 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 - } +func (m model) execAttach(s pier.Session) (tea.Model, tea.Cmd) { + cmd, stop, err := m.be.Attach(context.Background(), s) + if err != nil { + m.attaching = "" + m.fail(err) + return m, nil } - return 0 + m.attachStart = time.Now() + cmd = pier.ConnectingScreen(cmd, s.Name, false) + 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} + }) } -// 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" - } - return "aws-ec2" +func (m model) confirm(q string, do func() tea.Cmd) model { + m.ov, m.confirmQ, m.confirmDo = ovConfirm, q, do + return m } -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 - 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()) +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 m, nil } -func (m model) updateNew(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "esc", "ctrl+c": - m.mode = modeList - case "enter": - if m.input == "" { - return m, nil - } - if m.opts.CreateDetached == nil { // no creator wired (tests) - m.mode = modeList +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 } - 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) + 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) 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 -} - -// 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) 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": + m.ov = ovNone + s, ok := m.current() + if !ok { + return m, nil } - } - m.mode = modePool - if !m.polling && anyFilling(m.members) { - m.polling = true - return m, pollCmd() + t := m.machines[m.pickIdx].Type + if t == s.InstanceType { + return m, nil + } + 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 } -// 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 - } -} +// --- repos --------------------------------------------------------------------------- -// 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) +func (m model) currentRepo() (pier.Repo, bool) { + if len(m.repos) == 0 { + return pier.Repo{}, false } - sort.Strings(rows) - return rows + return m.repos[clamp(m.repoIdx, len(m.repos))], true } -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 + 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 } - 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 + if pier.BakeRunning(r.Name) { + m.note("a bake of " + r.Name + " is already running · log " + tilde(pier.BakeLogPath(r.Name))) + return m, nil } - 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 + 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("the pool fills from the repo's checkout — run pier from inside " + r.Name)) 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 { + return m.setPoolSize(r, r.PoolSize+1) + case "-", "_": + if r.PoolSize == 0 { 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 + 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 } - if m.poolSizes[row] == 0 { - m.status, m.statusBad = "pool size is 0 — press + then enter to warm one", false + 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 } - 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.PoolSize == 0 { + m.note(r.Name + " has no pool — + sets its size") 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 + 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 { + m.note(r.Name + "'s pool is empty") return m, nil } - m.poolConfirm = true - case "r": - m.loading = true - return m, tea.Batch(m.fetch, tickCmd()) + 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 { + 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 } -// 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) setPoolSize(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))) - } - if st.Stale > 0 { - parts = append(parts, ui.Bad.Render(fmt.Sprintf("✗ %d stale", st.Stale))) + root := r.Name + if r.Current { + root = m.opts.RepoRoot } - 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.SetPoolSize(context.Background(), root, n, nil) + note := "pool off for " + r.Name + if n > 0 { + note = "pool size " + strconv.Itoa(n) + " 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{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - -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") +// --- settings -------------------------------------------------------------------------- - 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 m, nil } - 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 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" + case tea.KeyRunes, tea.KeySpace: + m.input += string(k.Runes) } - 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 + return m, nil } -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 +// 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 } -} - -func age(t time.Time) string { - if t.IsZero() { - return "-" + 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") + if f.Key == "theme" { + applyTheme(val) + m.note("accent color saved") } - 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)) + 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."): + // 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 6079c04..0e690d4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,744 +1,392 @@ 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/internal/ui" + "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 + filled []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}, + 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}, }, - }} - - 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) ListSessions(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) 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 +} +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) 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) { + 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) 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) 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 { + 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.ListSessions(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) - } +// Pooled sessions are inventory, not work: they never sit in the session +// table, but the header counts them. +func TestPooledSessionsStayOutOfTheTable(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) - } + if strings.Contains(v, "pool-flb-1") { + t.Error("a ready session must not render as a session row") } - - // 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 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 pooled") || !strings.Contains(v, "checkout-flow") { + t.Errorf("header must count pooled 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") - } - 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) + m, cmd := press(t, m, "n") + if cmd != nil || len(f.removed) != 0 { + t.Fatal("n must cancel") } - 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 - } - } +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") } - if waited != 1 || rm == nil { - t.Fatalf("the batch must run the reachability wait, got waited=%d msg=%v", waited, rm) - } - 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", "POOL"} { + 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}, +func TestPoolKeys(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(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) + m, _ = press(t, m, "down", "+") + if !m.statusBad { + t.Error("+ on another repo must explain that fills need its checkout") } - 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) + m, _ = press(t, m, "up", "f") + if _, cmd := press(t, m, "f"); cmd != nil { + settle(t, m, cmd) } - - // 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)}, - } - 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 len(f.filled) == 0 { + t.Error("f on this repo must fill its pool now") } - 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, "x") + if m.ov != ovConfirm || !strings.Contains(m.confirmQ, "drain flb-estimation") { + t.Errorf("x must ask before draining, got %q", m.confirmQ) } } -// 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", "pool size"} { 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.PoolSize != 0 { + t.Errorf("choosing Lean must apply the preset, got %s ready=%d", f.cfg.Profile(), f.cfg.Speed.PoolSize) } +} - 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. @@ -761,3 +409,76 @@ 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") + } +} + +// 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") + } +} + +// 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 new file mode 100644 index 0000000..2227ed2 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,645 @@ +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/internal/ui" + "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 pooled", 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 { + if a := pier.Activity(s); a != "" { + return sDim.Render(" · " + a) + } + if s.State == 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("POOL", 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(poolCell(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 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.PoolSize) + 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("size %d", r.PoolSize) + if r.PoolSize == 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("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, "") + 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). 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, 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)) + } + 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" + line = sAccent.Render("❯ ") + 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", "+/-", "pool size", "f", "fill", "x", "drain", "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 + } + 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+2) + " " + 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("+ -", "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`."), + } + 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 328b998..4640b5d 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("#7C3AED"), + "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) + } + } +} 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 cf6e73c..9d2fde8 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 a ~5 min 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 — ~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) { - // 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), - 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 + 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("a pool of %d per repo, bake reminders %s", cfg.Speed.PoolSize, 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..2617a3e --- /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: 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 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. + 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: "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: "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 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() + 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/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/client.go b/pkg/pier/client.go new file mode 100644 index 0000000..34c8a7f --- /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, 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. +// +// 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..e04e239 --- /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 + // 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 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 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 +// 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) { + 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.NoPool + 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 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("nothing in the pool 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 pooled sessions back up — after a claim (one was consumed) + // and after a fallback create (there were too few) alike. + res.FillLog, res.FillErr = c.SpawnFill(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..ead42cd --- /dev/null +++ b/pkg/pier/events.go @@ -0,0 +1,55 @@ +package pier + +import ( + "errors" + "time" + + "github.com/usepier/pier/internal/driver" +) + +// 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") + ErrNoKey = driver.ErrNoKey +) diff --git a/pkg/pier/repos.go b/pkg/pier/repos.go new file mode 100644 index 0000000..52235a5 --- /dev/null +++ b/pkg/pier/repos.go @@ -0,0 +1,501 @@ +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 + // 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 +} + +// 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 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 { + 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 := FoldPool(sessions, name, cur, curGen, maxAge, now) + r := Repo{ + Name: name, Current: name == cur, + Image: c.cfg.BakedImage(name), + 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 + } + 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 +} + +// PoolStats is one repo's pool census. +type PoolStats struct { + Ready, Filling, Stale int + Ages []string // pooled sessions' ages, as listed +} + +// 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 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 + } + 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 pooled 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), c.buildFingerprint()), + 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 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) 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) + return nil + }) + if err != nil { + return "", err + } + c.cfg = next + if n == 0 { + _, err := c.DrainPool(ctx, repo, progress) + return "", err + } + return c.SpawnFill(repoRoot) +} + +// DrainReady destroys a repo's unclaimed pooled sessions (any repo; no local +// checkout needed) and returns how many went down. +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 + } + return pool.Drain(ctx, c.drv, sessions, repo, progress.stepper(time.Now())) +} + +// 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 + } + return pool.Fill(ctx, pp) +} + +// 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) 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, "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") +} + +// 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 +} + +// 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 new file mode 100644 index 0000000..49d3b1a --- /dev/null +++ b/pkg/pier/sessions.go @@ -0,0 +1,404 @@ +package pier + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/usepier/pier/internal/config" + "github.com/usepier/pier/internal/driver" + "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 +// 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 + } + merged, revived := mergeTombstones(sessions, tombstone.List(config.Dir())) + for _, name := range revived { + tombstone.Dismiss(config.Dir(), name) + } + 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. +func SplitPool(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 { + // 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 + } + 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 + case "attached": + s.Attached = true + } + 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. 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 { + 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) +} + +// 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 +// 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 errors.Is(err, driver.ErrNoKey) { + return err // waiting won't conjure the key + } + 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 +} + +// 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 +} + +// 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 "" +} diff --git a/pkg/pier/sessions_test.go b/pkg/pier/sessions_test.go new file mode 100644 index 0000000..8e567a7 --- /dev/null +++ b/pkg/pier/sessions_test.go @@ -0,0 +1,114 @@ +package pier + +import ( + "errors" + "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) + } +} + +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") + } +} + +// 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 := []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 := 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 := []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 := 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) + } +} + +// 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) + } +} diff --git a/skills/pier-onboard/SKILL.md b/skills/pier-onboard/SKILL.md index 9ce76bb..208a423 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 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 @@ -75,6 +75,14 @@ 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, and every new session from that image runs +it again, over the dependencies, build caches and volumes the earlier run +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". + Prefer the repo's own entry point (`make install`, `make dev-setup`) over duplicating its steps. Typical shape: @@ -95,6 +103,12 @@ where possible. A bare background process must fully detach — when the script ends and SIGHUPs its process group (`nohup` alone does not detach it). +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 `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). + ## Step 4 — write `.pier/include` (only if ignored files are needed) One path or glob per line, relative to the repo root. `*`, `?`, `[]` match @@ -123,12 +137,16 @@ 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), - 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 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 — pooled sessions + notice and recycle on the next claim or refill.