diff --git a/docs/feature-map/README.md b/docs/feature-map/README.md index 024a262795b..4394aab3249 100644 --- a/docs/feature-map/README.md +++ b/docs/feature-map/README.md @@ -328,6 +328,7 @@ Third-party and builtin apps that add their own pages, crons and MCP tools. | Builtin app surfaces | Top-level routes builtin apps claim | `/` via the `/:builtinApp/*` catch-all | `apps/builtinRegistry.ts` → per-app page | per-app `backend/routes.py` | `POST /api/apps//...` per app | | Dev Fleet Make Live | Repoint the live gateway at a worktree (stage the cutover, cancel a staged one, restart into it) — runs in the gateway process because the pointer is bind-masked from the sandboxed backend; owner-only, every app token refused | `/dev-fleet` → row → **Make live** / **Cancel staged cutover** / **Restart** | `pages/DevFleetPage.tsx` (`devFleetApi.ts` `postGateway`; the row controls in `pages/DevFleetPageRow.tsx` and `pages/DevFleetPageMenus.tsx`, the restart handshake in `pages/devFleetPageModel.ts`) | `src/kiro_crew/apps/builtins/dev_fleet/gateway_routes.py`, `live.py` (owners `live_leases.py`, `live_service.py`, `live_plan.py`); `src/kiro_crew/service/live_target.py`; the backend reads pointer state back through `pointer_broker.py` | `POST /api/apps/dev-fleet/make-live`, `POST /api/apps/dev-fleet/restart-gateway`; backend-token-only `GET /api/apps/dev-fleet/live-target`, `POST/PUT/DELETE /api/apps/dev-fleet/live-target/removal-lease` | | Dev Fleet Switch back | One safe return to the previous checkout after a completed Make Live cutover; the banner is absent while a cutover is only staged | `/dev-fleet` → completed-cutover banner → **Switch back** | `pages/DevFleetPage.tsx` (the banner in `pages/DevFleetPageNotices.tsx`) | `src/kiro_crew/apps/builtins/dev_fleet/fleet_state.py`, `gateway_routes.py`, `live.py`, `live_plan.py`; `src/kiro_crew/service/live_target.py` | `GET /apps/dev-fleet/api/fleet`, `POST /api/apps/dev-fleet/make-live` (`undo: true`) | +| Dev Fleet release-channel worktree | A detached worktree pinned to the newest published `stable` release, resolved from a private pruned tag mirror (`refs/dev-fleet/release-tags/*`) and verified against the oid the remote advertises, so a local tag cannot masquerade as a release. Carried as an ordinary fleet row, so a shipped build can be podded beside unreleased work. The pin never moves in place — a newer release is Remove then Create — and nothing here touches `$KIROCREW_HOME/channel`, so it does not change which channel this install updates from | `/dev-fleet` → the `release-channel-stable` row → **Create worktree** on the placeholder | `pages/DevFleetPage.tsx` (the placeholder row, the version badge and the `n/a` PR and Behind cells in `pages/DevFleetPageReleaseChannel.tsx`, the Rebase suppression on the channel row in `pages/DevFleetPageRow.tsx`, the payload types and row grouping in `pages/devFleetPageModel.ts`) | `src/kiro_crew/apps/builtins/dev_fleet/release_channel_pin.py` (resolve), `worktree_ops_release.py` (Create), `fleet_state_release_channel.py` (row state), `fleet_state.py` (publishes the row), `http_api.py`, `server.py` | `GET /apps/dev-fleet/api/fleet` (the `release_channel` key), `POST /apps/dev-fleet/api/release-channel/create` | | App session controls | A compact per-chat control an app contributes to the composer, handed the active session's identity | Chip in the composer bar of any chat, when an enabled app declares `contributes.sessionControls` — no route of its own | `hooks/useSessionControls.ts`, `components/SessionControlHost.tsx`, `components/ChatInput.tsx` (chips), `pages/ChatPage.tsx`, `pages/chat/page/ComposerDockView.tsx` and `pages/chat/page/sessionControls.ts` (wiring) | `src/kiro_crew/apps/manifest_contributes.py` (declaration), `src/kiro_crew/apps/manifest.py` (validation); the status route is app-owned | `GET /api/apps`, `GET /api/apps/{name}/{statusPath}` (in-gateway) or `GET /apps/{name}/api/{statusPath}` (process-backed) | | App rail run state | The state of an app's own scheduled jobs on its rail icon — running, failed, or succeeded within the last 90s. Derived from the app's crons rather than pushed: the host already owns the in-flight marker, `last_status`, and the `created_by` owner stamp, so an app declares nothing new and cannot set its own mark. A user-paused job contributes nothing, which is the opt-out that works with the app's page closed | Mark on the row of an enabled installed app that OWNS a cron job — whether declared in `contributes.crons` or created at run time through `ctx.cron.add_job()`; no route of its own. Covers builtin and AppHost-routed apps alike: the row carries the app's resolved `name`, so a builtin's bare nav id is not parsed back | `appRunState.ts`, `shell/nav/railBadges.ts` (reads the crons), `shell/nav/navRow.tsx` (`RunStateIndicator` in `NavBadge`) | `src/kiro_crew/dashboard/handlers/cron.py` (`api_crons` serializes the owning `app` and `user_paused`); `src/kiro_crew/apps/cron_sdk.py` (`owner_tag` / `app_owner_name`) | `GET /api/crons` | diff --git a/docs/system-specs/modules/dev-fleet.md b/docs/system-specs/modules/dev-fleet.md index aec62e205f2..a08a9be813a 100644 --- a/docs/system-specs/modules/dev-fleet.md +++ b/docs/system-specs/modules/dev-fleet.md @@ -24,6 +24,9 @@ Gateway session auth (token/cookie) gates the proxy entrance as with all builtin 6. **GitHub PR status** — TTL-cached `gh pr list` queries for merge state 7. **Make Live** — repoint the live gateway at another worktree via a live-target pointer file (no service definition is ever mutated) +8. **Release-channel pin** — one detached worktree at the newest published `stable` + release, resolved from a private pruned tag mirror and materialized by an + argument-less Create (see Release-channel worktrees) ## Backend component boundaries @@ -45,8 +48,19 @@ behind that facade is split by state and lifecycle ownership: systemd/launchd/foreground restart backend, service probes, the drop-in); - `live_plan.py`: a cutover's dry-run plan, operator-facing text, and the Switch-back undo-binding check (`_undo_binding_holds`). +- `release_channel_pin.py` owns release-channel naming, release-tag classification, and + tip resolution. Read-only by construction, so the fleet snapshot can resolve the tip + with no risk of moving a worktree; the Create mutation lives in + `worktree_ops_release.py` because it takes the same `.git` admin lock every other + worktree writer takes. It resolves from a private tag mirror rather than `refs/tags/` — + see Release-channel worktrees. +- `fleet_state_release_channel.py` owns the release channel's fleet row: the resolved tip + from `release_channel_pin.py`, the tree holding the reserved name, and the three-state + adoption rule (detached pin, branch checkout holding the name, unreadable). Reads + `release_channel_pin.py`, `repository.py` and `runtime.py` only. - `fleet_state.py` owns PR/context/resource caches, fleet projections and tombstones, and - provision reattachment state. PR status is cached for `_PR_TTL` (300 s); the context + provision reattachment state; it asks `fleet_state_release_channel.py` for the finished + channel row. PR status is cached for `_PR_TTL` (300 s); the context `git log` is reused while the row's git facts key holds (`_CTX_LOG_CACHE`), and a log over `_CTX_LOG_MAX_CHARS` (64 Ki chars) is used once and never kept. - `git_facts.py` owns the fleet view's per-worktree git facts cache (`_GIT_FACTS_CACHE`), @@ -60,7 +74,10 @@ behind that facade is split by state and lifecycle ownership: Its owners hold the rest: - `worktree_ops_pods.py`: pod actions and the per-worktree lock `_wt_lock`; - `worktree_ops_removal.py`: removing one worktree, and `_GIT_MUTATION_LOCK`; - - `worktree_ops_sync.py`: Pull + Build sync of the main checkout. + - `worktree_ops_sync.py`: Pull + Build sync of the main checkout; + - `worktree_ops_release.py`: release-channel Create — the mirror fetch, the provenance + check, the staging `worktree add`, the hermetic populate, and the `worktree move` + that adopts the tree. - `http_api.py` owns proxy HMAC verification, request/audit adapters, and response-shape translation. @@ -261,7 +278,7 @@ verification. Route names below are relative to that prefix. | `/apps/dev-fleet/api/fleet` | Lightweight worktree + pod list (polled every 12s), including `main_repo` and `main_repo_inferred`. `?fresh=1` forces cache bypass. Answers `{worktrees: [], needs_setup: true}` when no main checkout was found (see Main Checkout Discovery) and `{worktrees: [], error}` when a named checkout is unreadable. | | `/apps/dev-fleet/api/worktree?name=` | Lazy per-branch detail: PR, commits, disk usage | | `/apps/dev-fleet/api/pod/logs?name=&n=` | Pod journal tail (recent N lines, default 120, clamped to 1..1000) | -| `/apps/dev-fleet/api/run?id=` | Async run status + streamed output (last 60 lines), plus `cause` on a sync failure the gateway can name | +| `/apps/dev-fleet/api/run?id=` | Async run status + streamed output (last 60 lines), plus `cause` on a sync failure the gateway can name, and `result` on a finished in-process run (the release-channel create) | | `/apps/dev-fleet/api/prune-candidates` | List worktrees eligible for pruning | | `/apps/dev-fleet/api/prune-status` | Live prune progress: per-item state machine (`items`) + backward-compatible top-level counters | | `/apps/dev-fleet/api/disk` | Aggregate disk usage per worktree (async computation) | @@ -280,6 +297,7 @@ verification. Route names below are relative to that prefix. | `/apps/dev-fleet/api/pod/provision` | `{name}` | Start async venv+dist build (returns `{run_id}`) | | `/apps/dev-fleet/api/pod/provision/dismiss` | `{name, run_id}` | Forget a terminal provision failure when the run id still matches | | `/apps/dev-fleet/api/rebase` | `{name}` | Rebase worktree onto `{remote}/{base branch}` | +| `/apps/dev-fleet/api/release-channel/create` | `{}` | Start materializing the release channel's detached worktree at the channel tip (returns `202 {run_id}`; the result is read from `/api/run?id=`); the backend serves it as `/api/release-channel/create` and it takes no argument (see Release-channel worktrees) | Seven routes are served by the **gateway process** rather than the backend, under the in-gateway namespace `/api/apps/dev-fleet/` (`gateway_routes.py`, mounted by the @@ -442,6 +460,10 @@ in-gateway read and lease routes admit only Dev Fleet's own backend token. - Ambiguous worktree names (multiple checkouts with same basename) return HTTP 400 - `force` must be a boolean when provided - Main worktree removal is always refused regardless of force flag +- The release-channel mutation validates nothing, because it reads nothing: the endpoint + takes no argument, so there is no value to reject, sanitize, or smuggle into a git ref + or a directory name. The ref comes from resolving the one channel and the directory from + `WORKTREE_NAME`, both module constants. ## Prune Rules @@ -463,6 +485,25 @@ and `dirty_check_failed`. `merged_dirty`, `closed_dirty` and `active` can be ove by discarding or forcing. The auto-prune reaper (`_auto_prune_once`) removes only `code == "merged"`; `closed` and `empty` stay manual. +**The release-channel pin is never an `empty` candidate; nothing else changes.** The +`empty` verdict reads "zero own commits, not dirty, older than 48 hours" as ABANDONMENT, +and that inference does not hold for the pin: a detached tree has no branch and therefore +no PR, and holds no commits the base branch lacks — a release tag is an ancestor of the +base branch — so `own` is 0 by construction rather than by neglect. Past 48h `empty` is a +candidate the prune preview **preselects**, so without the guard the pin is preselected for +deletion in its steady state. The condition is BOTH halves of what identifies the pin: +detached (`branch is None`) AND the reserved basename (`release_channel_pin.WORKTREE_NAME`, +read from the path with no git probe, because prune runs over the fleet listing before +`fleet_state` classifies the channel and must not depend on a classification that can +fail). A branch checkout that merely holds the reserved name is an ordinary feature tree +and falls through to the merged/closed/active logic; a tree the operator detached by hand +under any other name keeps the `empty` verdict it has today, so Prune merged behaves on +every install exactly as it did before the channel row existed. There is no +`release_channel` verdict code and no reason string for one; the withheld pin reports +`fresh`. The check lives in `_prunable` and not in the caller because the removal path +re-asks for a fresh verdict before removing, so one condition covers both the preview and +the execution. + ### Parallel execution & per-item progress (issue #435) `prune-run` accepts a batch of names and processes them **concurrently** rather than one @@ -1515,6 +1556,489 @@ Fleet's build-pending badge compares `static/dist`'s mtime with the gateway's start, so a frontend-only `npm run build` served live through the dev link raises it too, although no restart is needed for that build. +## Release-channel worktrees + +One detached worktree for the published release channel, so a shipped build can be run +and clicked through next to unreleased work. It is an ordinary fleet row, which is the +whole point: pods and Make Live already drive any row, so a release boots the way a +feature branch does. + +`release_channel_pin.CHANNEL` is the literal `"stable"`, and the surface is singular on +purpose. There is no channel parameter, no channel key in the request, no map from channel +to resolved tip, and no list in the payload — each would be a shape with one possible +value that no caller could vary. Admitting a second channel is a change to this module's +shape, and the shape of that change is worth naming so it stays a step rather than a +surprise: `release_channel` is a single object, so a second channel turns it into a +collection — a plural key or a nested map, a row renderer that loops, a channel argument +back on the route, and `CHANNEL` as a tuple rather than a string. That is listed as a cost, +not specified here. + +The two channels left out are left out for different reasons. `nightly` is not resolvable +from git at all: it builds from the base branch's HEAD on a schedule and tags nothing, so +the newest ref a nightly row could name is `/` — which is the base +branch *now*, not the commit the last nightly published, and is where the primary checkout +already sits after Sync. `insider` tags do resolve, and ranking them is what has no answer: +ordering `-insider.N` against `-rc.N` needs a precedence between two prerelease spellings +that nothing in this repository states, so a prerelease tip would rest on a rule this +feature invented rather than on a fact. + +### Why a worktree and not a pin on the primary checkout + +Sync fast-forwards the primary checkout (`git merge --ff-only`) and refuses to run unless +HEAD is literally `BASE_BRANCH`. A stable tag is normally *behind* the base branch, so +pinning that checkout could only work by detaching its HEAD — which the sync guard rejects, +and which silently repoints every base-branch comparison on the row — or by resetting the +base branch backwards, which destroys work. The channel therefore gets its own detached +worktree: additive, non-destructive, and a row the rest of the app already knows how to +drive. + +### Not coupled to the update channel + +Nothing here reads or writes `$KIROCREW_HOME/channel`. That file says which channel the +user's real install *follows for updates*; a pin here says which git ref a worktree *sits +on*. Coupling them would mean materializing a stable worktree changed what the live install +downloads next. Only vocabulary is borrowed. + +### Naming + +`release_channel_pin.WORKTREE_NAME` is `release-channel-stable`, a sibling of the primary +checkout. The basename is the fleet row label (`Path(path).name`, verbatim) and the pod +identity (`kirocrew-pod@release-channel-stable.service`), and it satisfies the pod name +rule `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,60}$`. It deliberately omits the `kirocrew-wt-` prefix +every feature worktree carries: that difference is what separates the two groups visually +with no extra chrome. Nothing filters on the prefix — discovery is plain `git worktree +list`, and the derived `-wt-` prefix is only used to pick PR-lookup fallbacks for +pre-rename repo names. + +### Resolution + +| Channel | Resolves to | +|---------|-------------| +| `stable` | newest tag matching `v..` | + +Resolution is to a TAG, which is why this channel is the one admitted: an untagged channel +has no ref naming a specific published build. + +`is_release_tag` treats only a bare `vX.Y.Z` as a release tag, and its shape check IS the +classification: every such string is stable by definition, so there is no suffix left for a +classifier to read. A prerelease tag is rejected at the shape check rather than classified +and then discarded, and there is no second regex for the prerelease spellings — a pattern +whose every match is thrown away cannot change an outcome. + +**Ordered by semver, descending, and creation date is not that order.** A stable tag is +exactly `vX.Y.Z`, so its order is total and unambiguous. A backport cut after a newer line +(`v0.4.2` tagged after `v0.5.0`) is the newest tag by date and an older release by version, +and a re-pushed tag carries today's date for last year's release; either would pin the row +to a release stable users are not on. Parts are compared numerically, so `v0.10.0` beats +`v0.9.0`. The key comes from `apps.version.parse_version`, the repo's one version parser, +rather than a second parse spelled out here — and every tag reaching the sort already +matched the release-tag shape, so it always parses. The order is total under ties, so a +repeat resolve returns the same tag rather than flickering as git's date ordering shifts +under a re-fetch. The listing itself asks git for no order at all, because a tag order +asked for there would have no consumer and would read like the order the tip is chosen by. + +**The channel resolves from a private tag mirror, never from `refs/tags/`.** The mirror is +written by one refspec, `+refs/tags/v*:refs/dev-fleet/release-tags/v*` — forced, because a +re-pushed tag must land — and resolution reads only that namespace. `refs/tags/` cannot be the source of truth because the operator writes +to it too: a locally cut release candidate, a bisect marker, a `v99.0.0` tagged to test an +upgrade path. Each matches the stable tag shape, sorts newest, and under a plain `git tag +--list` becomes the channel tip — so the row would badge an unpublished commit as a shipped +release and Create would check it out. Nothing distinguishes a local tag from a fetched one +after the fact, so the separation happens where the ref is written; a mirror the operator +has no reason to write to makes "published" a property of WHERE the ref is rather than a +claim about how it got there. The same mirror answers the row's own version probe, because +`git tag --points-at HEAD` would let a local tag rename the row for the same reason. The +refspec lives in one module constant that every fetch site uses, because a second spelling +that drifted would fail silently: the channel would resolve against a mirror nothing +updates, which is indistinguishable from a repository that has published nothing new. + +That refspec is the ONLY thing the channel fetches. Nothing here writes the operator's own +`refs/tags/`: no `--tags`, no `refs/tags/*` destination, and `--no-tags` so git's default tag auto-follow cannot store the remote's tags beside the mirror, so the channel never changes a ref +the operator did not ask for, and a tag that reaches `refs/tags/` by any other route cannot +become a tip because the channel reads only the mirror. + +**The reported `ref` is the mirror ref, `refs/dev-fleet/release-tags/`.** It is exactly +the ref that resolved and it is checkout-able as it stands. `refs/tags/` is not named: +whether that ref exists in the operator's checkout, or points at this commit rather than at +a tag of their own with the same name, is not a fact the channel writes or can vouch for, +and publishing a name the resolve did not read would send an operator who acts on it to +whatever their local tag happens to hold. `version` and the badge carry the human-readable +release; `ref` is for git. + +The mirror listing is **bounded at the source**: git is asked for a `--count` one above the +accepted maximum, and the one extra row — or any ref name longer than the maximum name +length — is a `MirrorOverflow` raised on the RAW rows, before a single name is filtered, so +an overlength row cannot mask the count and a dropped name cannot pass for an absent one. +No release tag this channel admits is that long; a longer one is something else wearing the +mirror namespace, and it is reported rather than skipped. Refusing to name a tip beats +sorting a truncated set, which could silently pick the wrong release. + +An **empty mirror is a state, not an error**. A checkout that has not fetched genuinely does +not know what is published, so it reads as "no release found" — the payload's `unpublished` +— and the next refresher cycle fills it; `None` from the listing is reserved for git itself +failing, which is a red row. Conflating them would either put an error on an ordinary cold +start or, worse, invite a fallback to `refs/tags/` on exactly the path where the mirror is +least trustworthy. + +Create fetches **before** resolving, so it acts on the channel's real tip; resolving first +would pin a months-old release and call it the tip. The background `_status_refresher` tick +refreshes the mirror on every cycle with the same fetch argv, and that is load-bearing +rather than tidy: without it the mirror is refreshed only *inside* a mutation, so `at_tip` +reads true against a stale mirror and the version badge describes a tip that has already +moved. With no in-place advance, the row's report IS the whole product of resolution — an +operator decides whether to remove and re-create from what it says — so a stale one is the +only failure mode left. It fetches for every install, ungated: tags are small refs and only +the first fetch transfers them, so an operator without the worktree pays approximately +nothing, where a gate would cost a `git worktree list` each cycle plus a fail-open branch. + +**The mirror is PRUNED, and that is what makes it a fact rather than a habit.** The fetch +that writes the mirror carries `--prune`, so a mirror ref the remote does not advertise is +deleted. Without it, "the operator has no reason to write here" is a statement about habit +and not a boundary: a ref namespace is writable by anything with access to the repository — +an agent with git is exactly that — so one `git update-ref +refs/dev-fleet/release-tags/v999.0.0 ` outranks every real release by semver and +survives every additive fetch. That costs two things, and Create's integrity is not one of +them (the provenance check below owns that). First the **fleet rows**: the display path +resolves out of this same mirror and has no provenance backstop, so the forged ref is badged +as the stable release. Second Create's **availability**: the forgery is picked by every +resolve and refused by every provenance check, so Create stays refused until someone +hand-runs `git update-ref -d`. Pruning means a forged entry cannot outlive the fetch that +precedes every mutation. + +`--prune` is **not** `--prune-tags`, and the two must not be conflated. Pruning is scoped to +the **destinations of the refspecs actually given**, which `git-fetch(1)` states outright: the +destination of an explicit refspec is what gets pruned. The refresh therefore runs as **two +fetches**, so that scoping is structural +rather than argued. The pruning fetch names the mirror refspec alone and no +tags at all, so it deletes a forged mirror ref **and** a tag the remote retracted, while +`refs/tags/` is not among its destinations for a **local-only tag the operator authored** to +be reachable by, and a stale remote-tracking branch is left alone. `--prune-tags` is the flag +that contributes an implicit `+refs/tags/*:refs/tags/*` and destroys the operator's own tags; +it stays refused, and a test asserts its absence. The pruning fetch also passes +`--no-prune-tags` explicitly: `fetch.pruneTags` (or `remote..pruneTags`) in the +repository's own `.git/config` — a file an agent can write — would otherwise switch that +implicit refspec on for every refresher cycle. The flag pins the scoping whatever the config +says. This follows from the documented scoping rule rather than from one measured version, +since `--prune-tags` is the opt-in shorthand git **2.17 added**, so no older git prunes +`refs/tags/` more eagerly than this does. + +One fetch runs at a time per event loop (`_FETCH_LOCK`): the refresher and a Create fetch the +same refs, and two concurrent fetches contend for the same ref locks, so the loser's `cannot +lock ref` would refuse a Create that a second later succeeds. A second gateway is its own +fetcher; git's lock files arbitrate between processes. A failed refresher fetch is logged and +the fleet is still rebuilt from what the mirror holds: the mirror *is* what this checkout has +fetched, exactly as every row's Behind is measured against the last `/` a fetch +brought in. The channel's `error` is reserved for a git failure that blocks Create, which a +transient network failure is not. + +**The refresher's two fetches are two spawns, so their failures stay apart.** Every cycle +the refresher fetches the base branch exactly as it did before the channel existed (`git +fetch --quiet`, its own argv, no pruning) and then calls `fetch_refs` for the +mirror. The base branch is what every row's Behind is measured against; the mirror is the +channel's alone. Sharing one argv would have made a mirror the remote cannot serve leave +Behind stale for every row, and a base branch that will not fetch block the channel. Each +failure is logged with its own line and the fleet is still rebuilt from what this checkout +holds. + +**Pruning bounds persistence, not timing, so the checkout does not trust the mirror.** +`--prune` deletes only refs the remote does not advertise *at fetch time*; it cannot touch a +ref written after that subprocess exits. A ref planted between the fetch and the read that +resolves the tip is therefore unpruned by any fetch that has already run, outranks every real +release if its version is high enough, and its oid is what would be checked out and later run +by the row's pod. The per-worktree and mutation locks do not close that window — they are +per-event-loop, and a writer using `git update-ref` never takes them. + +So Create does not accept the mirror's answer on its own: it asks the configured upstream what +it publishes (`git ls-remote --tags refs/tags/ refs/tags/^{}`) and refuses +unless the resolved oid is the one advertised for that tag. That makes "published" a +**verified fact about that remote** instead of an inference from where a local ref sits, which +closes the ref-planting class rather than the instance — no timing wins, because a locally +planted ref is in the advertised set at no moment. The scope of that guarantee is worth +stating exactly: the remote is the one named in the repository's own git config and git reads +its URL from that config, so this defends against a writer who can plant a **ref**, not +against one who can rewrite `remote..url`. The latter already redirects every fetch the +app performs — including the sync that rebases a worktree onto the base branch — so pinning a +URL would have to be operator-owned state shared by all of those call sites, which is app-wide +hardening and not this feature's to add. Two details are load-bearing: the query names only +the resolved tag's exact ref **and** its explicit `^{}` pattern, so an annotated release yields +both its tag object and its commit without enumerating the remote's tag population; and +parsing accepts only those two refs and retains at most their two bounded oids. An +unadvertised tag is **not** a git error (exit 0, no output), so absence is read from the result +rather than from the exit code. A failed query refuses, and an unadvertised tag refuses rather +than silently falling back to the real oid, because a silent correction would materialize the +right tree and hide that the ref store had been written to. + +The background refresher shares the window but not the consequence: it resolves for the row +badge only and checks nothing out, so the worst case there is a mislabeled row that the next +fetch prunes. Create re-fetches independently, so a forged badge cannot carry into a checkout. + +So **retraction is handled here, not deferred**: the channel stops resolving a yanked release +on the next fetch. `refs/tags/` stays additive, so the operator can still see and check out +such a tag locally after upstream drops it — only the *channel* stops treating it as +published. + +### The name guard + +`release-channel-*` is a reserved basename, so a checkout is adopted as a channel pin only +when it is **detached** — never on the strength of its name. A user's own branch checkout +under that name keeps ordinary controls (including its behind-base-branch count), the fleet +reports `name_taken_by_branch`, and the row is never adopted as a channel pin rather than +having its HEAD moved out from under their branch. The frontend states this on that row +rather than rendering a second placeholder for the same directory. + +### Fleet payload + +`release_channel` is its own top-level key, ONE object whether or not the worktree exists: +`{lane, name, worktree, ref, version, tip_version, unpublished, error, at_tip, +name_taken_by_branch}`. It is **not** an extra `worktrees` row — with no worktree there is no +path, and every consumer of a `worktrees` entry (disk measurement, pod matching, prune +candidacy) assumes one. An adopted tree does also appear in `worktrees`, because it is a real +worktree; the placeholder is never injected there. + +Every field has a reader. `name` is what labels the not-yet-created row, published so the +naming rule lives on the backend only and is never rebuilt in the frontend; `ref` and +`version` are what the badge renders; `lane` names the channel for the sentences that name it +on screen — display data, which is why no request carries it back. The resolved commit id and +the worktree's own HEAD *sha* are deliberately absent: a field carried "for diagnostics" that +no surface shows is a contract nobody keeps. + +`version` is the release **this row is on**, which is not the same thing on both row kinds: on +a placeholder it is the release Create would check out (the channel tip), and on an adopted row +it is the release the tree actually holds, read from `for-each-ref --points-at HEAD` over the +private mirror namespace and filtered to release tags. `tip_version` is always the channel tip, +so a stale row can name both. Publishing the resolved tip as an adopted row's `version` would +rename the badge to every new release as it shipped while the checkout stayed put — the row +claiming a build it does not contain. `version` is `null` when the tree is detached at no +release tag at all, since adoption is by SHAPE rather than by being at a release, and that is a +state the row states rather than papering over with the tip. A listing that exits non-zero (a timeout, unreadable refs) is not the answer "on no release": that answer is an **empty** listing with exit 0. The failed read raises `ReleaseLookupFailed`, which the row carries as its `error` beside `version: null` **and `version_unknown: true`** — the tree stays adopted, its release is unknown, and the badge reads "version unknown" (tooltip pointing at the notice) rather than asserting "not on a release" about a tree nobody read. `version_unknown` is `false` on the positive answer, on every placeholder, and after a removal demotes the row to one. + +Five states come out of those fields, and each is a different sentence on screen: + +| State | Fields | Meaning | +|---|---|---| +| adopted | `worktree` non-null | the detached tree exists; `version` is the release it holds and `at_tip` its currency against `tip_version` | +| placeholder | `worktree` null, `ref` resolved | nothing on disk yet, and Create would check out `ref` | +| unpublished | `unpublished` true | the mirror holds no bare `vX.Y.Z` tag. Not an error, and Create stays enabled — the next fetch may publish one | +| taken | `name_taken_by_branch` true | the reserved basename holds a branch checkout, which keeps its ordinary controls | +| error | `error` non-null | a real git failure, carried to the shared error surface | + +A channel that fails to resolve is still published, carrying its `error`, because "never cut a +stable release" and "git could not be read" want different words on screen. `null` for the +whole key is reserved for a different thing: nothing can be said at all — no checkout, or the +resolve itself raised. Resolution never raises past that; it rides the cached fleet snapshot, +and a failed resolve must not blank the fleet view. + +That `error` is shown ON THE ROW and nowhere else, with deliberately no page-level notice. It +arrives on the 12s fleet poll rather than from a button, so an error-level toast would fire on +every mount for any checkout with no `v*` tags at all (a shallow or `--no-tags` clone, a fork +before its first release) — a normal state, for a feature that operator never opened. One +surface instead: a placeholder renders the error and disables Create on it, and an adopted row +— which has no placeholder, because `worktree` comes from the detached SHAPE while `error` +comes from RESOLUTION — carries it on its badge tooltip. + +`worktree` is non-null only for an adopted tree. The row's currency is the badge: `version` +names the release the tree contains and `at_tip` selects its ok/warn variant relative to the +**channel tip**, not `BASE_BRANCH`. The two are answered independently, so a failed tip lookup +leaves `version` in place — the tree did not change because the channel could not be read — +and sets `at_tip` to `null`, which the badge renders as the release the tree holds with a +tooltip saying the tip could not be resolved, never as "not on a release". The frontend renders +BEHIND and PR as *inapplicable* for this detached pin. + +### The Create mutation + +`POST /apps/dev-fleet/api/release-channel/create` takes an empty JSON body and acts on the one +channel. Because no request names a target, the SEL audit target is declared by the route as +`_audited(..., static_target=WORKTREE_NAME)` — the worktree it acts on. A body scan would +produce the empty string here, and an audit record naming nothing is the defect that shape +avoids; a route declaring a static target also never reads the body, so a client cannot +influence what its own mutation is recorded against. + +**The route answers the run, not the result.** The create is a fetch (120 s), a `worktree add` +(300 s), a populate (300 s) and a `worktree move` (120 s), and the app proxy the dashboard +reaches this backend through drops a request at 30 s (`routes_proxy._PROXY_TIMEOUT`). A route +that awaited the create could therefore never deliver its outcome to the caller that asked for +it; the client would see a 504 while the mutation ran on. So the route starts the create as a +tracked run (`runtime._start_task_run`, the in-process twin of the `_start_run` that sync and +provision use — same `_RUNS` record, same `/api/run?id=` reader, registered in `_ACTIVE_RUNS` +so `dev_fleet_cleanup` cancels it like any other run) and answers **`202 {"ok": true, "run_id", +"name"}`** as soon as the run exists. When the run leaves `running`, its record carries +`result`: the dict the create returned (`ok`, `code`, `error`, `lane`, `ref`, `version`, +`path`), with `exit_code` 0 for an `ok` result and 1 otherwise, or −1 with the reason as the +last output line when the run raised or was cancelled at shutdown. The page polls +`/api/run?id=` every 2 s and frames the result exactly as it framed the synchronous reply +(success toast naming the lane, version and the Provision control; otherwise the row-scoped +notice). `/api/run` scrubs **every string** in `result` on the way out, as it scrubs the output +lines: the owner that wrote the result carries no redactor of its own (git stderr is bounded +and scrubbed by `runtime._error_detail`, the module classified for that path; a path or a ref +is scrubbed only here, where it leaves the process). + +Refusals the route can decide **without** a run are still answered at once, as **409** with +`{"ok": false, "code", "error"}`: `no_repo`, `ambiguous_name` (a second checkout shares the +basename), `exists` (the worktree is registered), and `busy` (another operation holds +`_wt_lock(name)`, or a create is already running). The last case also carries the running +create's `run_id`: the start is single-flight (`_CREATE_ADMISSION_LOCK` makes the check and the +start one step), and a page that lost its own id — a reload mid-create — attaches to the run +named rather than reporting a collision. The running create is checked **before** the preflight, +under that lock: the run body holds `_wt_lock(name)` from its fetch to its move, so a preflight +run first would answer `busy` with no run to name for the whole life of the create, which is +exactly the window the attach exists for. A `busy` with no `run_id` therefore means another +operation — a removal — holds the name, not a create. Every other refusal (`path_exists`, `partial_clone`, `fetch_failed`, +`unresolved`, `unpublished`, `provenance_unverifiable`, `provenance_mismatch`) and the +unexpected git failure (`git_failed`) are results of the run. The preflight that decides the +synchronous refusals runs again inside the run, right before it takes the lock: the route's +answer described the fleet the caller saw, the run's describes the fleet it is about to write. + +**Two audit entries per create.** `_audited` on the route records the admission at the +response boundary as before (a 2xx is "the run started"; a 409 is `denied`). The run's terminal +state records a second entry, `dev_fleet_release_channel_create_result`, through the +`on_finish` hook of the run, with the split a synchronous response would have had: `ok` → +`success`, a refusal code → `denied`, anything else or no result at all → `failure`. The trail +therefore still says both that the mutation was asked for and how it ended. + +The sequence below runs under the per-name `_wt_lock(name)` and then `_GIT_MUTATION_LOCK`, the +subset of the module's lock order that applies to a writer touching no live target: + +1. Refuse if `/release-channel-stable` exists or `git worktree list` holds it. +2. Refresh the mirror: one pruning fetch (`--prune --no-prune-tags`, the mirror refspec + constant and nothing else) whose status decides, as above. +3. Resolve the tip from the mirror only — semver, bare `vX.Y.Z` only. +4. Check provenance against `git ls-remote` and refuse any oid the remote does not advertise + for that tag. +5. `git worktree add --no-checkout --detach `. +6. Populate the staging tree hermetically (below). +7. Verify `git -C rev-parse HEAD` equals the resolved oid. +8. Re-check that nothing exists at `` (under `_GIT_MUTATION_LOCK`), then + `git worktree move ` to adopt it, then verify the landing: `git -C + rev-parse --absolute-git-dir` must equal the staging worktree's own admin dir, read before + the move. +9. Mark the fleet cache adopted (`fleet_state._fleet_adopted`), so a rebuild already in flight + cannot publish a snapshot without the new row (below). + +The staging path is a unique sibling of the final path, named by this call, and any failure at +or after step 5 removes it (`git worktree remove --force`, then `rmdir`), deleting both its +directory and its admin record. Only a clean, fully populated detached tree is adopted. + +The staging step is what makes that cleanup provably safe rather than safe by inference. Aiming +the removal at the worktree's own path would require a single writer: the path-exists refusal +proves only that *this* process saw nothing there, and the locks this operation holds are +per-event-loop, so a second gateway sharing the repository can create that path between the +refusal and the add. The loser's cleanup would then delete the winner's populated worktree, and +untracked files are in no reflog. A per-call staging name means `remove --force` can only ever +name a directory this call made, and `move` decides the half of the race it can see: it +refuses a destination that is a registered **worktree**, so a writer that adopted `` +first wins and the loser discards its own staging tree and reports `path_exists`. + +`move` does **not** refuse a plain directory. Like `mv(1)`, `git worktree move ` with +an existing directory destination moves the source *into* it, exits 0, and registers the +worktree at `/` — nested inside whatever appeared at `` during +the fetch, resolve and populate (a `mkdir`, an operator's own `worktree add`, a checkout another +process is still building). Measured on git 2.54. So the move is bracketed. The destination is +re-checked under `_GIT_MUTATION_LOCK` immediately before it, which closes the window for every +writer in this process. The landing is verified immediately after it, which is the check no +lock can replace for a writer in another process: git is asked which admin dir the worktree at +`` uses, and the answer must equal the staging worktree's own, captured before the move. +Any other answer means `` is someone else's tree and ours is nested inside it; the +nested tree is then removed by *its* path (`/`, ours by construction), +never by ``, and the collision is reported as `path_exists`. The cleanup is best-effort +and never replaces the error it is cleaning up after; git's own message is what the operator +needs. + +**Adoption and the fleet cache.** The fleet snapshot is stale-while-revalidate with one +coalesced rebuild, and removal marks its row with a tombstone so a rebuild that started before +the removal cannot resurrect it. The tombstone reaches the channel key too: `_drop_worktrees` +demotes `release_channel` to its placeholder shape (`worktree` and `at_tip` null, `version` +back to `tip_version`) when the row it was adopted from is dropped, because the page offers +Create only when `worktree` is null — a snapshot with the row gone and the binding kept would +show neither the row nor the control that brings it back until the next rebuild. Creation +needs the mirror image: a rebuild that started before +the adoption read git before the worktree existed, and the `fresh=1` the page sends on success +coalesces onto that build — which would answer with the placeholder the toast just said was +replaced. `_fleet_adopted` bumps the epoch and zeroes the cache timestamp; a build that finds +the adoption epoch past its own start reads git again, once, before publishing. A second +adoption landing inside the re-read is left to the zeroed timestamp, which schedules the next +rebuild behind the snapshot rather than looping. + +### The hermetic populate + +The populate is ONE spawn, `git read-tree -u --reset `, run with an environment that hands +git a common dir this call made instead of the repository's own: + +| Variable | Value | +|---|---| +| `GIT_DIR` | the staging worktree's absolute gitdir (`git -C rev-parse --absolute-git-dir`) | +| `GIT_COMMON_DIR` | a fresh `mode 0700` directory created by this call under the worktrees directory (or the system temp directory), holding a minimal `config` — `bare = false`, and the real store's object format: `repositoryformatversion = 0` for a sha1 store, or `repositoryformatversion = 1` with `extensions.objectformat = sha256` for a sha256 one (read from `git rev-parse --show-object-format`; a common dir that declares no format is read as sha1, and against a sha256 store every oid is then "not a valid object name") — a `HEAD` reading `ref: refs/heads/none`, an empty `refs/`, and `objects/info/alternates` naming the repository's object store by absolute path (resolved through `git rev-parse --git-common-dir`) | +| `GIT_WORK_TREE` | the staging path | +| `GIT_CONFIG_NOSYSTEM` | `1` | +| `GIT_CONFIG_GLOBAL` | `os.devnull` | + +The module's git env neutralizers are applied on top, as they are to every git child here (see +*Git environment hardening*), and the temporary common dir is deleted once the spawn returns. + +**A partial clone is refused before any of this runs.** The private common dir names no promisor +remote — on purpose, since a lazy fetch is a network call and the populate runs on the tier that +has none — so in a `--filter` clone the release's blobs that are not already local make +`read-tree` die part-way with "unable to read", after the fetch and the `worktree add` had +succeeded. The create therefore reads `git config --get-regexp` for `remote..promisor` / +`extensions.partialclone` right after the path check and, if a promisor is **in force** — the +`promisor` value parses as a git boolean true (a hydrated checkout that dropped its filter and +ran `git fetch --refetch` keeps `promisor = false` behind and is a full clone), or the legacy +`extensions.partialclone` names any remote — answers the classified refusal `partial_clone` naming the remedy in the order git needs it: unset +`remote..partialclonefilter`, `git fetch --refetch `, then unset `remote..promisor` +(and `extensions.partialclone` if set) — the last step is the one that clears the marker this refusal +reads, because `--refetch` fills the store but leaves `promisor = true` behind; or use a full clone as +the main checkout. Nothing is fetched and nothing touches +disk. A config that cannot be read answers "not partial": the populate then fails on its own +terms with git's error rather than this refusal guessing. +The alternates entry is why this is cheap: the objects are the repository's, read in place, so +nothing is copied. + +What that buys is a statement about which files git opens rather than about timing. The +repository's own `.git/config` and `info/attributes` are not among them, so no write to either +— at any instant, by any process — can name a `filter..process`/`.smudge`/`.clean` +driver this spawn executes, or redirect through `core.worktree` where it writes. There is +consequently no config probe on this path and no filter or `core.worktree` enumeration: a key +space whose names are chosen by whoever writes the config cannot be enumerated, and a probe +could only ever report on a config this spawn never opens. + +Measured on git 2.54, with `filter.evil.smudge` marked `required` and `core.worktree` planted +in the repository's `.git/config`: a control populate ran the driver, and the hermetic populate +did not and wrote only into the staging path. + +Config is the only place a driver can be defined, which is what makes the checked-out content +irrelevant to this: `.gitattributes` can name a filter for a path but cannot say what it runs, +and a fetch carries refs and objects, never config, so a release cannot introduce a driver. + +One residual remains, and it is not this path's to close. An operator-config `required` clean +filter can make a later `git status` in that worktree exit 128 — the operator's own config +acting on an ordinary read of an ordinary worktree, identical for every other fleet row. The +row builder already tolerates a failed status. + +### Create is explicit, and the pin never moves on its own + +A pinned worktree never moves. There is no in-place "advance": moving the pin to a newer +release is Remove followed by Create, which the fleet already offers. That is a decision rather +than an omission — an in-place checkout of a tree that already exists has to reason about +everything already in it (a branch someone put it on, a dirty file, a commit stranded on a +detached HEAD, build artifacts belonging to the previous release), and every one of those is a +guard whose failure mode is silent. Remove + Create has none of them, because it destroys the +state they would protect. + +What it costs is real and worth naming: the provisioned tree (`node_modules`, built `dist`, +venv — `has_dist` is a filesystem check inside the worktree, so a re-created channel comes back +unprovisioned), the pod (its identity is the worktree basename, so removal stops the service and +drops its port), and the live-target pointer, which names a path removal deletes. On a control +used about once per stable release, that is the cheaper side of the trade, and it is two clicks +the fleet already had. + +The version badge is the currency signal and names no control. `version` states what the tree +contains and `at_tip` changes the badge from ok to warn when the channel has published a newer +release. The badge does not name the action that closes the gap: that is Remove then Create, two +controls on two surfaces (Remove in the row's detail panel, Create on the placeholder that +appears afterwards), and naming one would misdirect. + +Prune never offers this worktree, and the guard is on the detached shape rather than the +reserved name — see *Prune Rules*. + ## Make Live `POST /api/apps/dev-fleet/make-live` — served by the **gateway process**, not the diff --git a/src/kiro_crew/apps/builtins/dev_fleet/app.json b/src/kiro_crew/apps/builtins/dev_fleet/app.json index ccbd39eee94..4b9cdf7243d 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/app.json +++ b/src/kiro_crew/apps/builtins/dev_fleet/app.json @@ -16,7 +16,8 @@ "'Prune merged' refuses to delete a worktree with uncommitted or untracked work rather than discarding it", "Stream pod logs and mint pod access tokens from the UI", "Built for people developing Kiro Crew itself — not needed for everyday use", - "Pods run on Linux (systemd --user) and macOS (launchd) — but the macOS pod has no enforced memory/CPU ceiling; Make Live stages its pointer on every platform, and automatic restart needs a systemd --user unit or a macOS LaunchAgent; the fleet view, PR status, sync, rebase and prune work on any platform" + "Pods run on Linux (systemd --user) and macOS (launchd) — but the macOS pod has no enforced memory/CPU ceiling; Make Live stages its pointer on every platform, and automatic restart needs a systemd --user unit or a macOS LaunchAgent; the fleet view, PR status, sync, rebase and prune work on any platform", + "A worktree pinned to the newest stable release, so you can pod a shipped build beside your branch; it never changes which channel your own install updates from" ], "defaultEnabled": false, "platform": { diff --git a/src/kiro_crew/apps/builtins/dev_fleet/fleet_state.py b/src/kiro_crew/apps/builtins/dev_fleet/fleet_state.py index c820c283da1..67b40f6be80 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/fleet_state.py +++ b/src/kiro_crew/apps/builtins/dev_fleet/fleet_state.py @@ -12,7 +12,13 @@ from pathlib import Path from typing import Any -from kiro_crew.apps.builtins.dev_fleet import git_facts, live, repository, runtime +from kiro_crew.apps.builtins.dev_fleet import ( + fleet_state_release_channel, + git_facts, + live, + repository, + runtime, +) from kiro_crew.executors import subprocess_executor from kiro_crew.loop_lock import LoopBoundLock from kiro_crew.platform_compat import is_link_or_junction @@ -561,6 +567,12 @@ async def _context_cached( # reaped by the first build that started after them. _FLEET_EPOCH = 0 _FLEET_TOMBSTONES: dict[str, int] = {} +# The epoch of the latest worktree ADOPTION (`_fleet_adopted`), the mirror image of a +# tombstone. A rebuild that started before an adoption read git before the worktree +# existed, so storing its result would publish a snapshot WITHOUT the row the +# mutation just created -- and a `fresh=1` request coalescing onto that build would +# be answered with it. A build compares this against its own start and re-reads. +_FLEET_ADOPTED_EPOCH = 0 def _drop_worktrees(data: dict, names: set[str]) -> dict: @@ -575,13 +587,44 @@ def _drop_worktrees(data: dict, names: set[str]) -> dict: kept = [w for w in wts if w.get("name") not in names] if len(kept) == len(wts): return data - return {**data, "worktrees": kept} + out = {**data, "worktrees": kept} + # The release channel is published as its own key, built from the same discovery + # as the rows. Dropping the row it was adopted from without demoting the key + # would publish a channel that claims a worktree the rows do not carry, and the + # page reads `worktree` to decide whether Create is offered -- so the operator + # would see neither the row nor the control that brings it back until a later + # rebuild. Demote to the placeholder shape `_release_channel` builds: no tree, + # no currency, and `version` back to the tip Create would check out. + channel = out.get("release_channel") + if isinstance(channel, dict) and channel.get("worktree") in names: + out["release_channel"] = { + **channel, + "worktree": None, + "at_tip": None, + "version": channel.get("tip_version"), + "version_unknown": False, + } + elif isinstance(channel, dict) and channel.get("name") in names: + # The OTHER holder of the reserved name: a branch checkout, recorded as + # `name_taken_by_branch` with no `worktree`. One directory is one row, so the + # dropped row was the occupant; a flag left set hides Create until a rebuild. + out["release_channel"] = {**channel, "name_taken_by_branch": False} + return out async def _fleet_build() -> dict: global _FLEET_TOMBSTONES started = _FLEET_EPOCH data = await _build_fleet() + if _FLEET_ADOPTED_EPOCH > started: + # A worktree was adopted while this build ran, so the git state it read + # predates a row that now exists. A removal mid-build is repaired by + # subtraction below; an addition has nothing to subtract, so read again. Once: + # a second adoption landing inside the re-read is left to the zeroed + # timestamp `_fleet_adopted` set, which schedules the next rebuild behind the + # snapshot rather than looping here for as long as adoptions keep landing. + started = _FLEET_EPOCH + data = await _build_fleet() # Removals that landed DURING this build are invisible to it — the git state # it read predates them. Re-apply them so a slow build cannot put back a row # an eviction already removed. @@ -631,6 +674,21 @@ def _fleet_forget(name: str) -> None: _FLEET_CACHE["ts"] = 0.0 +def _fleet_adopted() -> None: + """Mark that a worktree was just created, so no older build can hide it. + + The counterpart of `_fleet_forget` for the one mutation that ADDS a row. Bumping + the epoch tells a build already in flight that its snapshot predates the new + worktree (`_fleet_build` re-reads); zeroing the timestamp schedules the rebuild + for a caller that only reads the cache. Nothing is evicted: there is no stale + row to drop, only a missing one to pick up. + """ + global _FLEET_EPOCH, _FLEET_ADOPTED_EPOCH + _FLEET_EPOCH += 1 + _FLEET_ADOPTED_EPOCH = _FLEET_EPOCH + _FLEET_CACHE["ts"] = 0.0 + + def _log_fleet_rebuild_failure(task: asyncio.Task) -> None: if task.cancelled(): return @@ -1439,6 +1497,11 @@ async def _build_fleet() -> dict: "main_repo": runtime._redact(repository._repo()), "main_repo_inferred": repository.MAIN_REPO_INFERRED, "base_branch": repository.BASE_BRANCH, + # The release channel, whether or not its worktree exists yet. See + # _release_channel for why this is not an extra `worktrees` row. Fed the RAW + # discovery entries, not the built rows: a row's `path` has been through + # `_redact` for display, and a redacted path is not safe to hand to git. + "release_channel": await fleet_state_release_channel._release_channel(worktrees), "build_pending": _build_pending(), "gateway_service_active": await live._gateway_service_active(), # Non-null while a cutover is staged but not yet running: the UI renders a @@ -1716,6 +1779,7 @@ async def work() -> None: "_DISK_EPOCH", "_DISK_TTL", "_FLEET_CACHE", + "_FLEET_ADOPTED_EPOCH", "_FLEET_EPOCH", "_FLEET_INFLIGHT", "_FLEET_TOMBSTONES", @@ -1752,6 +1816,7 @@ async def work() -> None: "_fetch_pr_status", "_fleet_build", "_fleet_cached", + "_fleet_adopted", "_fleet_forget", "_fleet_rebuild_task", "_fleet_refresh", diff --git a/src/kiro_crew/apps/builtins/dev_fleet/fleet_state_release_channel.py b/src/kiro_crew/apps/builtins/dev_fleet/fleet_state_release_channel.py new file mode 100644 index 00000000000..3416d45d9d6 --- /dev/null +++ b/src/kiro_crew/apps/builtins/dev_fleet/fleet_state_release_channel.py @@ -0,0 +1,173 @@ +"""The release channel's row in the fleet snapshot: its resolved tip and its tree's state. + +Resolution is :mod:`release_channel_pin`'s (a pure read); the row shape the dashboard +renders is decided here, apart from the rest of the fleet projection, so the channel's +three-state adoption rule (detached pin / branch checkout holding the name / unreadable) +reads as one unit and the fleet builder only asks for the finished row. +""" + +from __future__ import annotations + +from pathlib import Path + +from kiro_crew.apps.builtins.dev_fleet import release_channel_pin, repository, runtime + + +async def _release_channel(worktrees: list[dict]) -> dict | None: + """The release channel's resolved tip, and where its tree sits. ``None`` if unknown. + + Published as its OWN key rather than as an extra ``worktrees`` row. With no + worktree yet there is no path, and every existing consumer of a ``worktrees`` entry + -- disk measurement, pod matching, prune candidacy -- assumes one. A pathless row + injected there would be a fabricated worktree, so the placeholder stays here and + the frontend renders it as a ghost row instead. + + Never raises: this rides on the cached fleet snapshot, and a channel that cannot be + resolved must not take the whole fleet view down with it. ``None`` means "nothing + can be said about it" -- no repo, or the resolve itself blew up -- which is + different from a resolved-but-failed channel, whose row carries the error and still + renders. + """ + try: + repo = repository._repo() + except repository.RepoUnavailable: + return None + name = release_channel_pin.WORKTREE_NAME + # EVERY registered checkout carrying the reserved basename, not the last one a + # dict comprehension happened to keep. The channel can drive exactly one tree, and + # the page binds channel treatment to rows by name (`channelForRow`), so adopting + # one of two same-named checkouts would dress the other -- a branch checkout, say + # -- in the adopted tree's version badge and take its Rebase away. The same + # invariant Create enforces (`_find_worktree` refuses an ambiguous basename, so no + # second tree is ever created by this app) holds here for trees created by hand: + # with more than one, nothing is adopted and the row says why. + holders = [ + w + for w in worktrees + if not w.get("is_main") and w.get("path") and Path(w["path"]).name == name + ] + try: + res = await release_channel_pin.resolve_tip(repo=repo) + except Exception: # noqa: BLE001 + runtime.logger.exception("dev-fleet: release-channel resolution failed") + return None + wt = holders[0] if len(holders) == 1 else None + row: dict = { + "lane": release_channel_pin.CHANNEL, + # The basename this channel's worktree has or would have. Published even when + # nothing exists yet, because the frontend labels the not-yet-created row with + # it -- deriving the name there instead would put the naming rule on both sides + # of the boundary, where a change to WORKTREE_NAME silently desyncs the label + # from the real directory. + "name": name, + # Set only for a tree this app is willing to DRIVE as a channel pin. + "worktree": None, + # `ref` and `version` are what the row RENDERS. The resolved commit id and the + # worktree's own HEAD sha are deliberately absent: nothing reads them, and a + # field carried "for diagnostics" that no surface shows is a claim about the + # payload's contract that no consumer keeps. + # + # `version` is the release THIS ROW IS ON, which differs by row kind: on a + # placeholder it is the release Create would check out (the channel tip), and on + # an adopted row it is the release the tree actually holds -- overwritten below. + # `tip_version` is always the channel tip, so a stale row can name both without + # either being ambiguous. + "ref": res.get("ref"), + "version": res.get("version"), + "tip_version": res.get("version"), + # A benign empty channel is NOT an error. The resolver marks that state + # `unpublished`, and the row carries it apart from `error` so the frontend + # renders it as ordinary information and leaves Create enabled. `error` is + # reserved for a genuine git failure, which is what reaches the shared error + # surface. A resolved channel sets neither. + "unpublished": bool(res.get("unpublished")), + "error": None if res.get("ok") or res.get("unpublished") else res.get("error"), + "at_tip": None, + # On an adopted row, `version: null` means two different things, and the badge + # must not say the stronger one when only the weaker is known: `False` here + # and the tree IS on no published release (the real answer, "not on a + # release"); `True` and the release could not be READ (a timed-out or + # unreadable `--points-at` listing), in which case the row carries the error + # and the badge says the version is unknown. Never set on a placeholder. + "version_unknown": False, + # A worktree holds the reserved name but is NOT a channel pin, because it is + # POSITIVELY known to be on a branch. Distinct from "no worktree": Create would + # fail on the occupied path, so the UI must say what is in the way rather than + # offering an action that cannot work. + "name_taken_by_branch": False, + } + if len(holders) > 1: + # The ambiguity is the row's state, whatever the tip resolved to: it rides on + # `error` because that is the one field with a surface on an UNADOPTED row that + # exists in the fleet (`channelErrorFor` -> the shared ErrorNotice), and the + # fleet carrying the name is what suppresses the Create placeholder -- so the + # only control on offer is the one that resolves it. Count, not paths: the + # rows themselves show where each checkout sits. + row["error"] = ( + f"{len(holders)} registered worktrees are named {name}; the " + f"{release_channel_pin.CHANNEL} channel can drive only one. Rename or remove the " + "extras (`git worktree move` / `git worktree remove` in a shell) so one remains" + ) + if wt is not None: + st = await _release_channel_tree_state(name, wt, res, row) + _adopt_release_channel_row(row, st, tip_resolved=bool(res.get("ok"))) + return row + + +async def _release_channel_tree_state(name: str, wt: dict, res: dict, row: dict) -> dict: + """``worktree_state`` for the tree holding the reserved name, failures absorbed.""" + try: + return await release_channel_pin.worktree_state(wt["path"], res if res.get("ok") else {}) + except (release_channel_pin.MirrorOverflow, release_channel_pin.ReleaseLookupFailed) as exc: + # Both can only come from the points-at probe after HEAD was readable and the + # worktree was positively identified as detached: the tree IS the pin, its + # release is unknown, and the row says so instead of "not on a release". + if not row["error"]: + row["error"] = f"{name}: {exc}; refusing to name the release this tree holds" + return {"at_tip": False, "detached": True, "version": None, "version_unknown": True} + except Exception: # noqa: BLE001 + runtime.logger.exception("dev-fleet: release-channel worktree probe failed") + return {"at_tip": False, "detached": None, "version": None} + + +def _adopt_release_channel_row(row: dict, st: dict, *, tip_resolved: bool) -> None: + """Fold the tree's own state into *row*, in place. + + THREE states, not two. ``detached is None`` means the probe could not read HEAD at + all, and neither of the other branches may claim it: adopting it would drive a tree + of unknown shape, and reporting ``name_taken_by_branch`` would assert "this is on a + branch" about a tree nobody read -- which also hides the row entirely behind a + fabricated explanation. Say the truth instead: the path exists and its state is + unknown. + """ + detached = st.get("detached") + if detached is True: + row["worktree"] = row["name"] + # The tree's OWN release, not the channel's -- and published whether or not the + # tip resolved. It is answered from the local mirror, so a failed tip lookup is + # no reason to withhold it; leaving it unset here labelled a checkout sitting on + # a published release "not on a release" every time the resolver failed, which + # is the badge saying the opposite of the truth beside an error notice. Leaving + # the RESOLVED version here instead would rename the row to a build it does not + # contain the moment a newer release ships. `None` when the tree is detached at + # no release tag at all, which the row says plainly rather than papering over + # with the tip. + row["version"] = st.get("version") + # Only the probe's own failure sets this; a `None` version with it unset is the + # positive answer "detached at no release tag". + row["version_unknown"] = bool(st.get("version_unknown")) + if tip_resolved: + row["at_tip"] = st.get("at_tip") + # else: `at_tip` stays None -- with no tip there is no answer, and the row's + # tooltip says the tip could not be resolved. + elif detached is False: + row["name_taken_by_branch"] = True + elif not row["error"]: + row["error"] = f"{row['name']} exists but its git state could not be read" + + +__all__ = ( + "_adopt_release_channel_row", + "_release_channel", + "_release_channel_tree_state", +) diff --git a/src/kiro_crew/apps/builtins/dev_fleet/http_api.py b/src/kiro_crew/apps/builtins/dev_fleet/http_api.py index bafe1270a54..7785bdec296 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/http_api.py +++ b/src/kiro_crew/apps/builtins/dev_fleet/http_api.py @@ -11,7 +11,14 @@ from aiohttp import web -from kiro_crew.apps.builtins.dev_fleet import fleet_state, live, repository, runtime, worktree_ops +from kiro_crew.apps.builtins.dev_fleet import ( + fleet_state, + live, + release_channel_pin, + repository, + runtime, + worktree_ops, +) from kiro_crew.apps.proxy_auth import raw_request_target # --- standalone backend config --- @@ -141,6 +148,16 @@ async def api_dev_fleet_run(request: web.Request) -> web.Response: else None ) if snap: + # An in-process run (``runtime._start_task_run``) ends with its result dict + # on the record. Every string in it -- an ``error`` quoting a path, the + # ``path`` itself, a ref -- is scrubbed HERE, where it leaves the process, + # the same way the output lines are. The owner that wrote the result carries + # no redactor of its own; this route is the registered egress for it. + result = snap.get("result") + if isinstance(result, dict): + snap["result"] = { + k: runtime._redact(v) if isinstance(v, str) else v for k, v in result.items() + } return web.json_response(snap) return web.json_response({"error": "unknown run id"}, status=404) @@ -157,33 +174,53 @@ async def api_dev_fleet_disk(request: web.Request) -> web.Response: return web.json_response(await fleet_state._disk()) -def _audited(tool_name: str): +def _audited(tool_name: str, *, static_target: str = ""): """Audit every Dev Fleet mutation via SEL, exactly once per request. The decision is made at the single response boundary of the handler: 2xx -> success, 4xx -> denied, 5xx/exception -> failure. Target worktree name is read from the JSON body without consuming the stream (handlers re-parse independently); values are redacted before logging. + + ``static_target`` is for a route whose target is not in the request at all: the + release-channel mutation acts on one known worktree and takes no body, so the + object it touches is a constant. Passing it here keeps the trail naming a real + target instead of the empty string a body scan would produce, and a route that + declares it never consults the body -- a client cannot influence what its own + mutation is recorded against. Every other route reads the worktree fields named + inline below. """ def _decorate(handler): async def _wrapped(request: web.Request) -> web.Response: - target = "" + target = static_target try: - if request.content_length and request.can_read_body: + if not static_target and request.content_length and request.can_read_body: raw = await request.read() # cached; handler .json() re-reads it try: parsed = json.loads(raw) if isinstance(parsed, dict): + # Worktree fields only, in order, spelled here rather than + # behind a name: a channel field admitted into this chain + # would let a body name the channel as the target of a + # mutation that never touched it, and the release-channel + # route takes no body at all -- it declares a + # ``static_target``. t = parsed.get("name") or parsed.get("names") or parsed.get("path") if isinstance(t, str): target = t elif isinstance(t, list): target = ",".join(str(x) for x in t[:20]) except (ValueError, TypeError): - target = "" + # `target` was never reassigned, so it still holds the value set + # before the block. Re-assigning `static_target` here would + # write the same value it already has on every reachable route: + # a route declaring one skips this block entirely via `if not + # static_target`, and a route without one has + # `target == static_target == ""`. + pass except Exception: - target = "" + pass try: resp = await handler(request) except Exception as exc: @@ -505,6 +542,79 @@ async def api_dev_fleet_rebase(request: web.Request) -> web.Response: return await _pod_name_action(request, worktree_ops._rebase) +def _release_channel_outcome(result: dict | None) -> tuple[str, str]: + """``(outcome, error)`` for the audit entry of a finished create run. + + The same split ``_audited`` applies at a response boundary, applied to the run's + result dict instead: ``ok`` is a success, a ``code`` in + ``_RELEASE_CHANNEL_REFUSALS`` is a state the caller can act on and is recorded as + ``denied``, anything else -- an unexpected git failure, or no result at all because + the run raised or was cancelled at shutdown -- is a ``failure``. + """ + if result is None: + return "failure", "run ended without a result" + if result.get("ok"): + return "success", "" + refused = result.get("code") in worktree_ops._RELEASE_CHANNEL_REFUSALS + error = runtime._redact(str(result.get("error", "")))[:200] or str(result.get("code", "")) + return ("denied" if refused else "failure"), error + + +def _audit_release_channel_result(result: dict | None) -> None: + """Record how the create run ENDED, in the same trail that recorded its start.""" + outcome, error = _release_channel_outcome(result) + runtime._sel().log_tool_invocation( + session_key="api", + source="api", + tool_name="dev_fleet_release_channel_create_result", + tool_kind="dev_fleet", + outcome=outcome, + resources=release_channel_pin.WORKTREE_NAME, + error=error, + ) + + +@_audited("dev_fleet_release_channel_create", static_target=release_channel_pin.WORKTREE_NAME) +async def api_dev_fleet_release_channel_create(request: web.Request) -> web.Response: + """Start materializing the release channel's detached worktree. Empty body. + + Answers ``202 {ok: true, run_id}`` as soon as the create is RUNNING, not when it + is done: the fetch, ``worktree add``, populate and ``worktree move`` it performs + carry timeouts in the minutes, and the app proxy that fronts this backend drops a + request at 30 seconds -- a route that awaited the create could never deliver its + result. The caller polls ``/api/run?id=`` and reads ``result`` once the + run's ``status`` has left ``running``. + + A refusal that needs no run to decide -- the tree exists, the name is ambiguous, + another operation on the name is in flight -- is still answered here, as a 409 + with its ``code``: a state the caller can act on. ``busy`` for a create already + running also carries that run's id, so a page that lost its own attaches to it. + + Two audit entries per create: ``_audited`` records this ADMISSION (2xx is "the + run started", a 409 is ``denied``), and ``_audit_release_channel_result`` records + how the run ended, on the run's own terminal state, with the same split a + synchronous response would have had. + """ + started = await worktree_ops._release_channel_create_start( + on_finish=_audit_release_channel_result + ) + if started.get("ok"): + return web.json_response(started, status=202) + refused = started.get("code") in worktree_ops._RELEASE_CHANNEL_REFUSALS + # A dict LITERAL with its ``code`` key spelled here, not a hoisted variable: the + # error-code contract gate reads the body off the call, and ``run_id`` rides + # along as ``None`` when no run is in flight rather than being added after. + return web.json_response( + { + "ok": False, + "code": started.get("code"), + "error": started.get("error"), + "run_id": started.get("run_id"), + }, + status=409 if refused else 500, + ) + + # ============================================================================= # HMAC Proxy Middleware (fail-closed) # ============================================================================= @@ -654,6 +764,7 @@ async def api_health(request: web.Request) -> web.Response: "api_dev_fleet_prune_run", "api_dev_fleet_prune_status", "api_dev_fleet_rebase", + "api_dev_fleet_release_channel_create", "api_dev_fleet_run", "api_dev_fleet_sync", "api_dev_fleet_worktree", diff --git a/src/kiro_crew/apps/builtins/dev_fleet/release_channel_pin.py b/src/kiro_crew/apps/builtins/dev_fleet/release_channel_pin.py new file mode 100644 index 00000000000..9f9ec3035e5 --- /dev/null +++ b/src/kiro_crew/apps/builtins/dev_fleet/release_channel_pin.py @@ -0,0 +1,596 @@ +"""Release-channel worktree: one detached checkout pinned to the stable release. + +Dev Fleet manages git checkouts, and a git checkout has no release channel -- +``platform/update_capability.py`` says so outright: *"Only the wheel command +carries a channel. A git checkout follows its remote."* That is fine for the +install the user runs, and useless for the question this module answers: **what +did stable actually ship, and can I click through it right now?** + +WHY A WORKTREE, AND NOT A PIN ON THE PRIMARY CHECKOUT. Sync fast-forwards the +primary checkout (``git merge --ff-only``) and refuses to run unless HEAD is +literally :data:`repository.BASE_BRANCH`. A stable tag is normally BEHIND main, +so pinning that checkout to a lane could only work by detaching its HEAD (which +the sync guard rejects, and every ``origin/main`` comparison on the fleet row is +then measuring against a ref the user did not choose) or by resetting ``main`` +backwards, which destroys work. So the lane gets its own detached worktree +instead: additive, non-destructive, and it lands in the fleet as an ordinary row +that pods and Make Live already know how to drive. + +WHAT THIS MODULE IS NOT. It never reads or writes ``$KIROCREW_HOME/channel``. +That file says which lane the user's real install FOLLOWS for updates; a pin here +says which git ref a worktree SITS ON. Coupling them would mean materializing a +stable worktree silently changed what the user's live install downloads next -- a +blast radius nobody asked for. The only thing borrowed from the update stack is +vocabulary and validation. + +Resolution is deliberately split from mutation, and the line is the WORKTREE: +nothing here moves a checkout, so the fleet snapshot can resolve the channel on +its refresh path without that risk. It is not ref-free -- :func:`fetch_refs` +writes remote-tracking refs and tags, which is exactly what makes a resolve +current -- but a ref update cannot strand a commit or change what a pod is +serving. The create mutation lives in ``worktree_ops_release`` beside the other +worktree writers, because it takes the same ``.git`` admin lock those do. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from kiro_crew.apps.builtins.dev_fleet import repository, runtime +from kiro_crew.apps.version import parse_version +from kiro_crew.loop_lock import LoopBoundLock + +#: The one release channel Dev Fleet materializes: the channel whose tip a git tag +#: actually names, and whose order among those tags is a fact rather than a rule +#: this feature would have to invent. +#: +#: ``nightly`` could not be it: ``nightly.yml`` builds from ``main`` HEAD on a +#: schedule and tags NOTHING, so the newest ref it could name is ``/main`` +#: -- which is main *now*, not the commit the last nightly published, and is where +#: the primary checkout already sits after Sync. A row promising "what nightly +#: shipped" that shows neither is worse than no row. +#: +#: ``insider`` is out by product decision, not by a missing fact -- its +#: ``-insider.N`` tags do resolve. Ranking them is what has no answer: ordering +#: ``-insider.N`` against ``-rc.N`` needs a precedence between two prerelease +#: spellings that nothing in this repo states, so a prerelease channel's "tip" +#: would rest on a rule this feature invented. +#: +#: Singular on purpose. A tuple of channels, a channel parameter on every function +#: and a channel key in every request would all be shapes with one possible value. +#: A second channel is a change to this module's shape, made when the ordering rule +#: for prerelease tags exists to justify it. +CHANNEL = "stable" + +#: A tag naming a stable release: ``v1.2.3`` and nothing after it. +_STABLE_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") + +#: The most mirror refs one resolve will retain, and the most bytes of a ref name. +#: +#: The mirror is fed by the remote, so its population is external and has no +#: natural ceiling. Every listing below asks git for ``--count`` of this plus one +#: -- git truncates before any byte reaches this process -- and a listing that +#: returns the extra row is an OVERFLOW, reported as an error rather than sorted: +#: truncating silently could drop the true tip, so the channel refuses to name one +#: until an operator looks. Two thousand releases is beyond any real remote's tag +#: history; the cap exists to make the bound a fact rather than to be reached. +_MAX_RELEASE_REFS = 2048 +_MAX_REF_NAME_LEN = 64 + + +class MirrorOverflow(RuntimeError): + """The mirror holds more, or longer, refs than one resolve will retain. + + Raised BEFORE any name is filtered or sorted, so an overflowing listing can + never be read as a smaller population that happened to have no other tags: + a truncated tail would sort and could name a release that is not the tip. + """ + + +class ReleaseLookupFailed(RuntimeError): + """The ``--points-at HEAD`` listing itself failed (timeout, unreadable refs). + + Distinct from an EMPTY listing, which is the real answer "this detached tree sits + on no published release". A failed read says nothing about the tree, and letting + it fall through as ``None`` labelled an unchanged release checkout "not on a + release" for a poll cycle -- the badge asserting the opposite of the truth with + no error beside it. + """ + + +#: Where the remote's release tags are mirrored, and the ONLY place the channel +#: resolves from. +#: +#: ``refs/tags/`` cannot be that place. It is a shared namespace an operator writes +#: to as well: a locally cut release candidate, a bisect marker, a ``v99.0.0`` +#: someone tagged to test an upgrade path. Every one of those matches +#: :data:`_STABLE_TAG_RE`, sorts newest, and would become the channel "tip" -- so +#: the row would badge an unpublished commit as a shipped release and Create would +#: check it out. Nothing about a local tag distinguishes it from a fetched one +#: after the fact, so the separation has to happen when the ref is written. +#: +#: A mirror the operator has no reason to write to makes "published" a property of +#: WHERE the ref is rather than a claim about how it got there -- but only because +#: the fetch PRUNES it (see :func:`fetch_refs`). Location alone would be a statement +#: about habit, not an enforced boundary: a ref namespace is writable by anything +#: with access to this repo, so an unpruned mirror can be forged with one +#: ``git update-ref`` and the forgery outranks every real release. +#: +#: Because the mirror is pruned, a release retracted upstream also stops resolving +#: on the next fetch, while ``refs/tags/`` stays additive for the operator's own use. +_TAG_NS = "refs/dev-fleet/release-tags" + +#: The refspec that populates :data:`_TAG_NS`. Forced, because a re-pushed tag must +#: overwrite the mirror rather than being skipped as a non-fast-forward: the mirror +#: is a cache of the remote's answer, not history anyone builds on. +#: +#: Exported because two callers fetch for this channel -- :func:`fetch_refs` before a +#: mutation, and the background refresher every cycle -- and a refspec spelled twice +#: is a refspec that drifts. The one that drifted would fail silently: the channel +#: would resolve against a mirror that stopped being updated, which reads exactly +#: like a repo that has published nothing new. +_TAG_REFSPEC = f"+refs/tags/v*:{_TAG_NS}/v*" + +#: Serializes :func:`fetch_refs` within one event loop (see its body for why). +_FETCH_LOCK = LoopBoundLock() + +#: The basename of the release-channel worktree. +#: +#: The basename becomes the fleet row label (``fleet_state`` uses ``Path(path).name`` +#: verbatim) AND the pod identity (``kirocrew-pod@.service``), so it is spelled +#: in full rather than abbreviated: ``release-channel-stable`` reads as what it is +#: next to a ``kirocrew-wt-`` feature worktree, and the missing +#: ``kirocrew-wt-`` prefix is what visually separates the two groups with no extra +#: chrome. +#: +#: Deliberately NOT ``channel-`` -- bare "channel" already means four unrelated +#: things in this codebase (agent channels in ``kiro_crew/channel.py``, messaging +#: channels, notification channels, upload document channels). +#: +#: One literal, not a prefix composed with :data:`CHANNEL`. That composition had +#: exactly one consumer -- this line -- so it was generality for a second channel +#: that is not shipping, and it made the name that every other surface matches on +#: (the row, the pod identity, the reserved-name guard) something a reader had to +#: assemble. +WORKTREE_NAME = "release-channel-stable" + + +def is_release_tag(tag: str) -> bool: + """Whether *tag* names a release this channel publishes. + + The shape check IS the classification. ``_STABLE_TAG_RE`` admits only a bare + ``vX.Y.Z``, and every such string is stable by definition -- there is no + prerelease suffix left for ``release_channel.channel`` to read, so deferring to + it could only ever return the answer this already knows. + """ + return bool(_STABLE_TAG_RE.match(tag)) + + +def worktree_path(repo: str) -> str: + """Where the release-channel worktree lives: a sibling of the primary checkout. + + Matches where the existing fleet already is -- every ``kirocrew-wt-`` + worktree is a sibling of the primary checkout -- so the new tree lands in the + directory the operator already associates with this repo instead of a second + root they have to learn. + """ + return str(Path(repo).parent / WORKTREE_NAME) + + +async def fetch_refs(repo: str, *, timeout: int = 120) -> str | None: + """Refresh the tag mirror the channel resolves against, and nothing else. + + Returns ``None`` on success, else the error to report. Called before every + resolve that must be current, so a mutation acts on the channel's real tip + rather than on whatever the mirror held a cycle ago. The background refresher + calls it every cycle too, which is what keeps the fleet ROWS honest between + mutations; this call is what makes a Create honest at the moment it runs. + + One refspec, one spawn, one destination namespace. The fetch names + :data:`_TAG_REFSPEC` and nothing beside it: not the base branch (the refresher + fetches that in its own spawn, so a mirror failure cannot leave Behind stale and a + base-branch failure cannot block the channel), and not the operator's own + ``refs/tags/`` (nothing here writes a ref the operator did not ask for; the row + publishes the mirror ref it resolved, see :func:`_resolve_tagged`). The channel + reads only the mirror, so a tag that reaches ``refs/tags/`` by any other route + cannot become a tip. + + **``--prune`` is what makes the mirror a fact rather than a habit, and it is + load-bearing for two distinct jobs -- neither of which is Create's integrity.** A + ref namespace is writable by anything with access to this repo -- an agent with + git is exactly that -- so without pruning, a hand-written + ``refs/dev-fleet/release-tags/v999.0.0`` outranks every real release by semver + and survives every additive fetch. What that costs is, first, the FLEET ROWS: the + display path resolves out of this same mirror and has no provenance backstop, so + a forged ref is badged as the stable release there. Second, it costs Create's + AVAILABILITY: the forgery is picked by every resolve and refused by every + provenance check, so Create stays refused until someone hand-runs + ``git update-ref -d``. Create's INTEGRITY is owned by + :func:`advertised_release_commit`, which asks the configured upstream what it + publishes and refuses any other oid, so a forged mirror ref cannot reach a + checkout whether or not the fetch pruned. + + ``--prune`` is NOT ``--prune-tags``, and conflating the two is what left this + open. Pruning is scoped to the DESTINATIONS of the refspecs actually given, which + ``git-fetch(1)`` states directly: the destination of an explicit refspec is what + gets pruned. This fetch names the mirror refspec only and never mentions + ``refs/tags/``, so it deletes a forged mirror ref and a tag the remote retracted, + and ``refs/tags/`` is not among its destinations for a LOCAL-ONLY tag the operator + authored to be reachable by. It is ``--prune-tags`` that contributes an implicit + ``+refs/tags/*:refs/tags/*`` to a PRUNING fetch and destroys the operator's own + tags -- that flag is refused, and ``--no-prune-tags`` is said explicitly so a + ``fetch.pruneTags`` in the repository's own ``.git/config`` (a file an agent can + write) cannot turn it back on. A stale remote-tracking branch is likewise left + alone. This follows from the documented scoping rule rather than from one + version's behaviour, and ``--prune-tags`` is the opt-in shorthand git 2.17 ADDED, + so no older git prunes ``refs/tags/`` more eagerly than this. + + So retraction is handled here rather than deferred: the channel stops resolving a + yanked release on the next fetch, while ``refs/tags/`` is untouched so the operator + can still see and check out a tag locally after upstream drops it. + """ + # One fetch at a time. The refresher and a Create both call this on the same refs, + # and two concurrent fetches contend for the same ref locks: the loser exits with + # ``cannot lock ref``, which for a Create is a ``fetch_failed`` refusal of a click + # that would have succeeded a second later. Per event loop, like every lock in this + # app; a second process is its own fetcher and git's own lock files arbitrate. + async with _FETCH_LOCK: + remote = await repository._upstream_remote() + rc, _out, err = await runtime._run_cmd( + [ + "git", + "-C", + repo, + "fetch", + "--prune", + # Said explicitly, not left to config. ``fetch.pruneTags`` (or + # ``remote..pruneTags``) in this repository's own ``.git/config`` + # would make ``--prune`` with explicit refspecs ALSO prune + # ``refs/tags/`` -- the operator's local-only tags deleted on every + # refresher cycle, from a file an agent can write. The flag pins the + # scoping the docstring above promises, whatever the config says. + "--no-prune-tags", + # Tag auto-follow is ON by default even with an explicit refspec: git + # also stores, under `refs/tags/`, every remote tag pointing at an + # object the fetch brought in. That is a write to the operator's own + # namespace this fetch promises never to make, so it is switched off + # here rather than left to `remote..tagOpt` in a config file an + # agent can edit. + "--no-tags", + remote, + _TAG_REFSPEC, + ], + timeout=timeout, + ) + if rc != 0: + detail = runtime._error_detail(err) + return detail or f"git fetch {remote} {_TAG_REFSPEC} failed" + return None + + +async def advertised_release_commit(repo: str, tag: str) -> tuple[str | None, str | None]: + """The commit oid the CONFIGURED remote advertises for *tag*, or an error. + + The mirror namespace is writable by anything with access to this checkout, and + ``--prune`` on the fetch only deletes refs the remote does not advertise AT FETCH + TIME -- it cannot touch a ref written after that subprocess exits. So a ref + planted between the fetch and the read that resolves the tip is pruned by no fetch + that has already run, and ranks first if its version is high enough. This asks the + upstream remote what it publishes, so "published" is a verified fact about that + remote rather than an inference from where a local ref sits. + + The remote is the one named in this repository's own git config, and its URL is + read from that config by git at query time. So the guarantee is scoped to a writer + who can plant a local REF: it binds the checkout to what the configured upstream + advertises, and it is not a defence against a writer who can rewrite + ``remote..url``. Such a writer already redirects every fetch this app + performs -- including the sync that rebases a worktree onto ``/main`` -- + so a URL trust anchor would have to be operator-owned state shared by all of those + call sites, which this app does not have. + + The query names exactly ``refs/tags/`` and its explicit ``^{}`` pattern. An + ANNOTATED tag's ``refs/tags/vX.Y.Z`` line carries the TAG OBJECT's oid and only the + following ``^{}`` peel line carries the commit, so a resolver holding the commit + would mismatch every annotated release if the peel were not read -- which is how + releases are normally cut. Parsing accepts only those two exact refs and retains at + most their two validated, 64-character-bounded oids; the peel wins because it is + the commit an annotated tag resolves to. + + A tag the remote does not carry is NOT an error from git: ``ls-remote`` exits 0 and + prints nothing. That returns ``(None, None)``; a non-zero exit is reserved for the + query itself failing, which fails CLOSED. + """ + if not is_release_tag(tag): + raise ValueError(f"not a release tag: {tag!r}") + + remote = await repository._upstream_remote() + direct_ref = f"refs/tags/{tag}" + peeled_ref = f"{direct_ref}^{{}}" + rc, out, err = await runtime._run_cmd( + ["git", "-C", repo, "ls-remote", "--tags", remote, direct_ref, peeled_ref], + timeout=60, + ) + if rc != 0: + detail = runtime._error_detail(err) + return None, detail or f"git ls-remote {remote} failed" + + direct: str | None = None + peeled: str | None = None + for line in out.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + oid, ref = parts + if not re.fullmatch(r"[0-9a-f]{40,64}", oid): + continue + if ref == direct_ref: + direct = oid + elif ref == peeled_ref: + peeled = oid + return peeled or direct, None + + +async def resolve_tip(*, repo: str) -> dict: + """Resolve the channel to the ref it most recently published. + + Read-only: never fetches (call :func:`fetch_refs` first when freshness matters) + and never touches a worktree. + + Returns ``{"ok": True, lane, ref, oid, version}`` on success. A failure is + ``{"ok": False, "error": ...}``, and the two failure kinds are told apart by an + ``unpublished`` flag: a benign empty mirror -- a checkout that has fetched no + release tag -- carries ``"unpublished": True``, while a git failure carries no + such flag. A reader that branches only on ``ok`` sees no change. + + Resolution is to a TAG, which is why :data:`CHANNEL` is the channel it is: an + untagged channel has no ref naming a specific published build. + """ + try: + listed = await list_release_tags(repo) + except MirrorOverflow as exc: + # Said out loud, once, as the row's error: refusing to name a tip beats + # sorting a truncated set, which could silently pick the wrong release. + return {"ok": False, "error": f"{exc}; refusing to resolve the channel"} + if listed is None: + return {"ok": False, "error": "cannot list tags (git for-each-ref failed)"} + return await _resolve_tagged(repo, listed) + + +def _bounded_mirror_rows(listed: str) -> list[str]: + """Split one bounded mirror listing and reject overflow before filtering.""" + rows = [line.strip() for line in listed.splitlines()] + if len(rows) > _MAX_RELEASE_REFS: + raise MirrorOverflow(f"more than {_MAX_RELEASE_REFS} refs under {_TAG_NS}") + overlong = sum(1 for row in rows if len(row) > _MAX_REF_NAME_LEN) + if overlong: + raise MirrorOverflow( + f"{overlong} ref name(s) under {_TAG_NS} exceed {_MAX_REF_NAME_LEN} bytes" + ) + return rows + + +async def list_release_tags(repo: str) -> list[str] | None: + """Candidate release tags from the mirror, unordered. ``None`` if git failed. + + Bounded at the source: git is asked for ``--count`` of :data:`_MAX_RELEASE_REFS` + plus one, so at most that many lines ever reach this process. The one extra row, + or any name longer than :data:`_MAX_REF_NAME_LEN`, is :class:`MirrorOverflow` -- + raised on the RAW rows, before a single name is filtered, so an overlength row + cannot mask the count and a dropped name cannot pass for an absent one. No release + tag this channel admits is that long; a longer one is something else wearing the + mirror namespace, and it is reported rather than skipped. + + Reads :data:`_TAG_NS`, never ``refs/tags/``. That is the whole publication check: + a tag is a channel candidate because the mirror holds it, and the mirror holds only + what a fetch brought from the remote. ``git tag --list`` was the same query against + a namespace the operator also writes to, so a locally cut ``v99.0.0`` outranked + every real release and became the tip. + + Deliberately UNORDERED. :func:`_release_candidates` re-sorts by semver, and a tag + order asked for here would have no consumer -- a ``--sort=-creatordate`` is worse + than inert, because it reads like the order the tip is chosen by, and a backport cut + after a newer line is newer by DATE and is not the tip. + + An EMPTY mirror is not an error and must not be reported as one. It is the state of + a checkout that has not fetched yet, and the caller renders it the same way as a + repo that has published nothing -- which is honest, because an unfetched mirror is + precisely a checkout that does not know what is published. The next refresher cycle + fills it. ``None`` is reserved for git itself failing, which is a different row (an + error the operator is shown) and must stay distinguishable. + """ + listed = await repository._git( + repo, + "for-each-ref", + f"--count={_MAX_RELEASE_REFS + 1}", + "--format=%(refname:strip=3)", + f"{_TAG_NS}/", + timeout=20, + ) + if listed is None: + return None + return _bounded_mirror_rows(listed) + + +def _release_candidates(listed: list[str]) -> list[str]: + """The channel's tags out of *listed*, best-first -- the order its tip means. + + Ordered by SEMVER, descending, and creation date is NOT the same order. A backport + cut after a newer line (``v0.4.1`` tagged after ``v0.5.0``, which is ordinary + release practice) is the NEWEST tag by date and an OLDER release by version, and a + re-pushed or re-created tag carries today's date for last year's release. Either + would pin the row to a release stable users are not on. + + Stable under ties, so a repeat call resolves the same tag. + """ + tags = [t for t in (tag.strip() for tag in listed) if t and is_release_tag(t)] + # `parse_version` is the repo's one version parser; a second hand-rolled key here + # would be a second place for `v0.10.0` vs `v0.9.0` to disagree. Every tag reaching + # this line matched ``_STABLE_TAG_RE``, so it is a bare ``vX.Y.Z`` and the parse + # cannot raise. + return sorted(tags, key=lambda t: parse_version(t[1:]), reverse=True) + + +async def _resolve_tagged(repo: str, listed: list[str]) -> dict: + """Pick the channel's tip out of an already-listed tag set.""" + for tag in _release_candidates(listed): + oid = await repository._git(repo, "rev-parse", f"{_TAG_NS}/{tag}^{{commit}}") + if not oid: + # A listed tag that will not resolve is a broken mirror ref, not an empty + # channel. Keep scanning rather than reporting the channel as unpublished, + # which would hide a real release behind one bad ref. + continue + # No second classification of `version` here. `is_release_tag(tag)` already + # decided from the tag's shape, and `version` is that same `tag[1:]` -- so + # re-asking would be a tautology that can only ever agree. One decision, made + # once, at the point that picks the tag. + return { + "ok": True, + # The channel's name, for the row and the strings that name it on screen. + # Display data, not a parameter: no request carries it back. + "lane": CHANNEL, + # No `tag` key beside these. It is `ref` without its namespace prefix and + # `version` with a `v`, so a third spelling of one fact would be a payload + # contract no consumer keeps -- the same reason the fleet row omits the + # resolved commit id. + # + # The ref published is the MIRROR ref: the one that resolved, checkout-able + # as it stands. `refs/tags/` is not named because nothing here writes + # the operator's `refs/tags/` (see `fetch_refs`), so whether that ref + # exists locally, or names this commit, is the operator's business and not + # a fact this resolve can vouch for. + "ref": f"{_TAG_NS}/{tag}", + "oid": oid, + "version": tag[1:], + } + # An empty channel is benign, and the flag says so: this marks the state apart from + # a git failure so the fleet row can render it as ordinary information rather than + # an incident. A checkout with an empty mirror has fetched no release tag, and a + # Create fetches before it resolves, so the state is actionable rather than broken. + return { + "ok": False, + "unpublished": True, + "error": f"no {CHANNEL} release tag found in this checkout", + } + + +async def worktree_state(path: str, resolved: dict) -> dict: + """Where the worktree at *path* sits relative to the resolved channel tip. + + ``at_tip`` says whether the checkout is at the CHANNEL TIP, not whether it tracks + ``BASE_BRANCH`` -- a release worktree is not trying to track main. Together with + ``version``, that is the row's complete currency signal. + + ``version`` is the release the tree is ACTUALLY on, which is not the lane's + resolved version: the moment a newer release ships, the resolved tip moves and the + tree does not. A row that showed the resolved version would rename the operator's + checkout to a build it does not contain. + """ + # Three keys, all read by `fleet_state_release_channel._release_channel`. The worktree's own HEAD + # *oid* is still not among them -- no surface shows a bare sha -- but the release + # that oid corresponds to is exactly what the row's badge claims to display, so it + # is resolved here rather than inferred from the lane tip. + out: dict = {"at_tip": False, "detached": None, "version": None} + head = await repository._git(path, "rev-parse", "HEAD") + # THREE states, and the primitive underneath has to carry all three or the caller's + # three-state handling is decoration. `symbolic-ref --quiet HEAD` exits non-zero for + # a detached HEAD -- but it ALSO exits non-zero when the read fails outright (the + # directory was removed while still registered in `git worktree list`, the repo is + # unreadable, the probe timed out). Publishing any of those as a confirmed detached + # lane is the one thing the caller must never be told: it would adopt a tree of + # unknown shape as a channel pin. A tree whose HEAD cannot be read is not known to + # be anything, so `detached` stays None and the caller says so. + # `rev-parse HEAD` is a discriminator, not the whole answer: a readable HEAD + # plus a symbolic-ref that did not exit 0 still covers two cases, a detached HEAD + # (git's documented `--quiet` exit, 1) and a probe that never answered (the + # `_run_cmd` timeout, a sandbox refusal, a `fatal:` at 128). `_git` folds every + # non-zero exit into None, so the exit status is read here directly: only 1 is + # "detached", and a branch checkout whose probe timed out stays unknown instead + # of being adopted as the pin. + symref_rc, _symref, _err = await runtime._run_cmd( + ["git", "-C", path, "symbolic-ref", "--quiet", "HEAD"], timeout=6 + ) + if symref_rc == 0: + out["detached"] = False + elif symref_rc == 1 and head: + out["detached"] = True + tip = resolved.get("oid") + if not head: + return out + if tip and head == tip: + out["at_tip"] = True + # Same commit as the tip, so the same release by definition: no second git call + # to learn what this already tells us. + out["version"] = resolved.get("version") + return out + if out["detached"] is True: + # Independent of whether the TIP resolved. The tree's own release is a fact + # about the tree, answered from the local mirror with no network call, so a + # failed tip lookup must not turn a checkout that sits on a published release + # into "not on a release" -- that would misreport the one thing the badge + # exists to say, at exactly the moment the operator also has an error notice to + # read. With no tip, `at_tip` stays False and the caller says the tip is + # unknown. + out["version"] = await _release_at_head(path) + return out + + +async def _release_at_head(path: str) -> str | None: + """The version of the release tag *path*'s HEAD sits on, if it is on one. + + Reads the mirror, for the same reason resolution does: ``git tag --points-at`` + would let a tag the operator wrote rename the row, so a local ``v99.0.0`` on the + checked-out commit would badge it as a shipped release. The mirror answers + "published", and that is the claim the badge makes. + + The mirror lives in the shared ref store, so it is visible from a linked worktree + -- only HEAD and a few ``refs/worktree`` paths are per-worktree -- which is why + this can run against *path* rather than having to be handed the primary checkout. + + Filtered to release tags: a commit can carry several tags, and picking the first + would let an unrelated tag that happens to share the commit rename the row. + ``None`` is a real answer -- the worktree is adopted for being DETACHED, not for + being at a release, so an operator who checked out an arbitrary commit in it is on + no release and the row must not invent one. A listing that could not be READ is + not that answer: it raises :class:`ReleaseLookupFailed`, and the caller carries + the failure into the row's error rather than publishing a version it never + learned. An empty listing exits 0 with no output, so the two are told apart by + the exit status, not by the output being empty. + """ + rc, out, _err = await runtime._run_cmd( + [ + "git", + "-C", + path, + "for-each-ref", + f"--count={_MAX_RELEASE_REFS + 1}", + "--points-at", + "HEAD", + "--format=%(refname:strip=3)", + f"{_TAG_NS}/", + ], + timeout=12, + ) + if rc != 0: + raise ReleaseLookupFailed("the release this tree sits on could not be read") + listed = (out or "").strip() + for tag in _bounded_mirror_rows(listed): + if tag and is_release_tag(tag): + return tag[1:] + return None + + +#: What OTHER Dev Fleet components read through the ``server`` facade, and nothing +#: else. Each name below has a caller outside this module; a name with none is +#: reachable as an attribute anyway (tests import the module directly), so exporting +#: it would only widen the facade's surface without widening its use. +#: ``is_release_tag`` and ``list_release_tags`` are internal for that reason. +__all__ = [ + "CHANNEL", + "WORKTREE_NAME", + "advertised_release_commit", + "fetch_refs", + "resolve_tip", + "worktree_path", + "worktree_state", +] diff --git a/src/kiro_crew/apps/builtins/dev_fleet/runtime.py b/src/kiro_crew/apps/builtins/dev_fleet/runtime.py index 41bcf60715e..e563a2333a9 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/runtime.py +++ b/src/kiro_crew/apps/builtins/dev_fleet/runtime.py @@ -41,6 +41,32 @@ def _redact(text: str) -> str: return text +#: How much of a git child's stderr a row's ``error`` keeps. One figure for every +#: owner; no caller wants another, so there is no knob for one. +_ERROR_DETAIL_LIMIT = 200 + + +def _error_detail(text: str | None) -> str: + """A git child's stderr as the bounded, display-safe tail of a result string. + + The release-channel owners that turn a failed git command into a row's ``error`` + do the same three things -- strip, scrub, cut to a readable length -- and this is + that sequence spelled once for them (``worktree_ops_removal`` predates it and + keeps its own 300-character spelling). The scrub is :func:`_redact`, applied HERE: this + module is the one classified for Dev Fleet's git-stderr-into-a-result path + (``NON_EGRESS_REDACTION_MODULES``), so an owner that builds its error text + through this helper adds no redaction call site of its own and needs no entry + in that list. The output path is unchanged -- the fleet payload and the app's + JSON replies -- and so is its classification. + + Git stderr is the one input that needs this treatment at the owner: it is + unbounded and may quote a remote URL with credentials. A result's other strings + -- a path, a ref, a version -- are scrubbed where they leave the process, by the + route that serves them (``http_api.api_dev_fleet_run``), not by the owner. + """ + return _redact((text or "").strip())[:_ERROR_DETAIL_LIMIT] + + def _redact_pr(pr: dict | None) -> dict | None: """Redact string display fields of a PR status dict (url, state, etc.).""" if not pr: @@ -816,6 +842,106 @@ def _parse_step_marker(text: str) -> tuple[int | None, str | None]: return idx, label +def _admit_run_locked(rid: str, label: str) -> None: + """Register a new ``running`` entry in ``_RUNS``. Caller holds ``_RUNS_LOCK``. + + The one place a run record is born, for both kinds of run (a subprocess under + :func:`_start_run`, a coroutine under :func:`_start_task_run`), so the two cannot + drift in shape: ``/api/run`` reads the same keys off either. + """ + # Bound memory: evict the oldest COMPLETED runs beyond the cap + # (running entries are never evicted — reattach depends on them). + done = sorted( + (k for k, v in _RUNS.items() if v.get("status") != "running"), + key=lambda k: _RUNS[k].get("started", 0.0), + ) + for k in done[: max(0, len(done) - _RUNS_MAX_COMPLETED + 1)]: + _RUNS.pop(k, None) + _RUNS[rid] = { + "status": "running", + "exit_code": None, + "label": label, + "output": [], + "started": time.time(), + } + + +async def _start_task_run( + label: str, + work: Callable[[], Awaitable[dict]], + *, + on_finish: Callable[[dict | None], None] | None = None, +) -> str: + """Track an in-process coroutine as a run, in the registry ``/api/run`` serves. + + The subprocess runs (:func:`_start_run`) exist because a sync or a provision + outlives the app proxy's request deadline, and this is the same answer for a + mutation this process performs ITSELF: a route starts the work, replies with + the run id at once, and the page polls ``/api/run?id=`` for the outcome. ``work`` + is a factory, called on the worker task, so a run refused at admission never + creates a coroutine it would then have to close. + + The record is the one :func:`_admit_run_locked` writes, plus ``result`` at the + terminal state: the dict ``work`` returned, verbatim. ``exit_code`` follows that + dict's ``ok`` (0 when true, 1 otherwise) so a reader that only knows the + subprocess shape still reads the outcome right; a raised exception or a + shutdown cancellation is ``-1`` with the reason as the last output line. + + No watchdog of its own: every await in the work this is used for is already + bounded (git children carry per-command timeouts), and killing a coroutine + mid-mutation is what the work's own cancellation handling is for. Shutdown + reaches it the same way it reaches a subprocess run: the task is registered in + ``_ACTIVE_RUNS`` (with no process handle) and ``dev_fleet_cleanup`` cancels it. + + ``on_finish`` receives the result dict at the terminal state, or ``None`` when + there is none (exception, cancellation). Cheap and synchronous; exceptions are + logged and never propagate into the worker's own bookkeeping. + """ + rid = uuid.uuid4().hex[:12] + async with _RUNS_LOCK: + _admit_run_locked(rid, label) + + async def worker() -> None: + result: dict | None = None + try: + result = await work() + async with _RUNS_LOCK: + _RUNS[rid]["status"] = "done" + _RUNS[rid]["exit_code"] = 0 if result.get("ok") else 1 + _RUNS[rid]["result"] = result + except asyncio.CancelledError: + async with _RUNS_LOCK: + _RUNS[rid]["status"] = "done" + _RUNS[rid]["exit_code"] = -1 + _RUNS[rid]["output"].append("[shutdown] run aborted: gateway stopping") + raise + except Exception as exc: # noqa: BLE001 + async with _RUNS_LOCK: + _RUNS[rid]["status"] = "done" + _RUNS[rid]["exit_code"] = -1 + _RUNS[rid]["output"].append("[error] " + str(exc)) + finally: + if on_finish is not None: + try: + on_finish(result) + except Exception: # noqa: BLE001 + logger.exception("run %s on_finish callback failed", rid) + + task = asyncio.create_task(worker()) + async with _SHUTDOWN_ADMISSION_LOCK: + if _SHUTDOWN_IN_PROGRESS: + # Cleanup has already snapshotted _ACTIVE_RUNS. The task has not had + # its first step yet, so cancelling it here means ``work()`` is never + # called, and the record it would have finished is dropped with it. + task.cancel() + async with _RUNS_LOCK: + _RUNS.pop(rid, None) + raise RuntimeError("dev-fleet shutdown in progress: run refused") + _ACTIVE_RUNS[rid] = (task, None) + task.add_done_callback(lambda _t: _ACTIVE_RUNS.pop(rid, None)) + return rid + + async def _start_run( label: str, cmd: list[str], @@ -844,21 +970,7 @@ async def _start_run( # every real cause on the sync path and raise NameError on a provision. run_kind = label async with _RUNS_LOCK: - # Bound memory: evict the oldest COMPLETED runs beyond the cap - # (running entries are never evicted — reattach depends on them). - done = sorted( - (k for k, v in _RUNS.items() if v.get("status") != "running"), - key=lambda k: _RUNS[k].get("started", 0.0), - ) - for k in done[: max(0, len(done) - _RUNS_MAX_COMPLETED + 1)]: - _RUNS.pop(k, None) - _RUNS[rid] = { - "status": "running", - "exit_code": None, - "label": label, - "output": [], - "started": time.time(), - } + _admit_run_locked(rid, label) async def worker() -> None: proc: Any = None @@ -1299,6 +1411,7 @@ def _sel(): "_TRUSTED_PATH", "_UNRESOLVED_TOOL_PREFIX", "_WINDOWS_SAFE_ENV_KEYS", + "_admit_run_locked", "_bin_override_var", "_build_env", "_build_path", @@ -1317,6 +1430,7 @@ def _sel(): "_sanitized_npm_registry_env", "_sel", "_start_run", + "_start_task_run", "_toolchain_bin", "_trusted_bin", "_unresolved_tool_message", diff --git a/src/kiro_crew/apps/builtins/dev_fleet/server.py b/src/kiro_crew/apps/builtins/dev_fleet/server.py index b65ed1b39d8..cccc6837c9a 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/server.py +++ b/src/kiro_crew/apps/builtins/dev_fleet/server.py @@ -23,6 +23,7 @@ POST /api/pod/token {name} POST /api/pod/provision {name} -> start async build, returns {run_id} POST /api/rebase {name} + POST /api/release-channel/create -> start materializing the channel's detached worktree, returns {run_id} GET /api/health -> {"status": "ok", "start_id": ...} (restart handshake; proxied) GET /health -> same body, HMAC-exempt (gateway-internal liveness poll only) """ @@ -40,9 +41,11 @@ from kiro_crew.apps.builtins.dev_fleet import ( fleet_state, + fleet_state_release_channel, http_api, live, pointer_broker, + release_channel_pin, repository, runtime, worktree_ops, @@ -50,10 +53,17 @@ from kiro_crew.config.loader import KiroCrewConfig from kiro_crew.platform import boot_platform +# Ordered lowest-level first; the ownership DAG test enforces that no component +# imports one listed after it. ``release_channel_pin`` sits above ``live`` (it needs +# only runtime + repository) and below ``fleet_state_release_channel``, which turns its +# resolution into the channel's fleet row, ``fleet_state``, which publishes that row in +# the snapshot, and ``worktree_ops``, which mutates against it. _COMPONENTS = ( runtime, repository, live, + release_channel_pin, + fleet_state_release_channel, fleet_state, worktree_ops, http_api, @@ -234,6 +244,9 @@ def create_app() -> web.Application: app.router.add_post("/api/pod/provision", http_api.api_dev_fleet_pod_provision) app.router.add_post("/api/pod/provision/dismiss", http_api.api_dev_fleet_pod_provision_dismiss) app.router.add_post("/api/rebase", http_api.api_dev_fleet_rebase) + app.router.add_post( + "/api/release-channel/create", http_api.api_dev_fleet_release_channel_create + ) # NOT here: /api/make-live and /api/restart-gateway. Both touch the live-target # pointer (or its cutover latch), which this sandboxed backend must never reach — # they are served by the GATEWAY process under /api/apps/dev-fleet/ (see diff --git a/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py b/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py index 720470537ba..95ae59e5784 100644 --- a/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py +++ b/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING from kiro_crew import _facade_mirror, executors -from kiro_crew.apps.builtins.dev_fleet import fleet_state, repository, runtime +from kiro_crew.apps.builtins.dev_fleet import fleet_state, release_channel_pin, repository, runtime from kiro_crew.apps.builtins.dev_fleet.worktree_ops_pods import _wt_lock from kiro_crew.apps.builtins.dev_fleet.worktree_ops_removal import _worktree_remove from kiro_crew.git_tmp_packs import reap_stale_tmp_packs @@ -236,7 +236,27 @@ async def _prunable(path: str, branch: str | None) -> dict: return {**base, "ok": False, "code": "closed_new_commits", "unmerged_commits": unmerged} return {**base, "ok": True, "code": "closed", "unmerged_commits": unmerged} if own == 0 and not dirty: - if age_h and age_h > 48: + # `empty` reads "no commits of its own, clean, older than 48h" as ABANDONMENT, + # and that inference only holds for a tree on a BRANCH. A detached tree has no + # branch and therefore no PR, and every commit it holds is reachable from the + # base branch, so `own` is 0 by construction rather than by neglect -- for a + # release-channel pin that is the steady state, and it is what the operator + # asked Dev Fleet to keep. + # + # Withheld for the PIN only: detached AND carrying the reserved basename. Both + # halves are needed. A BRANCH checkout that merely borrowed the name is an + # ordinary feature tree and falls through to the merged/closed/active logic, + # where it belongs; a tree the operator detached by hand under any other name + # keeps today's `empty` verdict, so this change reaches nothing that was not + # created by this feature. The name is read from the path, with no git probe, + # because prune runs over the fleet listing before `fleet_state` classifies + # the channel and must not depend on a classification that can fail. + # + # The cost that remains is the one the operator asked for: a pin nobody has + # made live for weeks is still never preselected for deletion. Removing it is + # Remove on the row, as for any worktree. + pinned = branch is None and Path(path).name == release_channel_pin.WORKTREE_NAME + if not pinned and age_h and age_h > 48: return {**base, "ok": True, "code": "empty"} return {**base, "ok": False, "code": "fresh"} return {**base, "ok": False, "code": "active"} @@ -517,6 +537,33 @@ async def _status_refresher() -> None: return while True: try: + # Two fetches, two spawns, on purpose. The base branch is what every row's + # Behind is measured against and is fetched exactly as it was before the + # release channel existed; the channel's mirror refspec is fetched by + # `release_channel_pin.fetch_refs`, pruned, so a newly published release + # becomes visible BETWEEN mutations and a forged or retracted mirror ref + # does not survive a cycle. Keeping them apart is what keeps their failures + # apart: a mirror the remote cannot serve leaves Behind moving, and a base + # branch that will not fetch leaves the channel's badge honest. + # + # The mirror fetch is delegated rather than spelled here: this refresh and + # the one before a mutation must populate the same mirror under the same + # rules, and a second spelling that drifted would leave the channel + # resolving against a mirror nothing updates -- indistinguishable from a + # repo that has published nothing new. + # + # Unconditional. Gating the mirror fetch on a lane existing would mean a + # `git worktree list` every cycle plus a fail-open branch, to avoid a cost + # nothing measured: tags are small refs and only the first fetch transfers + # them, so an install with no lane pays approximately nothing. + # + # A failed fetch is logged, never raised, and the fleet is still rebuilt + # from what this checkout holds: every row's Behind is measured against the + # last `/` a fetch brought in, and the channel against the + # last mirror. Nothing in the payload marks that staleness -- the channel's + # `error` is reserved for a git failure that blocks Create, which a + # transient network blip is not -- so the log line is the one place the + # operator can see why a badge stopped moving. remote = await repository._upstream_remote() fetched = await runtime._run_cmd( ["git", "-C", repo, "fetch", remote, repository.BASE_BRANCH, "--quiet"], @@ -544,6 +591,13 @@ async def _status_refresher() -> None: await asyncio.get_running_loop().run_in_executor( executors.subprocess_executor(), reap_stale_tmp_packs, repo ) + fetch_err = await release_channel_pin.fetch_refs(repo, timeout=90) + if fetch_err: + runtime.logger.warning( + "dev-fleet: refresher release-tag mirror fetch failed; channel" + " resolved from the last fetched mirror: %s", + fetch_err, + ) await fleet_state._fleet_refresh() except Exception: runtime.logger.exception("dev-fleet status refresher failed") @@ -721,6 +775,7 @@ async def _auto_prune_reaper() -> None: "_PRUNE_CONCURRENCY", "_PRUNE_LOCK", "_PRUNE_STATE", + "_RELEASE_CHANNEL_REFUSALS", "_SYNC_RID", "_WT_LOCKS", "_auto_prune_cfg", @@ -749,6 +804,9 @@ async def _auto_prune_reaper() -> None: "_rebase_locked", "_reclaim_pod_locked", "_refresher_task", + "_release_channel_create", + "_release_channel_create_start", + "_release_channel_preflight", "_status_refresher", "_sync", "_sync_base_ref", @@ -770,12 +828,14 @@ async def _auto_prune_reaper() -> None: # ``worktree_ops_sync`` -- Pull + Build sync of the main checkout # ``worktree_ops_pods`` -- pod actions, and the per-worktree lock ``_wt_lock`` # ``worktree_ops_removal`` -- removing one worktree, and ``_GIT_MUTATION_LOCK`` +# ``worktree_ops_release`` -- materializing the release-channel worktree #: Owner modules, lowest layer first: the first holder of a name answers its reads. _OWNERS = ( "kiro_crew.apps.builtins.dev_fleet.worktree_ops_sync", "kiro_crew.apps.builtins.dev_fleet.worktree_ops_pods", "kiro_crew.apps.builtins.dev_fleet.worktree_ops_removal", + "kiro_crew.apps.builtins.dev_fleet.worktree_ops_release", ) for _owner in _OWNERS: _importlib.import_module(_owner) @@ -807,6 +867,30 @@ async def _auto_prune_reaper() -> None: _same_checkout, _token_windows, ) + from kiro_crew.apps.builtins.dev_fleet.worktree_ops_release import ( # noqa: F401 + _CREATE_ADMISSION_LOCK, + _CREATE_RID, + _CREATE_RUN_LABEL, + _GIT_TRUE, + _HERMETIC_COMMON_CONFIG, + _HERMETIC_COMMON_HEAD, + _PARTIAL_CLONE_KEYS, + _RELEASE_CHANNEL_REFUSALS, + _discard_failed_lane_creation, + _discard_hermetic_common_dir, + _hermetic_common_config, + _hermetic_paths, + _is_partial_clone, + _lane_staging_path, + _make_hermetic_common_dir, + _populate_release_tree, + _promisor_in_force, + _release_channel_create, + _release_channel_create_locked, + _release_channel_create_start, + _release_channel_preflight, + _stage_and_adopt, + ) from kiro_crew.apps.builtins.dev_fleet.worktree_ops_removal import ( # noqa: F401 _GIT_MUTATION_LOCK, _GIT_WORKTREE_REMOVE_TIMEOUT_SECS, diff --git a/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops_release.py b/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops_release.py new file mode 100644 index 00000000000..4241692bb65 --- /dev/null +++ b/src/kiro_crew/apps/builtins/dev_fleet/worktree_ops_release.py @@ -0,0 +1,749 @@ +"""Materializing the release-channel worktree: the one mutation this lane has. + +Resolution is a pure read and lives in ``release_channel_pin``. This is the writer, +and it lives beside the other worktree writers because it takes the same ``.git`` +admin lock they take -- a second module reaching for that lock from outside this +family is how a lock-order inversion gets introduced. Reached through +``worktree_ops``, its facade. + +Lock order, unchanged from the removal path:: + + _wt_lock(name) -> _GIT_MUTATION_LOCK + +The checkout itself is HERMETIC: the release's files are written by a ``git +read-tree -u --reset`` whose ``GIT_COMMON_DIR`` points at a throwaway directory +this process created, so the repository's own ``config`` and ``info/attributes`` +are never opened by that spawn. See :func:`_populate_release_tree` for what that +replaces and why enumerating the config instead could not close the class. +""" + +from __future__ import annotations + +import asyncio +import os +import secrets +import shutil +import tempfile +from pathlib import Path +from typing import Callable + +from kiro_crew.apps.builtins.dev_fleet import fleet_state, release_channel_pin, repository, runtime +from kiro_crew.apps.builtins.dev_fleet.worktree_ops_pods import _wt_lock +from kiro_crew.apps.builtins.dev_fleet.worktree_ops_removal import _GIT_MUTATION_LOCK +from kiro_crew.executors import subprocess_executor +from kiro_crew.loop_lock import LoopBoundLock + +#: Result ``code``s that mean "the current state does not allow this", as opposed to +#: "a git command this app runs failed unexpectedly". +#: +#: Read by ``http_api`` to pick the status: a refusal is a 4xx the caller can act on +#: (remove the tree, publish a release, clean the forged ref), an unexpected git +#: failure is a 5xx. Spelled ONCE, here, beside the code strings themselves -- the +#: alternative was a second literal list in the route, and the two would drift the +#: first time a refusal was added, silently turning it into a 500. +_RELEASE_CHANNEL_REFUSALS = frozenset( + { + "no_repo", + "ambiguous_name", + "exists", + "busy", + "path_exists", + "fetch_failed", + "unresolved", + "unpublished", + "provenance_unverifiable", + "provenance_mismatch", + "partial_clone", + } +) + +#: ``git config --get-regexp`` pattern for the two ways git records a promisor remote: +#: ``remote..promisor`` (a BOOLEAN -- a hydrated checkout keeps it as ``false``) +#: and the legacy ``extensions.partialClone = `` (a remote NAME; present at all +#: means partial). The values are read, not just the keys: see :func:`_is_partial_clone`. +_PARTIAL_CLONE_KEYS = r"^(extensions\.partialclone|remote\..*\.promisor)$" +#: What ``git config --type=bool`` accepts as true, lower-cased. A bare key with no +#: value is also true in git, which the parser below handles as the empty string. +_GIT_TRUE = frozenset({"true", "yes", "on", "1", ""}) + +#: Minimum config for a git common dir that must answer nothing but "not bare" and +#: the object format of the store it borrows. +#: +#: Written rather than copied: a copy of the repository's own ``config`` would carry +#: the very declarations the hermetic spawn exists to put out of reach. ``bare = +#: false`` is required because ``read-tree -u`` refuses to touch a working tree +#: without it. The object format is the one DATA key that must agree with the real +#: store: a sha256 repository's oids are 64 hex digits and its ``objects/`` is laid +#: out for them, and a common dir that declares no format is read as sha1, so every +#: oid is "not a valid object name" and every Create fails. ``extensions.*`` is only +#: honoured at ``repositoryformatversion = 1``, so that version is declared exactly +#: when an extension is; the sha1 shape stays at version 0, which every git reads. +#: Nothing else is needed, and nothing else is supplied. +_HERMETIC_COMMON_CONFIG = "[core]\n\trepositoryformatversion = 0\n\tbare = false\n" + + +def _hermetic_common_config(object_format: str) -> str: + """The common-dir ``config`` for a store of *object_format* (``sha1``/``sha256``).""" + if object_format == "sha1": + return _HERMETIC_COMMON_CONFIG + return ( + "[core]\n\trepositoryformatversion = 1\n\tbare = false\n" + f"[extensions]\n\tobjectformat = {object_format}\n" + ) + + +#: HEAD for that common dir. Unborn on purpose: the populate reads HEAD from the +#: staging worktree's OWN gitdir (``$GIT_DIR/HEAD``, which ``worktree add --detach`` +#: wrote to the resolved oid), so this file is never the one consulted for the +#: checkout -- it exists because git requires the common dir to look like a +#: repository at all. +_HERMETIC_COMMON_HEAD = "ref: refs/heads/none\n" + + +#: The run id of the most recent create started through +#: :func:`_release_channel_create_start`, and the lock that makes "is one running?" +#: and "start one" a single step. Checked against the run registry rather than +#: cleared at the end: a run whose status is not ``running`` is simply not in flight. +_CREATE_RID: str | None = None +_CREATE_ADMISSION_LOCK = LoopBoundLock() + +#: ``_RUNS`` label of the create, so a run listing names what it is. +_CREATE_RUN_LABEL = "create " + release_channel_pin.WORKTREE_NAME + + +async def _release_channel_preflight() -> tuple[dict | None, str, str, str]: + """``(refusal, repo, name, path)`` -- the refusals that need no lock to decide. + + These are answered twice on purpose: once by the route, synchronously, so the + caller hears ``exists`` or ``ambiguous_name`` as a 409 instead of a run that + fails, and once more by the run itself right before it takes the lock, because + the fleet it is about to write is the one at that moment and not the one the + route looked at. + """ + name = release_channel_pin.WORKTREE_NAME + try: + repo = repository._repo() + except repository.RepoUnavailable as exc: + return {"ok": False, "code": "no_repo", "error": str(exc)}, "", name, "" + existing, find_err = await repository._find_worktree(name) + if existing is None and find_err and find_err != f"worktree not found: {name}": + # The fleet's own writer only ever creates the canonical sibling path, so + # ambiguity means an out-of-band checkout; refusing is loud and + # path-recoverable. + refusal = { + "ok": False, + "code": "ambiguous_name", + "error": f"refusing to create {name}: {find_err}", + } + return refusal, repo, name, "" + if existing is not None: + # Names the two controls that exist. This is the one collision an operator is + # guaranteed to hit -- the placeholder's Create is gone once the row is + # adopted, so anything reaching here is a stale tab or a second client -- and + # the pin does not move in place, so "move it to the tip" would send them + # looking for a control that was never shipped. + refusal = { + "ok": False, + "code": "exists", + "error": f"{name} already exists -- remove it first, then create it again", + } + return refusal, repo, name, "" + if _wt_lock(name).locked(): + refusal = { + "ok": False, + "code": "busy", + "error": f"another {name} operation is already running", + } + return refusal, repo, name, "" + return None, repo, name, release_channel_pin.worktree_path(repo) + + +async def _is_partial_clone(repo: str) -> bool: + """Whether *repo* has a promisor remote in force (``--filter`` clone). No network. + + The two markers are read by VALUE, because a checkout that was hydrated with + ``git fetch --refetch`` after dropping its clone filter keeps + ``remote..promisor = false`` behind: the key's presence says nothing, its + boolean does. ``extensions.partialClone`` (older gits) holds a remote name, so + any value means partial. ``git config --get-regexp`` exits 0 with the matching + lines, 1 with none. Any other exit is "cannot tell" and answers False: the + populate then fails on its own terms with git's error, which is the honest + outcome when the config cannot be read. + """ + rc, out, _err = await runtime._run_cmd( + ["git", "-C", repo, "config", "--get-regexp", _PARTIAL_CLONE_KEYS], + timeout=20, + mode="strict", + ) + if rc != 0: + return False + return _promisor_in_force(out or "") + + +def _promisor_in_force(config_lines: str) -> bool: + """Parse ``--get-regexp`` output for the markers: one ``key value`` per line.""" + for line in config_lines.splitlines(): + key, _, value = line.strip().partition(" ") + key = key.lower() + if key == "extensions.partialclone": + return True + if key.startswith("remote.") and key.endswith(".promisor"): + if value.strip().lower() in _GIT_TRUE: + return True + return False + + +async def _release_channel_create_start( + on_finish: Callable[[dict | None], None] | None = None, +) -> dict: + """Start the create as a tracked run; ``{ok, run_id}`` or a refusal. + + The create is a fetch, a ``worktree add``, a populate and a ``worktree move``, + each with a timeout in the minutes, and the app proxy a browser reaches this + route through gives up on a request at 30 seconds -- so a route that awaited + the create could never deliver its result to the one caller that asked for it. + The route starts the run and answers with its id; the outcome is read from + ``/api/run?id=`` as ``result`` once the run's status has left ``running``. + + Single-flight: a second start while one is running answers ``busy`` and NAMES + the run, so a page that lost its own id (a reload mid-create) attaches to it + instead of reporting a collision. Check and start are one step under + :data:`_CREATE_ADMISSION_LOCK`; two queued starts could otherwise both see no + run in flight. + """ + global _CREATE_RID + name = release_channel_pin.WORKTREE_NAME + async with _CREATE_ADMISSION_LOCK: + # The running create is answered FIRST, before the preflight: the run body + # holds ``_wt_lock(name)`` from just after it starts until the move lands, + # so the preflight's own ``busy`` -- which names no run -- would otherwise + # be the answer for the whole of a create's life, and the page that asked + # would show a failure instead of attaching to the work in flight. + if _CREATE_RID is not None: + async with runtime._RUNS_LOCK: + running = runtime._RUNS.get(_CREATE_RID, {}).get("status") == "running" + if running: + return { + "ok": False, + "code": "busy", + "error": f"a {name} create is already running", + "run_id": _CREATE_RID, + } + # Awaited under the admission lock on purpose: two starts queued behind one + # another must each see the run the other started, and the preflight is a + # single ``git worktree list`` read. + refusal, _repo, name, _path = await _release_channel_preflight() + if refusal is not None: + return refusal + rid = await runtime._start_task_run( + _CREATE_RUN_LABEL, _release_channel_create, on_finish=on_finish + ) + _CREATE_RID = rid + return {"ok": True, "run_id": rid, "name": name} + + +async def _release_channel_create() -> dict: + """Materialize the release-channel worktree, detached at the channel tip. + + Detached on purpose. A branch would invite a ``git pull`` that drifts the tree + off the release it is supposed to BE, and the fleet's behind-main count on a + branch-bearing row would start measuring against a ref the operator never chose. + Detached says what the tree is: a fixed point, which moves only by being removed + and created again at a newer release. + + This is the body of the run :func:`_release_channel_create_start` tracks; it + runs to completion and returns the result the run record carries. + """ + refusal, repo, name, path = await _release_channel_preflight() + if refusal is not None: + return refusal + async with _wt_lock(name): + return await _release_channel_create_locked(repo, name, path) + + +async def _release_channel_create_locked(repo: str, name: str, path: str) -> dict: + """The create, with ``_wt_lock(name)`` already held.""" + loop = asyncio.get_running_loop() + # Refuse rather than adopt. A directory already at the target path is not ours; + # ``git worktree add`` fails on a non-empty path anyway, and refusing here names + # the path instead of surfacing git's message about a directory the operator did + # not know was involved. + if await loop.run_in_executor(subprocess_executor(), Path(path).exists): + return { + "ok": False, + "code": "path_exists", + "error": f"refusing: {path} already exists on disk", + } + if await _is_partial_clone(repo): + # The populate reads objects through a private common dir that names no + # promisor remote -- deliberately, since lazy fetching is a network call and + # the populate runs on the tier that has none. In a partial clone the + # release's blobs are not all local, so that read-tree would die part-way + # with "unable to read", after the fetch and the worktree add had succeeded. + # Refused HERE, before the fetch and before anything touches disk, with the + # state named: this is a property of the operator's checkout they can + # change, not a git failure. + return { + "ok": False, + "code": "partial_clone", + # The remedy has to clear the very setting this refusal reads, or an operator + # who follows it is refused again: `--refetch` fills the object store but + # leaves `remote..promisor` behind as `true`. Named in the order git + # needs them -- the filter first, so the refetch is a full one -- and the + # promisor flag last, after the objects are local. + "error": ( + "the main checkout is a partial clone (a promisor remote is configured), " + "so a release's files cannot all be read locally -- the release channel " + "needs a full clone. To hydrate this one, run in it: " + "`git config --unset remote..partialclonefilter`, then " + "`git fetch --refetch `, then `git config --unset remote..promisor` " + "(and `git config --unset extensions.partialclone` if set); or use a full " + "clone as the main checkout" + ), + } + fetch_err = await release_channel_pin.fetch_refs(repo) + if fetch_err: + return { + "ok": False, + "code": "fetch_failed", + "error": f"cannot refresh release refs: {fetch_err}", + } + resolved = await release_channel_pin.resolve_tip(repo=repo) + if not resolved.get("ok"): + return { + "ok": False, + "code": "unpublished" if resolved.get("unpublished") else "unresolved", + "error": resolved.get("error") or "the release channel could not be resolved", + } + oid = str(resolved["oid"]) + # The oid above came from the mirror, which the fetch's `--prune` only cleans of + # refs absent when that subprocess EXITED -- so a ref planted afterwards is + # unpruned, outranks every real release if its version is high enough, and its oid + # is what would be checked out and later run by the lane's pod. Ask the remote what + # it actually publishes and refuse anything else. This closes the class rather than + # the instance: no timing wins, because a locally planted ref is not in the + # advertised set at any moment. + # + # Fails CLOSED on a failed query, and treats an unadvertised tag as a refusal + # rather than falling back to the real oid -- a silent correction would materialize + # the right tree and hide that the mirror had been written to. + tag = str(resolved["ref"]).rsplit("/", 1)[-1] + published, adv_err = await release_channel_pin.advertised_release_commit(repo, tag) + if adv_err: + return { + "ok": False, + "code": "provenance_unverifiable", + "error": f"cannot confirm the release with the remote: {adv_err}", + } + if published != oid: + return { + "ok": False, + "code": "provenance_mismatch", + "error": ( + f"refusing: {tag} in this checkout does not match what the remote " + f"publishes -- the release ref store has been written to locally" + ), + } + staging = _lane_staging_path(path) + # Every exit from here to the adoption below has to leave the staging tree cleaned + # up, and a RETURN is not the only exit. `_run_uninterruptible` shields each git + # child but re-raises `CancelledError` once it returns, so an ordinary backend + # shutdown unwinds this frame between a successful `worktree add` and the `worktree + # move` that adopts it -- and the failure cleanups below are reached only by a + # returning failure, never by an unwind. `BaseException` rather than `Exception` + # because cancellation is the case this exists for. Best-effort and then re-raise: + # the cleanup's own awaits can be cancelled in turn, so this narrows the window + # rather than closing it. What is left when it loses is bounded -- a + # `.staging..` directory, which the operator's own `git worktree + # prune` or `remove` reclaims (this module never prunes repository-wide, see + # `_discard_failed_lane_creation`) and which cannot block a retry, because the + # create guard tests the LANE path and every call stages under a fresh name. + try: + return await _stage_and_adopt(repo, name, path, staging, oid, resolved) + except BaseException: + await _discard_failed_lane_creation(repo, staging) + raise + + +async def _stage_and_adopt( + repo: str, name: str, path: str, staging: str, oid: str, resolved: dict +) -> dict: + """Build the lane at *staging*, populate it hermetically, then adopt it at *path*.""" + loop = asyncio.get_running_loop() + add_err = "" + populate_err: str | None = None + staged_head: str | None = None + # Every failure below is reported AFTER this block, never inside it: + # `_discard_failed_lane_creation` takes `_GIT_MUTATION_LOCK` itself, and + # `LoopBoundLock` is not reentrant, so a cleanup reached while the lock is held + # would wait on this frame forever. + async with _GIT_MUTATION_LOCK: + # Build the admin record and staging gitdir first, but deliberately leave the + # worktree empty: the populate below supplies its own object-store view, so + # there is nothing to gain from letting git write files under the repository's + # own config first. + # + # Drained: a cancellation landing mid-`worktree add` would otherwise unwind + # this frame -- releasing _GIT_MUTATION_LOCK -- while git is still registering + # the admin record. + # + # Built at `staging`, not at `path`, so the destructive cleanup below can only + # ever target a directory THIS call named. See `_lane_staging_path`. + add_rc, _add_out, add_err = await runtime._run_uninterruptible( + runtime._run_cmd( + ["git", "-C", repo, "worktree", "add", "--no-checkout", "--detach", staging, oid], + timeout=300, + mode="strict", + ) + ) + if add_rc == 0: + populate_err = await _populate_release_tree(repo, staging, oid) + if populate_err is None: + # Read back through the staging worktree's REAL gitdir, which is a + # plain file read of `$GIT_DIR/HEAD`: the populate never moves HEAD, so + # this confirms the detached pin `worktree add` set is still the oid + # provenance verified. + staged_head = await repository._git(staging, "rev-parse", "HEAD", mode="strict") + if add_rc != 0: + # `worktree add` can fail AFTER registering the worktree and creating the + # directory, and the path-exists guard in the caller then refuses every retry -- + # so the operator would be left with a lane that can neither be created nor + # removed, recoverable only by hand. + await _discard_failed_lane_creation(repo, staging) + detail = runtime._error_detail(add_err) + return {"ok": False, "code": "git_failed", "error": f"git worktree add failed: {detail}"} + if populate_err is not None: + await _discard_failed_lane_creation(repo, staging) + return { + "ok": False, + "code": "git_failed", + "error": f"could not write the release into the worktree: {populate_err}", + } + if staged_head != oid: + await _discard_failed_lane_creation(repo, staging) + return { + "ok": False, + "code": "git_failed", + "error": "refusing: the staged worktree is not detached at the resolved release", + } + # Adopt the staged tree under the lane's real name. `worktree move` refuses a + # destination that is a registered WORKTREE, and that is the half of the race it + # decides: a second writer in this process or another gateway that adopted `path` + # first wins, and we discard our own staging tree instead of deleting theirs. + # + # It does NOT refuse a plain DIRECTORY. Like mv(1), `worktree move ` + # with an existing directory destination moves the source INTO it, exits 0, and + # registers the worktree at `/` -- nested inside whatever + # appeared at `path` during the fetch, resolve and populate above (a `mkdir`, an + # operator's own `worktree add`, a checkout another process is still building). + # Measured on git 2.54. So the move is bracketed: the destination is re-checked + # under the lock right before it, which closes the window for every writer in + # this process, and the landing is verified right after it, which is the check no + # lock can replace for a writer in another process. Verification asks git where + # the worktree at `path` keeps its admin dir and compares it with the staging + # worktree's own, captured before the move: equal means the move landed as + # intended; anything else means `path` is someone else's tree and ours is nested + # inside it, in which case the nested tree is removed -- it is ours by + # construction, see `_lane_staging_path` -- and the collision is reported. + collided = False + staging_gitdir = await repository._git( + staging, "rev-parse", "--absolute-git-dir", mode="strict" + ) + async with _GIT_MUTATION_LOCK: + if await loop.run_in_executor(subprocess_executor(), Path(path).exists): + collided, mrc, merr = True, 0, "" + else: + mrc, _mout, merr = await runtime._run_uninterruptible( + runtime._run_cmd( + ["git", "-C", repo, "worktree", "move", staging, path], + timeout=120, + mode="strict", + ) + ) + if mrc == 0 and not collided: + landed_gitdir = await repository._git( + path, "rev-parse", "--absolute-git-dir", mode="strict" + ) + if not staging_gitdir or landed_gitdir != staging_gitdir: + collided = True + # Where mv(1) semantics put it. Removing by that path and not by `path` + # is the whole point: `path` is the other writer's tree. + staging = os.path.join(path, os.path.basename(staging)) + if collided: + await _discard_failed_lane_creation(repo, staging) + return { + "ok": False, + "code": "path_exists", + "error": f"refusing: {path} already exists on disk", + } + if mrc != 0: + await _discard_failed_lane_creation(repo, staging) + if await loop.run_in_executor(subprocess_executor(), Path(path).exists): + return { + "ok": False, + "code": "path_exists", + "error": f"refusing: {path} already exists on disk", + } + return { + "ok": False, + "code": "git_failed", + "error": f"git worktree move failed: {runtime._error_detail(merr)}", + } + # The row exists now. Tell the fleet cache before anyone asks, so a rebuild that + # started while this create ran cannot publish a snapshot without it -- the + # `fresh=1` the page sends on success would otherwise coalesce onto that build + # and show the placeholder it just replaced. `_fleet_forget` is the removal twin. + fleet_state._fleet_adopted() + return { + "ok": True, + # The channel's name rides along for the success toast ("Created stable + # worktree at 0.5.3"); the page reads it from the reply rather than from a + # row it may not have rendered yet. + "lane": resolved["lane"], + "name": name, + "ref": resolved["ref"], + "version": resolved["version"], + "path": path, + } + + +async def _populate_release_tree(repo: str, staging: str, oid: str) -> str | None: + """Write *oid*'s tree into *staging* with this repository's config out of reach. + + ``None`` on success, else the error to report. + + WHY THE OBJECT STORE IS BORROWED RATHER THAN THE CONFIG READ. Checking a tag out + runs whatever ``filter..process``/``.smudge`` driver applies to the files it + writes, and that driver is an arbitrary command named by this repository's + ``.git/config`` -- which is writable by anything with the agent's filesystem + access. ``core.worktree`` from the same file redirects where those files land. + Neither is reachable by ``_GIT_ENV_NEUTRALIZERS``: the filter key space is + unbounded (```` is attacker-chosen) and git deliberately ignores a + ``core.worktree`` supplied through ``-c``/``GIT_CONFIG_*``. + + Enumerating the config instead -- probe for a filter key, probe for a redirect, + refuse -- was the previous shape, and it could not close the class. ``git`` + reads the LOCAL ``.git/config`` on every invocation and no flag suppresses that + (``GIT_CONFIG_NOSYSTEM`` and ``GIT_CONFIG_GLOBAL`` reach only the other two + scopes), so a probe and the checkout it guards are two spawns with a writable + file between them; the probe also had to grow a scope, a boolean-spelling and an + include-following rule per git feature it learned about. + + So the checkout is given a DIFFERENT repository to read config from: a throwaway + common dir this process just created, holding the minimum config + (:func:`_hermetic_common_config`: not bare, plus the store's own object format + so a sha256 store is read as one), an empty ``refs/``, and an + ``objects/info/alternates`` naming the real store -- so every object resolves + while ``config`` and ``info/attributes`` resolve to files no other writer has. + ``GIT_DIR`` stays the staging worktree's own gitdir, so the index and HEAD are + written where the worktree keeps them. ONE spawn, with no probe to race. + + A ``.gitattributes`` in the release's own tree can still NAME a filter, and that + stays inert: a name with no driver defined in the config git is reading is a + no-op, ``required`` included. + + What this does NOT promise: after the populate, the lane is an ordinary worktree + again, so an operator-config ``required`` clean filter can make a later ``git + status`` in it fail (rc 128). That is the operator's own config acting on an + ordinary read, identical to every other fleet row, and the row builder already + tolerates a failed status. + """ + loop = asyncio.get_running_loop() + gitdir, objects, object_format, probe_err = await _hermetic_paths(repo, staging) + if probe_err is not None: + return probe_err + try: + common = await loop.run_in_executor( + subprocess_executor(), + _make_hermetic_common_dir, + str(Path(staging).parent), + objects, + object_format, + ) + except OSError as exc: + return runtime._error_detail(f"cannot stage a private git common dir: {exc}") + try: + # Built, not merged over `os.environ`: the hazard here is a variable that must + # be ABSENT. An inherited `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, + # `GIT_ALTERNATE_OBJECT_DIRECTORIES` or `GIT_NAMESPACE` would survive an + # update() and silently retarget this write, so every inherited `GIT_*` is + # dropped and only the five below are supplied. `_run_cmd` re-applies + # `_GIT_ENV_NEUTRALIZERS` on top, which is where the fixed-key exec vectors + # (hooks, fsmonitor, credential helper, signature programs) are pinned. + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update( + { + "GIT_DIR": gitdir, + "GIT_COMMON_DIR": common, + "GIT_WORK_TREE": staging, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": os.devnull, + } + ) + # `read-tree -u --reset`, never `reset --hard`: this must update the index and + # the working tree and leave HEAD exactly where `worktree add --detach` put it, + # which is what the read-back above verifies. + rc, _out, err = await runtime._run_uninterruptible( + runtime._run_cmd( + ["git", "read-tree", "-u", "--reset", oid], + cwd=staging, + env=env, + timeout=300, + mode="strict", + ) + ) + finally: + # Deleted whatever happened, including on the cancellation path: the directory + # is this call's by construction (``mkdtemp``), holds no object of its own, and + # a leftover would be a private git repository sitting beside the operator's + # checkouts. + await loop.run_in_executor(subprocess_executor(), _discard_hermetic_common_dir, common) + if rc != 0: + return runtime._error_detail(err) or "git read-tree failed" + return None + + +async def _hermetic_paths(repo: str, staging: str) -> tuple[str, str, str, str | None]: + """``(staging gitdir, repository object store, object format, error)``. + + Paths are absolute; the format is ``sha1`` or ``sha256``, as git states it. Fails + CLOSED: a value git will not state is an error, because a populate whose object + store, index location or object format had to be guessed must not run. All three + reads take the ``strict`` tier -- they need no network, so they need no + credentials. + """ + rc, out, err = await runtime._run_cmd( + ["git", "-C", staging, "rev-parse", "--absolute-git-dir"], timeout=20, mode="strict" + ) + gitdir = (out or "").strip() + if rc != 0 or not gitdir: + detail = runtime._error_detail(err) + return "", "", "", detail or "cannot locate the staged worktree's git dir" + rc, out, err = await runtime._run_cmd( + ["git", "-C", repo, "rev-parse", "--git-common-dir"], timeout=20, mode="strict" + ) + stated = (out or "").strip() + if rc != 0 or not stated: + detail = runtime._error_detail(err) + return "", "", "", detail or "cannot locate this checkout's git object store" + # The private common dir must declare the SAME object format as the store it + # borrows, or git reads a sha256 store as sha1 and resolves nothing. + rc, out, err = await runtime._run_cmd( + ["git", "-C", repo, "rev-parse", "--show-object-format"], timeout=20, mode="strict" + ) + object_format = (out or "").strip() + if rc != 0 or object_format not in ("sha1", "sha256"): + detail = runtime._error_detail(err) + return "", "", "", detail or "cannot determine this checkout's git object format" + # `--git-common-dir` answers relatively (`.git`) for a command run inside the + # checkout, so it is joined against the repo rather than used as given; `join` + # keeps an absolute answer unchanged. + objects = await asyncio.get_running_loop().run_in_executor( + subprocess_executor(), lambda: os.path.realpath(os.path.join(repo, stated, "objects")) + ) + return gitdir, objects, object_format, None + + +def _make_hermetic_common_dir(parent: str, objects: str, object_format: str) -> str: + """Create the throwaway git common dir the populate reads config from. + + Runs on the executor: four file writes and a ``mkdtemp``. ``mkdtemp`` creates the + directory 0700 and with a name no other process can predict, which is what makes + the later delete this call's own. + + Placed beside the staging worktree rather than in the system temp dir so it shares + the filesystem and the access rules of the trees this app already writes, and so an + operator who finds a leftover finds it next to the tree it belonged to. + """ + common = tempfile.mkdtemp(prefix=".release-channel-gitcommon.", dir=parent) + os.makedirs(os.path.join(common, "objects", "info"), exist_ok=True) + os.makedirs(os.path.join(common, "refs"), exist_ok=True) + for relative, text in ( + ("config", _hermetic_common_config(object_format)), + ("HEAD", _HERMETIC_COMMON_HEAD), + (os.path.join("objects", "info", "alternates"), f"{objects}\n"), + ): + with open(os.path.join(common, relative), "w", encoding="utf-8") as handle: + handle.write(text) + return common + + +def _discard_hermetic_common_dir(common: str) -> str | None: + """Delete the throwaway common dir. Best-effort; the populate's error is the news.""" + try: + shutil.rmtree(common) + except OSError as exc: # pragma: no cover - defensive; nothing to recover + runtime.logger.warning("release-channel: private git common dir left behind: %s", exc) + return str(exc) + return None + + +def _lane_staging_path(path: str) -> str: + """A sibling path only THIS call can name, for building a lane before adopting it. + + Creating the lane directly at its final path made the failure cleanup unsafe, and + the reason is worth keeping: the cleanup inferred ownership from the earlier + ``Path(path).exists()`` guard -- "it was not there before we ran, so whatever is + there now is ours". That inference needs a single writer, and the locks these + operations hold (:data:`_GIT_MUTATION_LOCK`, ``_wt_lock``) are per-event-loop, so + they serialize one process and not two. A second gateway sharing the repo could + create the path between our guard and our ``worktree add``; ours then failed and the + cleanup deleted THEIR populated worktree, with no reflog to recover untracked files + from. + + Staging removes the inference instead of adding a lock. The pid and a random suffix + make the directory ours by construction, so ``remove --force`` can never name a path + another writer owns, and ``git worktree move`` -- which refuses an existing + destination -- becomes the single step that decides the race. The loser discards its + own staging tree and reports the collision. + + Deliberately a sibling rather than a temp dir: ``worktree move`` across filesystems + is not guaranteed, and the lane's parent is where it must land. + """ + return f"{path}.staging.{os.getpid()}.{secrets.token_hex(4)}" + + +async def _discard_failed_lane_creation(repo: str, path: str) -> None: + """Remove the residue of a ``worktree add`` that failed part-way. + + Only ever called on a STAGING path, which carries the creating process's pid and a + random suffix, so the directory is this call's by construction. That is the whole + reason a ``remove --force`` is allowed here: the previous shape aimed it at the + lane's real path and inferred ownership from an earlier existence check, which a + second process on the same repo could invalidate. See :func:`_lane_staging_path`. + + Best-effort by construction -- the caller is about to return git's real error, and a + cleanup failure must not replace it, so every outcome here is swallowed and logged. + + ONE scoped command, no ``worktree prune``. ``remove --force `` deletes the + staging worktree's administrative record itself, and does so whether or not the + directory still exists (a staging dir that ``worktree add`` never got to create, or + one already gone, is exit 0 with the record dropped). A repository-wide ``prune`` + after it therefore covered nothing this helper owns, and what it DID reach was every + other worktree of the repository: an unrelated, unlocked checkout whose directory is + temporarily absent -- unmounted removable or network media is the ordinary case -- is + exactly what ``prune`` unregisters, and it has no undo. Cleanup after a failed lane + create never gets to decide that for a worktree it did not create. + """ + try: + async with _GIT_MUTATION_LOCK: + await runtime._run_uninterruptible( + runtime._run_cmd( + ["git", "-C", repo, "worktree", "remove", "--force", path], + timeout=60, + mode="strict", + ) + ) + except Exception: # pragma: no cover - defensive; the real error is the caller's + runtime.logger.warning("release-channel: cleanup after failed create raised", exc_info=True) + + +__all__ = ( + "_CREATE_RUN_LABEL", + "_RELEASE_CHANNEL_REFUSALS", + "_discard_failed_lane_creation", + "_lane_staging_path", + "_release_channel_create", + "_release_channel_create_start", + "_release_channel_preflight", +) diff --git a/temp-screenshots/release-channel/01-release-channel-adopted-dark.png b/temp-screenshots/release-channel/01-release-channel-adopted-dark.png new file mode 100644 index 00000000000..a33f7ba8b4c Binary files /dev/null and b/temp-screenshots/release-channel/01-release-channel-adopted-dark.png differ diff --git a/temp-screenshots/release-channel/02-release-channel-placeholder-create-dark.png b/temp-screenshots/release-channel/02-release-channel-placeholder-create-dark.png new file mode 100644 index 00000000000..a6efa2d9e40 Binary files /dev/null and b/temp-screenshots/release-channel/02-release-channel-placeholder-create-dark.png differ diff --git a/temp-screenshots/release-channel/03-release-channel-name-taken-branch-dark.png b/temp-screenshots/release-channel/03-release-channel-name-taken-branch-dark.png new file mode 100644 index 00000000000..b4ec76208af Binary files /dev/null and b/temp-screenshots/release-channel/03-release-channel-name-taken-branch-dark.png differ diff --git a/temp-screenshots/release-channel/04-release-channel-at-tip-dark.png b/temp-screenshots/release-channel/04-release-channel-at-tip-dark.png new file mode 100644 index 00000000000..ede6f954c26 Binary files /dev/null and b/temp-screenshots/release-channel/04-release-channel-at-tip-dark.png differ diff --git a/temp-screenshots/release-channel/05-release-channel-unpublished-create-live-dark.png b/temp-screenshots/release-channel/05-release-channel-unpublished-create-live-dark.png new file mode 100644 index 00000000000..69a2728da69 Binary files /dev/null and b/temp-screenshots/release-channel/05-release-channel-unpublished-create-live-dark.png differ diff --git a/temp-screenshots/release-channel/06-release-channel-blocked-placeholder-dark.png b/temp-screenshots/release-channel/06-release-channel-blocked-placeholder-dark.png new file mode 100644 index 00000000000..5807891ec14 Binary files /dev/null and b/temp-screenshots/release-channel/06-release-channel-blocked-placeholder-dark.png differ diff --git a/temp-screenshots/release-channel/07-release-channel-blocked-adopted-dark.png b/temp-screenshots/release-channel/07-release-channel-blocked-adopted-dark.png new file mode 100644 index 00000000000..c3567284004 Binary files /dev/null and b/temp-screenshots/release-channel/07-release-channel-blocked-adopted-dark.png differ diff --git a/temp-screenshots/release-channel/08-release-channel-no-release-dark.png b/temp-screenshots/release-channel/08-release-channel-no-release-dark.png new file mode 100644 index 00000000000..a8b73814899 Binary files /dev/null and b/temp-screenshots/release-channel/08-release-channel-no-release-dark.png differ diff --git a/temp-screenshots/release-channel/09-release-channel-create-busy-dark.png b/temp-screenshots/release-channel/09-release-channel-create-busy-dark.png new file mode 100644 index 00000000000..d33aefba745 Binary files /dev/null and b/temp-screenshots/release-channel/09-release-channel-create-busy-dark.png differ diff --git a/temp-screenshots/release-channel/10-release-channel-create-success-dark.png b/temp-screenshots/release-channel/10-release-channel-create-success-dark.png new file mode 100644 index 00000000000..90e84aa97af Binary files /dev/null and b/temp-screenshots/release-channel/10-release-channel-create-success-dark.png differ diff --git a/temp-screenshots/release-channel/11-release-channel-create-failed-dark.png b/temp-screenshots/release-channel/11-release-channel-create-failed-dark.png new file mode 100644 index 00000000000..fc2b5583fdd Binary files /dev/null and b/temp-screenshots/release-channel/11-release-channel-create-failed-dark.png differ diff --git a/temp-screenshots/release-channel/12-release-channel-unreadable-head-dark.png b/temp-screenshots/release-channel/12-release-channel-unreadable-head-dark.png new file mode 100644 index 00000000000..644bf194f36 Binary files /dev/null and b/temp-screenshots/release-channel/12-release-channel-unreadable-head-dark.png differ diff --git a/temp-screenshots/release-channel/13-release-channel-row-menu-open-dark.png b/temp-screenshots/release-channel/13-release-channel-row-menu-open-dark.png new file mode 100644 index 00000000000..ed1644c2bf2 Binary files /dev/null and b/temp-screenshots/release-channel/13-release-channel-row-menu-open-dark.png differ diff --git a/temp-screenshots/release-channel/14-release-channel-version-unknown-dark.png b/temp-screenshots/release-channel/14-release-channel-version-unknown-dark.png new file mode 100644 index 00000000000..a6259f4e612 Binary files /dev/null and b/temp-screenshots/release-channel/14-release-channel-version-unknown-dark.png differ diff --git a/test/test_dev_fleet_app_fleet_cache.py b/test/test_dev_fleet_app_fleet_cache.py index 3530dd6ae78..91361a7fd44 100644 --- a/test/test_dev_fleet_app_fleet_cache.py +++ b/test/test_dev_fleet_app_fleet_cache.py @@ -275,10 +275,15 @@ def _clean_fleet_cache(): saved = dict(mod._FLEET_CACHE) saved_inflight = mod._FLEET_INFLIGHT saved_epoch = mod._FLEET_EPOCH + saved_adopted = mod._FLEET_ADOPTED_EPOCH saved_tombs = dict(mod._FLEET_TOMBSTONES) mod._FLEET_CACHE.update({"data": None, "ts": 0.0}) fleet_state_mod._FLEET_INFLIGHT = None fleet_state_mod._FLEET_EPOCH = 0 + # Both epochs, together: `_fleet_build` re-reads when the adoption epoch is + # past its start, so an adoption epoch left behind by another test against a + # reset `_FLEET_EPOCH` makes every build here run twice. + fleet_state_mod._FLEET_ADOPTED_EPOCH = 0 fleet_state_mod._FLEET_TOMBSTONES = {} try: yield @@ -287,6 +292,7 @@ def _clean_fleet_cache(): mod._FLEET_CACHE.update(saved) fleet_state_mod._FLEET_INFLIGHT = saved_inflight fleet_state_mod._FLEET_EPOCH = saved_epoch + fleet_state_mod._FLEET_ADOPTED_EPOCH = saved_adopted fleet_state_mod._FLEET_TOMBSTONES = saved_tombs diff --git a/test/test_dev_fleet_component_boundaries.py b/test/test_dev_fleet_component_boundaries.py index 6da48ed1d2a..95ee7710bb5 100644 --- a/test/test_dev_fleet_component_boundaries.py +++ b/test/test_dev_fleet_component_boundaries.py @@ -11,16 +11,19 @@ from kiro_crew.apps.builtins.dev_fleet import ( fleet_state, + fleet_state_release_channel, http_api, live, live_leases, live_plan, live_service, + release_channel_pin, repository, runtime, server, worktree_ops, worktree_ops_pods, + worktree_ops_release, worktree_ops_removal, worktree_ops_sync, ) @@ -29,6 +32,8 @@ runtime, repository, live, + release_channel_pin, + fleet_state_release_channel, fleet_state, worktree_ops, http_api, @@ -37,7 +42,12 @@ #: The owner modules a component's code moved to, read with it: same rank, same rules. _COMPONENT_OWNERS = { live: (live_leases, live_service, live_plan), - worktree_ops: (worktree_ops_sync, worktree_ops_pods, worktree_ops_removal), + worktree_ops: ( + worktree_ops_sync, + worktree_ops_pods, + worktree_ops_removal, + worktree_ops_release, + ), } _RANK.update( { @@ -233,6 +243,11 @@ def test_route_manifest_and_http_adapter_ownership_are_stable() -> None: ("POST", "/api/pod/provision", "api_dev_fleet_pod_provision"), ("POST", "/api/pod/provision/dismiss", "api_dev_fleet_pod_provision_dismiss"), ("POST", "/api/rebase", "api_dev_fleet_rebase"), + ( + "POST", + "/api/release-channel/create", + "api_dev_fleet_release_channel_create", + ), # NOT here: /api/restart-gateway and /api/make-live. They touch the live-target # pointer (or its cutover latch), which is masked from this sandboxed backend and # every child it spawns; the gateway process serves them under diff --git a/test/test_dev_fleet_release_channel_fleet.py b/test/test_dev_fleet_release_channel_fleet.py new file mode 100644 index 00000000000..8643be35d4c --- /dev/null +++ b/test/test_dev_fleet_release_channel_fleet.py @@ -0,0 +1,211 @@ +"""Release-channel pin: the fleet-wide surfaces the row touches. + +Sibling of ``test_dev_fleet_release_channel_ops.py`` (which holds the Create mutation +and the row's classification) carrying the three places the channel reaches beyond +its own row: the ``_drop_worktrees`` reconciliation a tombstoned removal gets, the +prune verdict that must never preselect the pin, and the background refresher's +fetch shape. Split out for the file line cap; one subject per module. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from pathlib import Path + +import pytest + +from kiro_crew.apps.builtins.dev_fleet import ( + fleet_state, + release_channel_pin, + repository, + runtime, + worktree_ops, +) +from kiro_crew.testing.clock import ManualClock +from kiro_crew.testing.wait import await_until + + +def _async_return(value): + async def _call(*_args, **_kw): + return value + + return _call + + +# -------------------------------------------------------------------------- +# tombstoned removal +# -------------------------------------------------------------------------- +def test_dropping_the_channels_worktree_demotes_the_channel_to_its_placeholder(): + """A tombstoned removal must take the channel's adopted binding with the row. + + `_drop_worktrees` is the one reconciliation a removal mid-build gets; leaving + `release_channel.worktree` set would publish a snapshot with no release row and no + Create either, since the page offers Create only when `worktree` is null. + """ + name = release_channel_pin.WORKTREE_NAME + data = { + "worktrees": [{"name": "main"}, {"name": name}], + "release_channel": { + "name": name, + "worktree": name, + "version": "0.4.9", + "tip_version": "0.5.0", + "at_tip": False, + "name_taken_by_branch": False, + }, + } + out = fleet_state._drop_worktrees(data, {name}) + assert [w["name"] for w in out["worktrees"]] == ["main"] + assert out["release_channel"]["worktree"] is None + assert out["release_channel"]["at_tip"] is None + assert out["release_channel"]["version"] == "0.5.0" # back to what Create would check out + assert out["release_channel"]["tip_version"] == "0.5.0" + # Copied, never mutated: a response in flight may still hold the old dict. + assert data["release_channel"]["worktree"] == name + # Dropping an unrelated row leaves the channel alone. + same = fleet_state._drop_worktrees(data, {"main"}) + assert same["release_channel"] is data["release_channel"] + + +def test_dropping_the_branch_checkout_holding_the_reserved_name_frees_it(): + """The OTHER holder: a branch checkout under the reserved name, recorded as + `name_taken_by_branch` with no `worktree`. One directory is one row, so dropping + the row that carries the name drops the occupant; a flag left set would hide + Create behind a checkout that is gone until a later rebuild.""" + name = release_channel_pin.WORKTREE_NAME + data = { + "worktrees": [{"name": "main"}, {"name": name, "branch": "feat/borrowed"}], + "release_channel": { + "name": name, + "worktree": None, + "version": "0.5.0", + "tip_version": "0.5.0", + "at_tip": None, + "name_taken_by_branch": True, + }, + } + out = fleet_state._drop_worktrees(data, {name}) + assert [w["name"] for w in out["worktrees"]] == ["main"] + assert out["release_channel"]["name_taken_by_branch"] is False + assert out["release_channel"]["worktree"] is None + assert data["release_channel"]["name_taken_by_branch"] is True # copied, not mutated + same = fleet_state._drop_worktrees(data, {"main"}) + assert same["release_channel"] is data["release_channel"] + + +# -------------------------------------------------------------------------- +# prune +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_prune_never_offers_the_detached_pin(tmp_path, monkeypatch): + """`empty` reads clean-and-old as ABANDONMENT, which does not hold for the pin. + + A detached tree at the reserved name has no branch and therefore no PR, and every + commit it holds is reachable from the base branch -- so `own == 0` is the steady + state rather than neglect, and it is exactly what the operator asked for. + """ + tree = tmp_path / release_channel_pin.WORKTREE_NAME + tree.mkdir() + monkeypatch.setattr(repository, "_own_commits_count", _async_return(0)) + monkeypatch.setattr(repository, "_real_dirty", _async_return(False)) + monkeypatch.setattr(repository, "_dirty_split", _async_return((False, []))) + monkeypatch.setattr(repository, "_git", _async_return("abc123")) + monkeypatch.setattr(fleet_state, "_pr_status_cached", _async_return(None)) + # Old enough that `empty` would be offered for a branch checkout. + ManualClock(start=Path(tree).stat().st_ctime + 400_000).install(monkeypatch, worktree_ops) + + verdict = await worktree_ops._prunable(str(tree), None) + assert verdict["ok"] is False + assert verdict["code"] == "fresh" + + +@pytest.mark.asyncio +async def test_prune_still_offers_a_hand_detached_tree_under_any_other_name(tmp_path, monkeypatch): + """The exemption reaches only what this feature creates. + + A tree the operator detached by hand -- at a release tag or anywhere else -- under + its own name keeps today's `empty` verdict on age, so Prune merged behaves for every + install exactly as it did before the channel row existed. + """ + tree = tmp_path / "kirocrew-wt-experiment" + tree.mkdir() + monkeypatch.setattr(repository, "_own_commits_count", _async_return(0)) + monkeypatch.setattr(repository, "_real_dirty", _async_return(False)) + monkeypatch.setattr(repository, "_dirty_split", _async_return((False, []))) + monkeypatch.setattr(repository, "_git", _async_return("abc123")) + monkeypatch.setattr(fleet_state, "_pr_status_cached", _async_return(None)) + ManualClock(start=Path(tree).stat().st_ctime + 400_000).install(monkeypatch, worktree_ops) + + verdict = await worktree_ops._prunable(str(tree), None) + assert verdict["ok"] is True + assert verdict["code"] == "empty" + + +@pytest.mark.asyncio +async def test_prune_still_offers_a_branch_checkout_holding_the_reserved_name( + tmp_path, monkeypatch +): + """Both halves are needed: a BRANCH tree is an ordinary feature tree whatever its name.""" + tree = tmp_path / release_channel_pin.WORKTREE_NAME + tree.mkdir() + monkeypatch.setattr(repository, "_own_commits_count", _async_return(0)) + monkeypatch.setattr(repository, "_real_dirty", _async_return(False)) + monkeypatch.setattr(repository, "_dirty_split", _async_return((False, []))) + monkeypatch.setattr(repository, "_git", _async_return("abc123")) + monkeypatch.setattr(fleet_state, "_pr_status_cached", _async_return(None)) + ManualClock(start=Path(tree).stat().st_ctime + 400_000).install(monkeypatch, worktree_ops) + + verdict = await worktree_ops._prunable(str(tree), "feat/borrowed-the-name") + assert verdict["ok"] is True + assert verdict["code"] == "empty" + + +# -------------------------------------------------------------------------- +# refresher +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_the_refresher_fetches_the_base_branch_and_the_mirror_in_separate_spawns( + monkeypatch, tmp_path +): + """Two fetches per cycle, each its own argv, so their failures stay apart. + + The base branch is what every row's Behind is measured against and is fetched as + it was before the channel existed; the mirror refspec is the channel's. A mirror + the remote cannot serve must not leave Behind stale, and here it does not: the base + fetch runs first and on its own, the mirror fetch fails, and the fleet is still + rebuilt. + """ + calls: list[list[str]] = [] + rebuilt = asyncio.Event() + + async def fake_run(cmd, **kw): + calls.append(list(cmd)) + if release_channel_pin._TAG_REFSPEC in cmd: + return 128, "", "fatal: could not read from remote repository" + return 0, "", "" + + async def fake_refresh(): + rebuilt.set() + return {} + + monkeypatch.setattr(repository, "_repo", lambda: str(tmp_path)) + monkeypatch.setattr(repository, "_upstream_remote", _async_return("origin")) + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + monkeypatch.setattr(fleet_state, "_fleet_refresh", fake_refresh) + task = asyncio.create_task(worktree_ops._status_refresher()) + try: + await await_until(rebuilt, timeout=5) + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + fetches = [cmd for cmd in calls if "fetch" in cmd] + assert len(fetches) == 2, fetches + base, mirror = fetches + assert repository.BASE_BRANCH in base + assert release_channel_pin._TAG_REFSPEC not in base + assert release_channel_pin._TAG_REFSPEC in mirror + assert repository.BASE_BRANCH not in mirror + assert "--prune" not in base, "pruning is the mirror fetch's job, scoped to its namespace" diff --git a/test/test_dev_fleet_release_channel_ops.py b/test/test_dev_fleet_release_channel_ops.py new file mode 100644 index 00000000000..d9f7bcc32ce --- /dev/null +++ b/test/test_dev_fleet_release_channel_ops.py @@ -0,0 +1,1913 @@ +"""Materializing the release-channel worktree, and publishing where it sits. + +Three classes of defect are guarded here, all of which produce a tree that looks +healthy: + +* **Resolving stale.** Create must fetch BEFORE resolving. The background refresher + keeps the fleet ROWS current, but it runs on its own schedule -- a mutation that + resolved first would pin whatever the tag mirror held at that moment and report the + result as the channel tip. +* **Adopting a tree that is not ours.** ``release-channel-stable`` is a reserved name, + and a user's own branch checkout under that name must never have its HEAD moved. + Adoption requires the SHAPE (detached), never the name. +* **Running the repository's own config while checking the release out.** A + ``filter..smudge`` in ``.git/config`` is an arbitrary command git executes on + the files it writes, and a ``core.worktree`` beside it decides where those files + land. The populate is given a private git common dir so neither is in reach; the + real-git test at the end of this module proves both arms. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import shlex +import shutil +from pathlib import Path + +import pytest +from git_maintenance_helpers import git_maintenance_off_entries + +from kiro_crew.apps.builtins.dev_fleet import ( + fleet_state, + fleet_state_release_channel, + http_api, + release_channel_pin, + repository, + runtime, + worktree_ops, +) +from kiro_crew.testing.wait import await_until + +requires_git = pytest.mark.skipif(shutil.which("git") is None, reason="git not available") + +_TIP_OID = "1" * 40 +_OTHER_OID = "2" * 40 +_TAG_OBJECT_OID = "3" * 40 + + +class _Git: + """Records every argv and answers the reads these ops make.""" + + def __init__( + self, + *, + tags=("v0.5.0",), + head=_TIP_OID, + head_tag="v0.4.9", + fail=None, + gitdir="/fake/gitdir", + advertised=None, + peeled=None, + staged_head=None, + ): + self.calls: list[list[str]] = [] + self.kwargs: list[dict] = [] + self._tags = list(tags) + self._head = head + # What the mirror answers when narrowed to HEAD: the release the worktree is + # ACTUALLY on. Deliberately a different release from the lane tip in `tags`, + # because a fake that answered with the tip could not tell a row showing its + # own version from one showing the tip's. + self._head_tag = head_tag + self._fail = fail or {} + self._gitdir = gitdir + # What `rev-parse HEAD` answers for the STAGED worktree, which the create reads + # back to confirm the detached pin. Defaults to the resolved tip. + self._staged_head = _TIP_OID if staged_head is None else staged_head + # What the REMOTE advertises per tag, as `ls-remote` prints it. Default: every + # tag in `_tags` advertises the oid `rev-parse` answers with, i.e. a checkout + # whose mirror agrees with the remote. `peeled` adds `^{}` lines, which is what + # an ANNOTATED tag looks like -- plain entry = tag object, peel = commit. + self._advertised = {t: _TIP_OID for t in self._tags} if advertised is None else advertised + self._peeled = peeled or {} + self.modes: list[str] = [] + + async def __call__(self, cmd, **kw): + self.calls.append(list(cmd)) + self.kwargs.append(dict(kw)) + self.modes.append(kw.get("mode", "standard")) + for needle, result in self._fail.items(): + if needle in cmd: + return result + if "rev-parse" in cmd and "--absolute-git-dir" in cmd: + return 0, f"{self._gitdir}\n", "" + if "rev-parse" in cmd and "--git-common-dir" in cmd: + return 0, ".git\n", "" + if "rev-parse" in cmd and "--show-object-format" in cmd: + return 0, "sha1\n", "" + if "config" in cmd and "--get-regexp" in cmd: + return 1, "", "" # not a partial clone: no promisor remote declared + if "fetch" in cmd: + return 0, "", "" + if "for-each-ref" in cmd and "--points-at" in cmd: + return (0, self._head_tag + "\n", "") if self._head_tag else (0, "", "") + if "for-each-ref" in cmd: + return 0, "\n".join(self._tags) + "\n", "" + if "symbolic-ref" in cmd: + return 1, "", "not a symbolic ref" # detached + if "ls-remote" in cmd: + lines = [f"{oid}\trefs/tags/{t}" for t, oid in self._advertised.items()] + lines += [f"{oid}\trefs/tags/{t}^{{}}" for t, oid in self._peeled.items()] + return 0, ("\n".join(lines) + "\n" if lines else ""), "" + if "read-tree" in cmd: + return 0, "", "" + if "worktree" in cmd: + return 0, "", "" + if "rev-parse" in cmd: + if cmd[-1] == "HEAD": + staged = "worktree" not in cmd and any(".staging." in part for part in cmd) + answer = self._staged_head if staged else self._head + return (0, answer + "\n", "") if answer else (128, "", "fatal: bad revision") + return 0, _TIP_OID + "\n", "" + return 1, "", f"unexpected argv: {cmd}" + + def argv_with(self, needle: str) -> list[str] | None: + for call in self.calls: + if needle in call: + return call + return None + + def kwargs_with(self, needle: str) -> dict | None: + for call, kw in zip(self.calls, self.kwargs): + if needle in call: + return kw + return None + + def order(self, *needles: str) -> list[int]: + """Index of the first call containing each needle.""" + return [next(i for i, c in enumerate(self.calls) if needle in c) for needle in needles] + + +@pytest.fixture(autouse=True) +def _isolated_fleet_epochs(_floor_monkeypatch): + """Every test here starts from, and leaves behind, clean fleet-cache epochs. + + A successful Create calls ``fleet_state._fleet_adopted``, which advances + ``_FLEET_EPOCH`` and ``_FLEET_ADOPTED_EPOCH``; both are module globals read by + every fleet build in the worker. Without this, a later test in another file that + resets only ``_FLEET_EPOCH`` sees ``_FLEET_ADOPTED_EPOCH > started`` and builds + twice. Through ``_floor_monkeypatch`` so a test's own ``monkeypatch.undo()`` + cannot lift it (testing-conventions D11). + """ + _floor_monkeypatch.setattr(fleet_state, "_FLEET_EPOCH", 0) + _floor_monkeypatch.setattr(fleet_state, "_FLEET_ADOPTED_EPOCH", 0) + _floor_monkeypatch.setattr(fleet_state, "_FLEET_TOMBSTONES", {}) + _floor_monkeypatch.setattr(fleet_state, "_FLEET_INFLIGHT", None) + _floor_monkeypatch.setattr(fleet_state, "_FLEET_CACHE", {"data": None, "ts": 0.0}) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + """A primary checkout path whose sibling lane dir genuinely does not exist.""" + # A DIRECTORY name, not prose: it mirrors the real primary checkout's own folder, + # and `worktree_path` derives the lane dir as that folder's sibling -- so + # respelling it would stop the fixture matching the layout under test. + checkout = tmp_path / "KiroCrew" # brand-ok: directory name, not prose + checkout.mkdir() + monkeypatch.setattr(repository, "_repo", lambda: str(checkout)) + monkeypatch.setattr(repository, "_UPSTREAM_REMOTE", "origin") + monkeypatch.setattr(repository, "BASE_BRANCH", "main") + # Each test owns its own lock, so a refusal in one cannot leak into the next. + monkeypatch.setattr(worktree_ops, "_WT_LOCKS", {}) + monkeypatch.setattr(repository, "_find_worktree", _missing()) + return str(checkout) + + +def _found(entry: dict): + async def _find(_name: str): + return entry, None + + return _find + + +def _missing(err: str | None = None): + async def _find(name: str): + return None, err or f"worktree not found: {name}" + + return _find + + +# -------------------------------------------------------------------------- +# create: refusals before any mutation +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_create_refuses_when_the_worktree_already_exists(repo, monkeypatch): + """Refuse, and send the operator only to controls that exist. + + This is the one collision an operator is guaranteed to reach, so the message is the + whole remedy they get. The pin does not move in place, so "move it to the tip" + would send them hunting for a control that was never shipped. + """ + monkeypatch.setattr( + repository, "_find_worktree", _found({"path": "/somewhere/release-channel-stable"}) + ) + got = await worktree_ops._release_channel_create() + assert got["ok"] is False + assert got["code"] == "exists" + assert "remove" in got["error"].lower() + + +@pytest.mark.asyncio +async def test_create_refuses_an_ambiguous_name_before_any_git_mutation(repo, monkeypatch): + """The fleet's own writer only ever creates the canonical sibling path.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + monkeypatch.setattr(repository, "_find_worktree", _missing("ambiguous worktree name")) + got = await worktree_ops._release_channel_create() + assert got["code"] == "ambiguous_name" + assert git.calls == [] + + +@pytest.mark.asyncio +async def test_create_refuses_an_occupied_path_by_name(repo, monkeypatch): + """A directory at the lane path is not ours, so it is named rather than adopted.""" + Path(release_channel_pin.worktree_path(repo)).mkdir() + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "path_exists" + assert release_channel_pin.WORKTREE_NAME in got["error"] + assert git.calls == [] + + +@pytest.mark.asyncio +async def test_create_refuses_while_another_operation_holds_the_worktrees_lock(repo, monkeypatch): + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + lock = worktree_ops._wt_lock(release_channel_pin.WORKTREE_NAME) + async with lock: + got = await worktree_ops._release_channel_create() + assert got["code"] == "busy" + assert git.calls == [] + + +@pytest.mark.asyncio +async def test_create_reports_a_failed_fetch_instead_of_resolving_locally(repo, monkeypatch): + """A stale mirror must not be mistaken for the channel's real tip.""" + git = _Git(fail={"fetch": (128, "", "fatal: could not read from remote repository")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "fetch_failed" + assert git.argv_with("worktree") is None + + +@pytest.mark.asyncio +async def test_create_fetches_before_it_resolves(repo, monkeypatch): + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + fetch_at, listing_at = git.order("fetch", "for-each-ref") + assert fetch_at < listing_at + + +@pytest.mark.asyncio +async def test_create_reports_an_empty_channel_as_unpublished(repo, monkeypatch): + git = _Git(tags=()) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "unpublished" + assert git.argv_with("worktree") is None + + +@pytest.mark.asyncio +async def test_create_refuses_a_tag_the_remote_does_not_publish(repo, monkeypatch): + """A mirror ref planted after the fetch is pruned by no fetch that already ran.""" + git = _Git(advertised={}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "provenance_mismatch" + assert "written to locally" in got["error"] + assert git.argv_with("worktree") is None + + +@pytest.mark.asyncio +async def test_create_refuses_a_published_tag_whose_local_oid_was_rewritten(repo, monkeypatch): + git = _Git(advertised={"v0.5.0": _OTHER_OID}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "provenance_mismatch" + assert git.argv_with("worktree") is None + + +@pytest.mark.asyncio +async def test_an_annotated_release_tag_passes_provenance_through_its_peel_line(repo, monkeypatch): + """The plain entry carries the TAG OBJECT; only the peel line carries the commit.""" + git = _Git(advertised={"v0.5.0": _TAG_OBJECT_OID}, peeled={"v0.5.0": _TIP_OID}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True, got + + +@pytest.mark.asyncio +async def test_create_refuses_when_the_remote_cannot_be_asked(repo, monkeypatch): + """Fails CLOSED: an unverifiable release is not checked out on the local oid.""" + git = _Git(fail={"ls-remote": (128, "", "fatal: could not read from remote repository")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "provenance_unverifiable" + assert git.argv_with("worktree") is None + + +# -------------------------------------------------------------------------- +# create: staging and adoption +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_create_stages_the_worktree_then_adopts_the_lane_path(repo, monkeypatch): + """The lane is built under a name only this call can produce, then moved into place. + + `worktree move` refuses an existing destination, which is what makes IT the step + that decides the race rather than the path-exists guard: a second writer that + reached the lane path first wins, and this call discards its own staging tree. + """ + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True, got + path = release_channel_pin.worktree_path(repo) + add = git.argv_with("add") + assert add is not None + staging = add[-2] + assert staging.startswith(f"{path}.staging.") + assert str(os.getpid()) in staging + assert "--no-checkout" in add and "--detach" in add + assert add[-1] == _TIP_OID + move = git.argv_with("move") + assert move is not None + assert move[-2:] == [staging, path] + add_at, move_at = git.order("add", "move") + assert add_at < move_at + + +@pytest.mark.asyncio +async def test_two_calls_never_stage_under_the_same_name(repo): + path = release_channel_pin.worktree_path(repo) + first = worktree_ops._lane_staging_path(path) + second = worktree_ops._lane_staging_path(path) + assert first != second + assert Path(first).parent == Path(path).parent + + +@pytest.mark.asyncio +async def test_the_successful_result_names_only_what_a_caller_reads(repo, monkeypatch): + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert set(got) == {"ok", "lane", "name", "ref", "version", "path"} + assert got["name"] == release_channel_pin.WORKTREE_NAME + assert got["ref"] == f"{release_channel_pin._TAG_NS}/v0.5.0" + assert got["version"] == "0.5.0" + + +@pytest.mark.asyncio +async def test_a_failed_create_never_force_removes_the_lane_path(repo, monkeypatch): + """The cleanup may only ever name a staging path this call produced. + + Aimed at the lane path it would infer ownership from the earlier existence check, + and a second gateway sharing the repo invalidates that inference -- the cleanup + would then delete THEIR populated worktree with no reflog to recover from. + """ + git = _Git(fail={"add": (128, "", "fatal: could not create work tree dir")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + path = release_channel_pin.worktree_path(repo) + removes = [c for c in git.calls if "remove" in c] + assert removes, git.calls + for call in removes: + assert call[-1].startswith(f"{path}.staging.") + assert call[-1] != path + assert not any("prune" in c for c in git.calls), git.calls + + +@pytest.mark.asyncio +async def test_a_failed_create_does_not_leave_the_lane_uncreatable(repo, monkeypatch): + """`worktree add` can fail after registering the record, so the record is dropped.""" + git = _Git(fail={"add": (128, "", "fatal: boom")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + remove = git.argv_with("remove") + assert remove is not None + assert "--force" in remove + + +@pytest.mark.asyncio +async def test_a_failed_move_reports_the_collision_it_lost(repo, monkeypatch): + path = release_channel_pin.worktree_path(repo) + + async def git(cmd, **kw): + if "move" in cmd: + Path(path).mkdir(exist_ok=True) + return 128, "", "fatal: target already exists" + return await base(cmd, **kw) + + base = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "path_exists" + assert base.argv_with("remove") is not None + + +@pytest.mark.asyncio +async def test_a_directory_that_appeared_at_the_lane_path_is_refused_before_the_move( + repo, monkeypatch +): + """`worktree move ` nests INTO the directory, exit 0 (mv(1) + semantics, measured on git 2.54). So the destination is re-checked under the lock + right before the move: a directory that appeared during the fetch, resolve and + populate is a collision, and no move is attempted against it.""" + path = release_channel_pin.worktree_path(repo) + base = _Git() + + async def git(cmd, **kw): + if "read-tree" in cmd: + # Another writer lands at the lane path while we populate our staging tree. + Path(path).mkdir(exist_ok=True) + return await base(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "path_exists" + assert base.argv_with("move") is None + removed = base.argv_with("remove") + assert removed is not None and removed[-1].startswith(f"{path}.staging."), removed + + +@pytest.mark.asyncio +async def test_a_move_that_nested_into_a_foreign_directory_is_undone_and_reported( + repo, monkeypatch +): + """The check no lock can replace: a directory created by ANOTHER process between the + pre-move check and the move makes git nest our tree inside it and exit 0. The + landing is verified by asking git which admin dir the worktree at the lane path + uses; a foreign answer means the nested tree -- ours by construction -- is removed + by ITS path, never the lane path, and the collision is reported.""" + path = release_channel_pin.worktree_path(repo) + base = _Git() + staging_seen: list[str] = [] + + async def git(cmd, **kw): + if "move" in cmd: + staging_seen.append(cmd[-2]) + if "rev-parse" in cmd and "--absolute-git-dir" in cmd and cmd[2] == path: + return 0, "/elsewhere/.git/worktrees/theirs\n", "" + return await base(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "path_exists" + assert staging_seen, "the move itself must have run for the landing to be checked" + removed = base.argv_with("remove") + assert removed is not None + assert removed[-1] == os.path.join(path, os.path.basename(staging_seen[0])), removed + assert removed[-1] != path + + +@pytest.mark.asyncio +async def test_a_landed_move_is_verified_by_its_own_admin_dir(repo, monkeypatch): + """The happy path asks the same question and gets the staging worktree's answer.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True + path = release_channel_pin.worktree_path(repo) + landed = [c for c in git.calls if "--absolute-git-dir" in c and c[2] == path] + assert len(landed) == 1, git.calls + assert git.argv_with("remove") is None + + +@pytest.mark.asyncio +async def test_a_successful_create_names_the_lane_the_toast_reads(repo, monkeypatch): + """The page's success toast interpolates `lane`; a reply without it renders + "Created worktree" with the channel missing.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True + assert got["lane"] == release_channel_pin.CHANNEL + assert set(got) == {"ok", "lane", "name", "ref", "version", "path"} + + +@pytest.mark.asyncio +async def test_a_successful_create_marks_the_fleet_adopted(repo, monkeypatch): + """So a rebuild already in flight cannot publish a snapshot without the new row.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + marks: list[int] = [] + monkeypatch.setattr(fleet_state, "_fleet_adopted", lambda: marks.append(1)) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True + assert marks == [1] + + +@pytest.mark.asyncio +async def test_a_refused_create_never_marks_the_fleet_adopted(repo, monkeypatch): + git = _Git(staged_head=_OTHER_OID) + monkeypatch.setattr(runtime, "_run_cmd", git) + marks: list[int] = [] + monkeypatch.setattr(fleet_state, "_fleet_adopted", lambda: marks.append(1)) + got = await worktree_ops._release_channel_create() + assert got["ok"] is False + assert marks == [] + + +@pytest.mark.asyncio +async def test_a_fleet_build_that_predates_an_adoption_reads_git_again(monkeypatch): + """The `fresh=1` the page sends after Create coalesces onto whatever build is in + flight. A build that started before the adoption read git before the worktree + existed; publishing its snapshot would show the placeholder the toast just said + was replaced. The adoption epoch makes that build re-read, once.""" + builds: list[int] = [] + + async def build(): + builds.append(len(builds) + 1) + if len(builds) == 1: + fleet_state._fleet_adopted() # lands while the first read is in flight + return {"worktrees": [], "release_channel": {"worktree": None}} + return {"worktrees": [{"name": "release-channel-stable"}], "release_channel": {}} + + monkeypatch.setattr(fleet_state, "_build_fleet", build) + monkeypatch.setattr(fleet_state, "_FLEET_CACHE", {"data": None, "ts": 0.0}) + data = await fleet_state._fleet_build() + assert builds == [1, 2] + assert data["worktrees"] == [{"name": "release-channel-stable"}] + assert fleet_state._FLEET_CACHE["data"] is data + + +@pytest.mark.asyncio +async def test_a_fleet_build_with_no_adoption_reads_git_once(monkeypatch): + builds: list[int] = [] + + async def build(): + builds.append(1) + return {"worktrees": []} + + monkeypatch.setattr(fleet_state, "_build_fleet", build) + monkeypatch.setattr(fleet_state, "_FLEET_CACHE", {"data": None, "ts": 0.0}) + await fleet_state._fleet_build() + assert builds == [1] + + +@pytest.mark.asyncio +async def test_an_adoption_zeroes_the_cache_timestamp_so_a_cached_reader_rebuilds(monkeypatch): + monkeypatch.setattr(fleet_state, "_FLEET_CACHE", {"data": {"worktrees": []}, "ts": 1e12}) + before = fleet_state._FLEET_EPOCH + fleet_state._fleet_adopted() + assert fleet_state._FLEET_CACHE["ts"] == 0.0 + assert fleet_state._FLEET_ADOPTED_EPOCH == fleet_state._FLEET_EPOCH == before + 1 + + +def test_error_detail_strips_scrubs_and_bounds_a_git_stderr(monkeypatch): + """The one spelling of git-stderr-into-a-result, applied where it is classified.""" + monkeypatch.setattr(runtime, "_redact", lambda text: text.replace("hunter2", "***")) + assert runtime._error_detail(" password hunter2 leaked\n") == "password *** leaked" + assert runtime._error_detail(None) == "" + assert runtime._error_detail("x" * 500) == "x" * 200 + + +def test_the_release_owners_carry_no_redactor_of_their_own(): + """The classification guard (`test_security_posture`) walks for redactor call sites + by name; these two modules carry none. Git stderr is scrubbed by `runtime._error_detail` + (the module classified for that path), and every other string in a result -- a path, + a ref -- is scrubbed by the route that serves the run record, `api_dev_fleet_run`, + where it leaves the process. Pinned so a later edit that calls a redactor here knows + it is adding a classification, not just a line.""" + import inspect + import re + + from kiro_crew.apps.builtins.dev_fleet import worktree_ops_release + + call = re.compile(r"\b\w*redact\w*\(") + for module in (release_channel_pin, worktree_ops_release): + assert not call.search(inspect.getsource(module)), module.__name__ + + +@pytest.mark.asyncio +async def test_a_staged_tree_not_detached_at_the_release_is_discarded(repo, monkeypatch): + """The read-back is what makes "detached at the verified oid" a checked fact.""" + git = _Git(staged_head=_OTHER_OID) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + assert "not detached at the resolved release" in got["error"] + assert git.argv_with("move") is None + assert git.argv_with("remove") is not None + + +@pytest.mark.asyncio +async def test_a_cancelled_create_discards_the_staging_tree_on_the_way_out(repo, monkeypatch): + """A return is not the only exit: an ordinary shutdown unwinds this frame.""" + git = _Git() + seen: list[list[str]] = [] + + async def cancelling(cmd, **kw): + seen.append(list(cmd)) + if "read-tree" in cmd: + raise asyncio.CancelledError + return await git(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", cancelling) + with pytest.raises(asyncio.CancelledError): + await worktree_ops._release_channel_create() + assert any("remove" in c for c in seen), seen + + +@pytest.mark.asyncio +async def test_every_create_mutation_runs_at_the_repo_controlled_tier(repo, monkeypatch): + """The checkout acts on repo-controlled content, so it gets no credential helper.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + for call, mode in zip(git.calls, git.modes): + if "worktree" in call or "read-tree" in call: + assert mode == "strict", call + + +@pytest.mark.asyncio +async def test_the_remote_facing_calls_keep_their_credentials(repo, monkeypatch): + """The fetch and the provenance query authenticate, so they stay at the standard tier.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + for call, mode in zip(git.calls, git.modes): + if "fetch" in call or "ls-remote" in call: + assert mode == "standard", call + + +# -------------------------------------------------------------------------- +# create: the hermetic populate +# -------------------------------------------------------------------------- +def _populate_env(git: _Git) -> dict: + kw = git.kwargs_with("read-tree") + assert kw is not None, git.calls + return dict(kw["env"]) + + +@pytest.mark.asyncio +async def test_the_populate_is_one_read_tree_and_never_a_reset(repo, monkeypatch): + """`read-tree -u --reset` updates index and tree and leaves HEAD where it is. + + The read-back of HEAD is what confirms the detached pin, so a populate that moved + HEAD would make that check measure its own write. + """ + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + populate = git.argv_with("read-tree") + assert populate == ["git", "read-tree", "-u", "--reset", _TIP_OID] + assert not any("--hard" in c for c in git.calls), git.calls + + +@pytest.mark.asyncio +async def test_the_populate_spawn_carries_its_own_git_location(repo, monkeypatch): + """Three variables decide what the checkout reads and where it writes. + + ``GIT_COMMON_DIR`` is the one that matters: it points config and + ``info/attributes`` at files this process just created, so the repository's own + ``.git/config`` is never opened by this spawn and no write to it, at any instant, + can reach it. + """ + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + env = _populate_env(git) + staging = (git.argv_with("add") or [])[-2] + assert env["GIT_DIR"] == "/fake/gitdir" + assert env["GIT_WORK_TREE"] == staging + assert env["GIT_COMMON_DIR"] and env["GIT_COMMON_DIR"] != "/fake/gitdir" + assert env["GIT_CONFIG_NOSYSTEM"] == "1" + assert env["GIT_CONFIG_GLOBAL"] == os.devnull + assert git.kwargs_with("read-tree")["cwd"] == staging + + +@pytest.mark.asyncio +async def test_the_populate_env_inherits_no_other_git_variable(repo, monkeypatch): + """A variable that must be ABSENT cannot be handled by merging over the environment. + + An inherited ``GIT_INDEX_FILE``, ``GIT_OBJECT_DIRECTORY``, + ``GIT_ALTERNATE_OBJECT_DIRECTORIES`` or ``GIT_NAMESPACE`` would survive an update() + and silently retarget this write. + """ + monkeypatch.setenv("GIT_INDEX_FILE", "/tmp/stolen-index") + monkeypatch.setenv("GIT_ALTERNATE_OBJECT_DIRECTORIES", "/tmp/stolen-objects") + monkeypatch.setenv("GIT_NAMESPACE", "stolen") + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + await worktree_ops._release_channel_create() + env = _populate_env(git) + supplied = { + "GIT_DIR", + "GIT_COMMON_DIR", + "GIT_WORK_TREE", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_GLOBAL", + } + assert {k for k in env if k.startswith("GIT_")} == supplied + + +@pytest.mark.asyncio +async def test_the_private_common_dir_is_a_minimal_repository_pointing_at_the_real_objects( + repo, monkeypatch +): + """Every object must resolve while every config scope must not. + + The contents are captured while the spawn is in flight, because the directory is + deleted the moment the populate returns. + """ + git = _Git() + captured: dict[str, str] = {} + + async def capturing(cmd, **kw): + if "read-tree" in cmd: + common = Path(kw["env"]["GIT_COMMON_DIR"]) + captured["mode"] = oct(common.stat().st_mode & 0o777) + captured["config"] = (common / "config").read_text(encoding="utf-8") + captured["head"] = (common / "HEAD").read_text(encoding="utf-8") + captured["alternates"] = (common / "objects" / "info" / "alternates").read_text( + encoding="utf-8" + ) + captured["refs"] = str((common / "refs").is_dir()) + captured["attributes"] = str((common / "info" / "attributes").exists()) + return await git(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", capturing) + await worktree_ops._release_channel_create() + # POSIX access bits only: Windows `stat()` reports 0o777 for every directory, + # so the mode assertion is a POSIX fact; the content assertions hold everywhere. + if os.name == "posix": + assert captured["mode"] == "0o700" + assert "bare = false" in captured["config"] + assert "filter" not in captured["config"] + assert captured["head"].startswith("ref: ") + assert captured["alternates"].strip() == os.path.realpath(os.path.join(repo, ".git", "objects")) + assert captured["refs"] == "True" + assert captured["attributes"] == "False" + + +@pytest.mark.asyncio +async def test_the_private_common_dir_is_deleted_after_a_successful_populate(repo, monkeypatch): + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["ok"] is True, got + assert not Path(_populate_env(git)["GIT_COMMON_DIR"]).exists() + + +@pytest.mark.asyncio +async def test_the_private_common_dir_is_deleted_after_a_failed_populate(repo, monkeypatch): + git = _Git(fail={"read-tree": (128, "", "fatal: unable to write new index file")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + assert "unable to write new index file" in got["error"] + assert not Path(_populate_env(git)["GIT_COMMON_DIR"]).exists() + assert git.argv_with("move") is None + assert git.argv_with("remove") is not None + + +@pytest.mark.asyncio +async def test_the_private_common_dir_is_deleted_when_the_populate_is_cancelled(repo, monkeypatch): + git = _Git() + seen: dict[str, str] = {} + + async def cancelling(cmd, **kw): + if "read-tree" in cmd: + seen["common"] = kw["env"]["GIT_COMMON_DIR"] + raise asyncio.CancelledError + return await git(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", cancelling) + with pytest.raises(asyncio.CancelledError): + await worktree_ops._release_channel_create() + assert not Path(seen["common"]).exists() + + +@pytest.mark.asyncio +async def test_an_unstatable_git_dir_refuses_the_populate(repo, monkeypatch): + """A populate whose index location had to be guessed must not run.""" + git = _Git(fail={"--absolute-git-dir": (128, "", "fatal: not a git repository")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + assert git.argv_with("read-tree") is None + assert git.argv_with("move") is None + + +@pytest.mark.asyncio +async def test_an_unstatable_object_store_refuses_the_populate(repo, monkeypatch): + git = _Git(fail={"--git-common-dir": (128, "", "fatal: not a git repository")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + assert git.argv_with("read-tree") is None + + +@pytest.mark.asyncio +async def test_the_populate_error_is_redacted_before_it_reaches_the_page(repo, monkeypatch): + secret = "https://x-access-token:ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB@github.com/o/r" + git = _Git(fail={"read-tree": (128, "", f"fatal: {secret}")}) + monkeypatch.setattr(runtime, "_run_cmd", git) + got = await worktree_ops._release_channel_create() + assert "ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" not in got["error"] + + +# -------------------------------------------------------------------------- +# the hermetic populate, against real git +# -------------------------------------------------------------------------- +def _fixture_git_env(home: Path) -> dict[str, str]: + """Env for the fixture's own git calls: no ambient config, templates or identity. + + The attack under test is planted in the fixture repo's LOCAL config on purpose, so + the operator's global and system config are pointed at ``os.devnull`` and + ``init.templateDir`` is emptied -- otherwise an inherited template's hooks would be + copied into the fixture repo and executed by its commit, which is a host-side effect + of running the suite and would also muddy what this test proves. + + Pointing the global config at ``os.devnull`` also drops the suite's maintenance + floor, so the entries that keep git's auto maintenance off are re-applied last. + """ + env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": str(home), + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "GIT_TEMPLATE_DIR": "", + "GIT_TERMINAL_PROMPT": "0", + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + } + env.update(git_maintenance_off_entries(env)) + return env + + +async def _spawn(argv: list[str], *, cwd: str, env: dict[str, str]) -> tuple[int, str, str]: + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=cwd, + env=env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + # Bounded, so a stalled git child fails this test by name instead of hanging the + # xdist worker until the suite watchdog kills it (and every test sharing the + # worker with it). Half the suite's --timeout=120. + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=60) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise AssertionError(f"git did not finish within 60s: {argv}") from None + return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace") + + +@pytest.mark.asyncio +@requires_git +async def test_the_hermetic_populate_runs_no_repo_declared_filter_and_writes_only_into_staging( + tmp_path, monkeypatch +): + """Both arms, against real git, from one fixture repository. + + The repository's own ``.git/config`` declares a REQUIRED ``filter.evil.smudge`` + whose command writes a marker file, and a ``core.worktree`` pointing outside the + worktree; the committed tree carries a ``.gitattributes`` naming that filter for the + file being checked out. An ordinary populate reads that config: the marker appears + and the file lands outside. The production populate reads a private common dir + instead: no marker, and the file lands in the staging path alone. + + The two arms share one repository so neither can pass by accident -- a fixture that + failed to arm the attack would fail the control arm first. + """ + home = tmp_path / "home" + home.mkdir() + env = _fixture_git_env(home) + primary = tmp_path / "KiroCrew" # brand-ok: directory name, not prose + primary.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + marker = tmp_path / "filter-ran" + (primary / "payload.txt").write_text("release payload\n", encoding="utf-8") + (primary / ".gitattributes").write_text("payload.txt filter=evil\n", encoding="utf-8") + for argv in ( + ["git", "init", "-q"], + ["git", "symbolic-ref", "HEAD", "refs/heads/main"], + ["git", "add", "payload.txt", ".gitattributes"], + ["git", "commit", "-q", "-m", "one"], + # The attack: an arbitrary command git EXECUTES on every file it writes, named + # by a key whose `` is the config author's choice -- which is why no fixed + # set of neutralizer keys can disarm it. + # git runs the driver through `sh -c` itself, so no nested shell; the marker + # path is POSIX-spelled and shell-quoted so a Windows backslash path, or one + # with a space, reaches the redirection intact. + [ + "git", + "config", + "--local", + "filter.evil.smudge", + f"printf ran > {shlex.quote(marker.as_posix())}; cat", + ], + ["git", "config", "--local", "filter.evil.required", "true"], + ["git", "config", "--local", "extensions.worktreeConfig", "true"], + ["git", "config", "--local", "core.worktree", str(outside)], + ): + rc, _out, err = await _spawn(argv, cwd=str(primary), env=env) + assert rc == 0, f"fixture setup failed: {argv} -> {err}" + rc, out, err = await _spawn(["git", "rev-parse", "HEAD"], cwd=str(primary), env=env) + assert rc == 0, err + oid = out.strip() + + # --- control: an ordinary populate reads the repository's own config --- + control = tmp_path / "release-channel-stable.staging.control" + rc, _out, err = await _spawn( + ["git", "worktree", "add", "--no-checkout", "--detach", str(control), oid], + cwd=str(primary), + env=env, + ) + assert rc == 0, err + rc, _out, err = await _spawn( + ["git", "read-tree", "-u", "--reset", oid], cwd=str(control), env=env + ) + assert rc == 0, err + assert marker.exists(), "the fixture never armed the attack: the control filter did not run" + assert (outside / "payload.txt").exists(), "the fixture never armed the core.worktree redirect" + assert not (control / "payload.txt").exists() + marker.unlink() + (outside / "payload.txt").unlink() + (outside / ".gitattributes").unlink() + + # --- the production populate, on the same armed repository --- + async def real_git(cmd, *, cwd=None, env=None, **kw): + base = dict(_fixture_git_env(home)) + base.update(env or {}) + return await _spawn(list(cmd), cwd=cwd or str(primary), env=base) + + monkeypatch.setattr(runtime, "_run_cmd", real_git) + staging = tmp_path / "release-channel-stable.staging.hermetic" + rc, _out, err = await _spawn( + ["git", "worktree", "add", "--no-checkout", "--detach", str(staging), oid], + cwd=str(primary), + env=env, + ) + assert rc == 0, err + + assert await worktree_ops._populate_release_tree(str(primary), str(staging), oid) is None + + assert not marker.exists(), "the repository's smudge filter was executed" + assert not (outside / "payload.txt").exists(), "the populate wrote outside the worktree" + assert (staging / "payload.txt").read_text(encoding="utf-8") == "release payload\n" + assert (staging / ".gitattributes").exists() + # HEAD is untouched and the tree is still detached: the populate writes files, the + # `worktree add --detach` above is what pinned the release. + rc, out, err = await _spawn(["git", "rev-parse", "HEAD"], cwd=str(staging), env=env) + assert rc == 0 and out.strip() == oid, err + rc, _out, _err = await _spawn( + ["git", "symbolic-ref", "--quiet", "HEAD"], cwd=str(staging), env=env + ) + assert rc != 0, "the populated staging worktree must remain detached" + # Nothing of this call's own is left beside the operator's checkouts. + leftovers = [p.name for p in tmp_path.iterdir() if p.name.startswith(".release-channel-")] + assert leftovers == [] + staged_leftovers = [p.name for p in staging.parent.iterdir() if "gitcommon" in p.name] + assert staged_leftovers == [] + + +@pytest.mark.asyncio +async def test_the_hermetic_populate_reads_a_sha256_store_as_sha256(tmp_path, monkeypatch): + """The private common dir declares the real store's object format. + + A common dir with no ``extensions.objectformat`` is read as sha1, so against a + sha256 repository every 64-digit oid is "not a valid object name" and the + populate can never succeed. Real git, both arms from one repository: the control + is the sha1-shaped config this test's fixture would have written before. + """ + home = tmp_path / "home" + home.mkdir() + env = _fixture_git_env(home) + primary = tmp_path / "KiroCrew" # brand-ok: directory name, not prose + primary.mkdir() + (primary / "payload.txt").write_text("sha256 payload\n", encoding="utf-8") + for argv in ( + ["git", "init", "-q", "--object-format=sha256"], + ["git", "symbolic-ref", "HEAD", "refs/heads/main"], + ["git", "add", "payload.txt"], + ["git", "commit", "-q", "-m", "one"], + ): + rc, _out, err = await _spawn(argv, cwd=str(primary), env=env) + assert rc == 0, f"fixture setup failed: {argv} -> {err}" + rc, out, err = await _spawn(["git", "rev-parse", "HEAD"], cwd=str(primary), env=env) + assert rc == 0, err + oid = out.strip() + assert len(oid) == 64, "the fixture is not a sha256 repository" + + async def real_git(cmd, *, cwd=None, env=None, **kw): + base = dict(_fixture_git_env(home)) + base.update(env or {}) + return await _spawn(list(cmd), cwd=cwd or str(primary), env=base) + + monkeypatch.setattr(runtime, "_run_cmd", real_git) + staging = tmp_path / "release-channel-stable.staging.sha256" + rc, _out, err = await _spawn( + ["git", "worktree", "add", "--no-checkout", "--detach", str(staging), oid], + cwd=str(primary), + env=env, + ) + assert rc == 0, err + # Control: the sha1-shaped config cannot read this store at all. + assert "objectformat" not in worktree_ops._HERMETIC_COMMON_CONFIG + assert "objectformat = sha256" in worktree_ops._hermetic_common_config("sha256") + assert worktree_ops._hermetic_common_config("sha1") == worktree_ops._HERMETIC_COMMON_CONFIG + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + worktree_ops, + "_hermetic_common_config", + lambda _fmt: worktree_ops._HERMETIC_COMMON_CONFIG, + ) + control = await worktree_ops._populate_release_tree(str(primary), str(staging), oid) + assert control is not None and "valid object" in control, control + assert not (staging / "payload.txt").exists() + # The production populate, format declared: the tree lands. + assert await worktree_ops._populate_release_tree(str(primary), str(staging), oid) is None + assert (staging / "payload.txt").read_text(encoding="utf-8") == "sha256 payload\n" + staged_leftovers = [p.name for p in staging.parent.iterdir() if "gitcommon" in p.name] + assert staged_leftovers == [] + + +@pytest.mark.asyncio +async def test_create_refuses_a_partial_clone_before_it_fetches_or_touches_disk( + tmp_path, monkeypatch +): + """A ``--filter`` clone names a promisor remote; the populate cannot lazily fetch. + + Real git: a ``blob:none`` clone of a local source. The refusal is decided before + the fetch, the ``worktree add`` and the populate, and it is a classified refusal + (4xx), not ``git_failed``. + """ + home = tmp_path / "home" + home.mkdir() + env = _fixture_git_env(home) + source = tmp_path / "source" + source.mkdir() + (source / "payload.txt").write_text("payload\n", encoding="utf-8") + for argv in ( + ["git", "init", "-q"], + ["git", "symbolic-ref", "HEAD", "refs/heads/main"], + ["git", "add", "payload.txt"], + ["git", "commit", "-q", "-m", "one"], + ["git", "config", "--local", "uploadpack.allowFilter", "true"], + ): + rc, _out, err = await _spawn(argv, cwd=str(source), env=env) + assert rc == 0, f"fixture setup failed: {argv} -> {err}" + partial = tmp_path / "KiroCrew" # brand-ok: directory name, not prose + rc, _out, err = await _spawn( + ["git", "clone", "-q", "--filter=blob:none", source.as_uri(), str(partial)], + cwd=str(tmp_path), + env=env, + ) + assert rc == 0, err + rc, out, _err = await _spawn( + ["git", "config", "--get-regexp", worktree_ops._PARTIAL_CLONE_KEYS], + cwd=str(partial), + env=env, + ) + assert rc == 0 and out.strip(), "the fixture is not a partial clone" + + spawned: list[list[str]] = [] + + async def real_git(cmd, *, cwd=None, env=None, **kw): + spawned.append(list(cmd)) + base = dict(_fixture_git_env(home)) + base.update(env or {}) + return await _spawn(list(cmd), cwd=cwd or str(partial), env=base) + + monkeypatch.setattr(runtime, "_run_cmd", real_git) + assert await worktree_ops._is_partial_clone(str(partial)) is True + assert await worktree_ops._is_partial_clone(str(source)) is False + spawned.clear() + got = await worktree_ops._release_channel_create_locked( + str(partial), release_channel_pin.WORKTREE_NAME, str(tmp_path / "release-channel-stable") + ) + assert got["code"] == "partial_clone" + assert "partial clone" in got["error"] and "full clone" in got["error"] + # The remedy must clear the very setting this refusal reads, or an operator who + # follows it is refused again: `--refetch` leaves `promisor = true` behind. + assert "git config --unset remote..promisor" in got["error"] + assert got["error"].index("partialclonefilter") < got["error"].index("--refetch") + assert got["error"].index("--refetch") < got["error"].index("remote..promisor`") + assert got["code"] in worktree_ops._RELEASE_CHANNEL_REFUSALS + # Nothing but the config read ran: no fetch, no worktree add, no populate. + assert [c for c in spawned if "fetch" in c or "worktree" in c or "read-tree" in c] == [] + assert not (tmp_path / "release-channel-stable").exists() + # The remedy the refusal names, run verbatim against this clone, clears the + # refusal: no marker in force and no object missing. A remedy that left + # `promisor = true` behind would loop the operator back to this same code. + for argv in ( + ["git", "config", "--unset", "remote.origin.partialclonefilter"], + ["git", "fetch", "-q", "--refetch", "origin"], + ["git", "config", "--unset", "remote.origin.promisor"], + ): + rc, _out, err = await _spawn(argv, cwd=str(partial), env=env) + assert rc == 0, f"{argv} -> {err}" + assert await worktree_ops._is_partial_clone(str(partial)) is False + rc, out, _err = await _spawn( + ["git", "rev-list", "--objects", "--no-walk", "--missing=print", "HEAD"], + cwd=str(partial), + env=env, + ) + assert rc == 0 and not [line for line in out.splitlines() if line.startswith("?")] + + +@pytest.mark.asyncio +async def test_a_full_clone_is_not_mistaken_for_a_partial_one(repo, monkeypatch): + """The probe answers False on exit 1 (no match) AND on a config that cannot be read.""" + git = _Git() + monkeypatch.setattr(runtime, "_run_cmd", git) + assert await worktree_ops._is_partial_clone(repo) is False + probe = git.argv_with("--get-regexp") + assert probe is not None and probe[-1] == worktree_ops._PARTIAL_CLONE_KEYS + assert git.kwargs_with("--get-regexp")["mode"] == "strict" + unreadable = _Git(fail={"--get-regexp": (128, "", "fatal: bad config")}) + monkeypatch.setattr(runtime, "_run_cmd", unreadable) + assert await worktree_ops._is_partial_clone(repo) is False + + +@pytest.mark.asyncio +async def test_a_hydrated_clone_with_promisor_false_is_not_partial(repo, monkeypatch): + """The marker is read by VALUE: ``remote.origin.promisor = false`` stays behind after + an operator drops the clone filter and runs ``git fetch --refetch``, and that checkout + needs no lazy fetch. A key's presence is not the condition; its boolean is.""" + hydrated = _Git(fail={"--get-regexp": (0, "remote.origin.promisor false\n", "")}) + monkeypatch.setattr(runtime, "_run_cmd", hydrated) + assert await worktree_ops._is_partial_clone(repo) is False + # Every spelling git accepts as true, and a bare key with no value (true in git). + for value in ("true", "True", "yes", "on", "1", ""): + line = f"remote.origin.promisor {value}".rstrip() + assert worktree_ops._promisor_in_force(line + "\n") is True, value + for value in ("false", "no", "off", "0"): + assert worktree_ops._promisor_in_force(f"remote.origin.promisor {value}\n") is False, value + # The legacy extension holds a remote NAME, so any value means partial. + assert worktree_ops._promisor_in_force("extensions.partialclone origin\n") is True + # Several remotes: one in force is enough; none in force is a full clone. + assert ( + worktree_ops._promisor_in_force("remote.origin.promisor false\nremote.up.promisor true\n") + is True + ) + assert ( + worktree_ops._promisor_in_force("remote.origin.promisor false\nremote.up.promisor false\n") + is False + ) + assert worktree_ops._promisor_in_force("") is False + # And through the real create: a hydrated clone is NOT refused as partial. + monkeypatch.setattr(runtime, "_run_cmd", hydrated) + got = await worktree_ops._release_channel_create() + assert got.get("code") != "partial_clone", got + + +@pytest.mark.asyncio +async def test_the_populate_declares_the_stores_object_format_to_the_private_common_dir( + repo, monkeypatch +): + """The format is read from the real store and written, never assumed.""" + captured: dict[str, str] = {} + git = _Git() + + async def sha256_store(cmd, **kw): + if "rev-parse" in cmd and "--show-object-format" in cmd: + return 0, "sha256\n", "" + if "read-tree" in cmd: + common = Path(kw["env"]["GIT_COMMON_DIR"]) + captured["config"] = (common / "config").read_text(encoding="utf-8") + return await git(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", sha256_store) + await worktree_ops._release_channel_create() + assert "repositoryformatversion = 1" in captured["config"] + assert "objectformat = sha256" in captured["config"] + assert "bare = false" in captured["config"] + # A format git will not state refuses the populate rather than guessing one. + unknown = _Git(fail={"--show-object-format": (0, "weird\n", "")}) + monkeypatch.setattr(runtime, "_run_cmd", unknown) + got = await worktree_ops._release_channel_create() + assert got["code"] == "git_failed" + assert "object format" in got["error"] + assert unknown.argv_with("read-tree") is None + + +# -------------------------------------------------------------------------- +# the fleet payload +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_fleet_publishes_a_channel_with_no_worktree_as_a_placeholder(repo, monkeypatch): + """With no worktree there is no path, so this is not an extra ``worktrees`` row.""" + monkeypatch.setattr(runtime, "_run_cmd", _Git()) + row = await fleet_state_release_channel._release_channel([]) + assert row is not None + assert row["worktree"] is None + assert row["name"] == release_channel_pin.WORKTREE_NAME + assert row["lane"] == release_channel_pin.CHANNEL + assert row["ref"] == f"{release_channel_pin._TAG_NS}/v0.5.0" + assert row["version"] == "0.5.0" + assert row["tip_version"] == "0.5.0" + assert row["at_tip"] is None + assert row["unpublished"] is False + assert row["error"] is None + assert row["name_taken_by_branch"] is False + + +@pytest.mark.asyncio +async def test_fleet_adopts_a_detached_worktree_and_reports_its_currency(repo, monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _Git(head=_OTHER_OID, head_tag="v0.4.1")) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] == release_channel_pin.WORKTREE_NAME + # The tree's OWN release, not the lane tip: a row showing the tip would rename the + # checkout to a build it does not contain the moment a newer release ships. + assert row["version"] == "0.4.1" + assert row["tip_version"] == "0.5.0" + assert row["at_tip"] is False + + +@pytest.mark.asyncio +async def test_a_tree_at_the_tip_reports_at_tip(repo, monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _Git(head=_TIP_OID)) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["at_tip"] is True + assert row["version"] == "0.5.0" + + +@pytest.mark.asyncio +async def test_an_adopted_row_on_no_release_tag_reports_no_version(repo, monkeypatch): + """Adoption is for being detached; an operator at an arbitrary commit is on no release.""" + monkeypatch.setattr(runtime, "_run_cmd", _Git(head=_OTHER_OID, head_tag="")) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] == release_channel_pin.WORKTREE_NAME + assert row["version"] is None + + +@pytest.mark.asyncio +async def test_an_adopted_row_keeps_its_own_release_when_the_tip_lookup_fails(repo, monkeypatch): + """The tree's release is a local fact; a failed resolve is no reason to withhold it.""" + git = _Git(head=_OTHER_OID, head_tag="v0.4.1") + + async def failing(cmd, **kw): + if "for-each-ref" in cmd and "--points-at" not in cmd: + return 128, "", "fatal: not a git repository" + return await git(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", failing) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] == release_channel_pin.WORKTREE_NAME + assert row["version"] == "0.4.1" + assert row["tip_version"] is None + # With no tip there is no answer, and the row says so rather than guessing. + assert row["at_tip"] is None + assert row["error"] + + +@pytest.mark.asyncio +async def test_fleet_does_not_adopt_a_branch_checkout_that_shares_the_name(repo, monkeypatch): + """A user's own branch tree under the reserved name must never be driven as a pin.""" + + async def on_a_branch(cmd, **kw): + if "symbolic-ref" in cmd: + return 0, "refs/heads/main\n", "" + return await _Git(head=_OTHER_OID)(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", on_a_branch) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] is None + assert row["name_taken_by_branch"] is True + + +@pytest.mark.asyncio +async def test_fleet_does_not_claim_a_branch_when_the_probe_could_not_read_head(repo, monkeypatch): + """Three states, not two: an unreadable HEAD is neither adopted nor called a branch.""" + monkeypatch.setattr(runtime, "_run_cmd", _Git(head="")) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] is None + assert row["name_taken_by_branch"] is False + assert "could not be read" in row["error"] + + +@pytest.mark.asyncio +async def test_fleet_adopts_nothing_when_two_checkouts_share_the_reserved_name(repo, monkeypatch): + """Two registered trees named for the channel: neither is the pin, and the row says so. + + Adopting one would dress the other -- a branch checkout listed first, say -- in the + adopted tree's version badge and take its Rebase away, because the page binds + channel treatment to rows by name. The error is the surface an unadopted row that + exists in the fleet has, and the name being present is what keeps Create off. + """ + git = _Git(head=_OTHER_OID, head_tag="v0.4.1") + monkeypatch.setattr(runtime, "_run_cmd", git) + name = release_channel_pin.WORKTREE_NAME + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{name}", "branch": "main"}, {"path": f"/elsewhere/{name}"}] + ) + assert row is not None + assert row["worktree"] is None + assert row["name_taken_by_branch"] is False + assert row["at_tip"] is None + assert row["version"] == "0.5.0" # the tip Create would check out, not either tree's + assert "2 registered worktrees are named" in row["error"] + assert "can drive only one" in row["error"] + # Neither tree is probed: there is no candidate to classify, so no HEAD is read. + touched = [c for c in git.calls if any(f"/{name}" in str(a) for a in c)] + assert touched == [] + + +@pytest.mark.asyncio +async def test_fleet_ignores_the_main_checkout_and_a_pathless_entry(repo, monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _Git()) + row = await fleet_state_release_channel._release_channel( + [ + {"path": f"/x/{release_channel_pin.WORKTREE_NAME}", "is_main": True}, + {"path": None}, + ] + ) + assert row is not None + assert row["worktree"] is None + + +@pytest.mark.asyncio +async def test_fleet_flags_an_empty_channel_as_unpublished_not_an_error(repo, monkeypatch): + """Create stays enabled: the resolver's own fetch is what fills the mirror.""" + monkeypatch.setattr(runtime, "_run_cmd", _Git(tags=())) + row = await fleet_state_release_channel._release_channel([]) + assert row is not None + assert row["unpublished"] is True + assert row["error"] is None + assert row["ref"] is None + assert row["version"] is None + + +@pytest.mark.asyncio +async def test_fleet_reports_a_git_failure_as_an_error_not_unpublished(repo, monkeypatch): + async def broken(cmd, **kw): + return 128, "", "fatal: not a git repository" + + monkeypatch.setattr(runtime, "_run_cmd", broken) + row = await fleet_state_release_channel._release_channel([]) + assert row is not None + assert row["unpublished"] is False + assert row["error"] + + +@pytest.mark.asyncio +async def test_fleet_release_channel_is_none_when_resolution_raises(repo, monkeypatch): + """A channel that cannot be resolved must not take the whole fleet view down.""" + + async def boom(**kw): + raise RuntimeError("resolver exploded") + + monkeypatch.setattr(release_channel_pin, "resolve_tip", boom) + assert await fleet_state_release_channel._release_channel([]) is None + + +@pytest.mark.asyncio +async def test_fleet_release_channel_is_none_without_a_checkout(monkeypatch): + def no_repo(): + raise repository.RepoUnavailable("no checkout") + + monkeypatch.setattr(repository, "_repo", no_repo) + assert await fleet_state_release_channel._release_channel([]) is None + + +@pytest.mark.asyncio +async def test_an_overflowing_points_at_listing_becomes_the_rows_error(repo, monkeypatch): + rows = "\n".join(f"v0.0.{n}" for n in range(release_channel_pin._MAX_RELEASE_REFS + 1)) + + async def overflowing(cmd, **kw): + if "for-each-ref" in cmd and "--points-at" in cmd: + return 0, rows + "\n", "" + return await _Git(head=_OTHER_OID)(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", overflowing) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] == release_channel_pin.WORKTREE_NAME + assert row["version"] is None + assert "refusing to name the release" in row["error"] + + +@pytest.mark.asyncio +async def test_a_failed_points_at_listing_is_the_rows_error_not_no_release(repo, monkeypatch): + """A lookup that could not be READ is not the answer "on no release". + + ``for-each-ref --points-at HEAD`` exits 0 with no output for a detached tree that + sits on no published tag -- that is `version: null` and the badge says so. A + non-zero exit (timeout, unreadable refs) says nothing about the tree, and + publishing `version: null` with no error made the badge read "not on a release" + for an unchanged release checkout. The failure now rides the row's error, the tree + stays adopted, and the version is unknown rather than asserted. + """ + + async def unreadable(cmd, **kw): + if "for-each-ref" in cmd and "--points-at" in cmd: + return 128, "", "fatal: could not read refs" + return await _Git(head=_OTHER_OID)(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", unreadable) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["worktree"] == release_channel_pin.WORKTREE_NAME # still adopted: HEAD read fine + assert row["version"] is None + # The flag the badge reads to say "version unknown" instead of "not on a release". + assert row["version_unknown"] is True + assert "could not be read" in row["error"] + assert "refusing to name the release" in row["error"] + # The genuine "no release here" answer is unchanged: exit 0, empty listing, no + # error -- and the flag stays False, so the badge's positive label is earned. + monkeypatch.setattr(runtime, "_run_cmd", _Git(head=_OTHER_OID, head_tag="")) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert row["version"] is None + assert row["version_unknown"] is False + assert row["error"] is None + # Never set on a placeholder, and cleared when a removal demotes the row to one. + placeholder = await fleet_state_release_channel._release_channel([]) + assert placeholder is not None and placeholder["version_unknown"] is False + demoted = fleet_state._drop_worktrees( + { + "worktrees": [{"name": release_channel_pin.WORKTREE_NAME}], + "release_channel": { + **row, + "worktree": release_channel_pin.WORKTREE_NAME, + "version_unknown": True, + }, + }, + {release_channel_pin.WORKTREE_NAME}, + ) + assert demoted["release_channel"]["version_unknown"] is False + + +@pytest.mark.asyncio +async def test_worktree_overflow_does_not_overwrite_a_resolver_error(repo, monkeypatch): + """The first error wins: the resolver's failure is the one the operator must act on.""" + rows = "\n".join(f"v0.0.{n}" for n in range(release_channel_pin._MAX_RELEASE_REFS + 1)) + + async def overflowing(cmd, **kw): + if "for-each-ref" in cmd and "--points-at" in cmd: + return 0, rows + "\n", "" + if "for-each-ref" in cmd: + return 128, "", "fatal: the mirror could not be listed" + return await _Git(head=_OTHER_OID)(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", overflowing) + row = await fleet_state_release_channel._release_channel( + [{"path": f"/x/{release_channel_pin.WORKTREE_NAME}"}] + ) + assert row is not None + assert "cannot list tags" in row["error"] + + +@pytest.mark.asyncio +async def test_the_fleet_payload_carries_the_channel_under_its_own_key(): + """A pathless row injected into ``worktrees`` would be a fabricated worktree.""" + import inspect + + source = inspect.getsource(fleet_state._build_fleet) + assert ( + '"release_channel": await fleet_state_release_channel._release_channel(worktrees)' in source + ) + + +# -------------------------------------------------------------------------- +# the route +# -------------------------------------------------------------------------- +class _FakeRequest: + """Enough of ``web.Request`` for the audited decorator and the handler.""" + + def __init__(self, body: bytes = b""): + self._body = body + self.content_length = len(body) or None + self.can_read_body = bool(body) + + async def read(self) -> bytes: + return self._body + + +@pytest.mark.asyncio +async def test_the_route_audits_the_one_worktree_it_acts_on(monkeypatch): + """The target is a constant, so no body can name what the mutation is recorded against.""" + recorded: list[dict] = [] + + class _Sel: + def log_tool_invocation(self, **kw): + recorded.append(kw) + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + + async def started(on_finish=None): + return {"ok": True, "run_id": "r1", "name": release_channel_pin.WORKTREE_NAME} + + monkeypatch.setattr(worktree_ops, "_release_channel_create_start", started) + resp = await http_api.api_dev_fleet_release_channel_create( + _FakeRequest(b'{"name": "kirocrew-wt-somebody-elses-tree"}') + ) + assert resp.status == 202 + assert len(recorded) == 1 + assert recorded[0]["resources"] == release_channel_pin.WORKTREE_NAME + assert recorded[0]["tool_name"] == "dev_fleet_release_channel_create" + assert recorded[0]["outcome"] == "success" + + +@pytest.mark.asyncio +async def test_the_route_answers_the_run_id_before_the_create_is_done(repo, monkeypatch): + """The reply is the run, not the result: the proxy in front gives up at 30 s. + + The create is held open by a gate here, and the route must still have answered -- + with an id whose run is ``running`` -- before that gate opens. + """ + + class _Sel: + def log_tool_invocation(self, **kw): + return None + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + gate = asyncio.Event() + + async def slow_create(): + await gate.wait() + return {"ok": True, "lane": "stable", "ref": "refs/tags/v0.5.0", "version": "0.5.0"} + + monkeypatch.setattr(worktree_ops, "_release_channel_create", slow_create) + resp = await http_api.api_dev_fleet_release_channel_create(_FakeRequest()) + assert resp.status == 202 + body = json.loads(resp.text) + assert body["ok"] is True + rid = body["run_id"] + assert runtime._RUNS[rid]["status"] == "running" + assert runtime._RUNS[rid]["label"] == "create " + release_channel_pin.WORKTREE_NAME + gate.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert runtime._RUNS[rid]["status"] == "done" + assert runtime._RUNS[rid]["exit_code"] == 0 + assert runtime._RUNS[rid]["result"]["version"] == "0.5.0" + + +@pytest.mark.asyncio +async def test_the_run_record_carries_the_outcome_the_route_used_to_answer_with(repo, monkeypatch): + """``/api/run`` is where the result now lives; the row's ``error`` reaches it scrubbed.""" + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + monkeypatch.setattr(runtime, "_redact", lambda text: text.replace("hunter2", "***")) + + async def failed_create(): + return { + "ok": False, + "code": "git_failed", + "error": "helper hunter2 refused", + "path": "/srv/hunter2/lane", + "attempt": 1, + } + + monkeypatch.setattr(worktree_ops, "_release_channel_create", failed_create) + started = await worktree_ops._release_channel_create_start() + rid = started["run_id"] + await asyncio.sleep(0) + await asyncio.sleep(0) + assert runtime._RUNS[rid]["exit_code"] == 1 + + class _Req: + query = {"id": rid} + + resp = await http_api.api_dev_fleet_run(_Req()) + snap = json.loads(resp.text) + assert snap["status"] == "done" + assert snap["result"]["code"] == "git_failed" + # EVERY string in the result is scrubbed at this egress, not only `error`: the + # owner that wrote the result carries no redactor, so the path it quotes is + # scrubbed here or nowhere. + assert snap["result"]["error"] == "helper *** refused" + assert snap["result"]["path"] == "/srv/***/lane" + assert snap["result"]["attempt"] == 1 + # The record itself is untouched: the scrub is the route's, applied on the way out. + assert runtime._RUNS[rid]["result"]["error"] == "helper hunter2 refused" + + +@pytest.mark.asyncio +async def test_a_start_while_the_run_body_holds_the_lock_still_names_the_run(repo, monkeypatch): + """The reattach must work for the WHOLE life of a create, not only before it locks. + + The run body holds ``_wt_lock(name)`` from the fetch to the move, which is minutes. + A start arriving then must answer with the running create's id, not with the + preflight's own ``busy`` (which names no run), or a reloaded page sees a failure. + """ + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + gate = asyncio.Event() + locked = asyncio.Event() + + async def locking_create(): + async with worktree_ops._wt_lock(release_channel_pin.WORKTREE_NAME): + locked.set() + await gate.wait() + return {"ok": True} + + monkeypatch.setattr(worktree_ops, "_release_channel_create", locking_create) + first = await worktree_ops._release_channel_create_start() + task = runtime._ACTIVE_RUNS[first["run_id"]][0] + try: + # Bounded by name: the worker is the only thing that sets `locked`, and a worker + # that fails before the lock is absorbed by `_start_task_run` without + # signalling, so a bare wait would hang to the suite timeout instead of failing + # here. `await_until` carries the lost-run bound (half the test's timeout). + await await_until(locked, what="the create run to take the worktree lock") + assert worktree_ops._wt_lock(release_channel_pin.WORKTREE_NAME).locked() + second = await worktree_ops._release_channel_create_start() + assert second["code"] == "busy" + assert second["run_id"] == first["run_id"] + finally: + # Drain the worker either way, so nothing outlives the test; a worker that + # does not finish once the gate opens is cancelled and the failure names it. + gate.set() + try: + await await_until(task, what="the create run to finish once the gate opens") + except AssertionError: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + raise + # With the create finished and the lock released, another operation holding the + # lock is still `busy` -- and names no run, because there is none. + async with worktree_ops._wt_lock(release_channel_pin.WORKTREE_NAME): + third = await worktree_ops._release_channel_create_start() + assert third["code"] == "busy" + assert "run_id" not in third + + +@pytest.mark.asyncio +async def test_the_route_audits_how_the_run_ended_not_only_that_it_started(repo, monkeypatch): + """Two entries per create: admission at the response, outcome at the terminal state.""" + recorded: list[dict] = [] + + class _Sel: + def log_tool_invocation(self, **kw): + recorded.append(kw) + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + + async def refused_create(): + return {"ok": False, "code": "provenance_mismatch", "error": "refusing: v0.5.0 differs"} + + monkeypatch.setattr(worktree_ops, "_release_channel_create", refused_create) + resp = await http_api.api_dev_fleet_release_channel_create(_FakeRequest()) + assert resp.status == 202 + await asyncio.sleep(0) + await asyncio.sleep(0) + assert [e["tool_name"] for e in recorded] == [ + "dev_fleet_release_channel_create", + "dev_fleet_release_channel_create_result", + ] + assert recorded[0]["outcome"] == "success" # the run was admitted + assert recorded[1]["outcome"] == "denied" # the run ended in a refusal + assert recorded[1]["resources"] == release_channel_pin.WORKTREE_NAME + assert "v0.5.0 differs" in recorded[1]["error"] + + +def test_the_result_audit_splits_outcomes_the_way_the_response_did(): + assert http_api._release_channel_outcome({"ok": True})[0] == "success" + assert http_api._release_channel_outcome({"ok": False, "code": "exists"})[0] == "denied" + assert http_api._release_channel_outcome({"ok": False, "code": "git_failed"})[0] == "failure" + outcome, error = http_api._release_channel_outcome(None) + assert outcome == "failure" and error + + +@pytest.mark.asyncio +async def test_a_second_start_while_one_runs_names_the_run_in_flight(repo, monkeypatch): + """``busy`` with a ``run_id`` is an attach point, not a collision to report.""" + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + gate = asyncio.Event() + + async def slow_create(): + await gate.wait() + return {"ok": True} + + monkeypatch.setattr(worktree_ops, "_release_channel_create", slow_create) + first = await worktree_ops._release_channel_create_start() + second = await worktree_ops._release_channel_create_start() + assert second["ok"] is False + assert second["code"] == "busy" + assert second["run_id"] == first["run_id"] + assert len(runtime._RUNS) == 1 + gate.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + # Once the run has left ``running``, the next start is a new run. + third = await worktree_ops._release_channel_create_start() + assert third["ok"] is True + assert third["run_id"] != first["run_id"] + await asyncio.sleep(0) + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_a_refusal_the_route_can_decide_is_answered_without_a_run(repo, monkeypatch): + """``exists`` is a 409 at once, and no run record is written for it.""" + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(worktree_ops, "_CREATE_RID", None) + monkeypatch.setattr( + repository, "_find_worktree", _found({"path": "/somewhere/release-channel-stable"}) + ) + + class _Sel: + def log_tool_invocation(self, **kw): + return None + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + resp = await http_api.api_dev_fleet_release_channel_create(_FakeRequest()) + assert resp.status == 409 + body = json.loads(resp.text) + assert body["code"] == "exists" + assert body["run_id"] is None # nothing to attach to + assert runtime._RUNS == {} + + +@pytest.mark.asyncio +async def test_the_route_relays_the_in_flight_run_on_busy(monkeypatch): + class _Sel: + def log_tool_invocation(self, **kw): + return None + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + + async def busy(on_finish=None): + return {"ok": False, "code": "busy", "error": "already running", "run_id": "r9"} + + monkeypatch.setattr(worktree_ops, "_release_channel_create_start", busy) + resp = await http_api.api_dev_fleet_release_channel_create(_FakeRequest()) + assert resp.status == 409 + assert json.loads(resp.text)["run_id"] == "r9" + + +@pytest.mark.asyncio +async def test_a_task_run_that_raises_ends_like_a_failed_subprocess(monkeypatch): + """Same record shape as ``_start_run``: ``exit_code -1`` and the reason as output.""" + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + seen: list = [] + + async def boom(): + raise RuntimeError("the lane fell over") + + rid = await runtime._start_task_run("create probe", boom, on_finish=seen.append) + await asyncio.sleep(0) + await asyncio.sleep(0) + run = runtime._RUNS[rid] + assert run["status"] == "done" + assert run["exit_code"] == -1 + assert run["output"] == ["[error] the lane fell over"] + assert "result" not in run + assert seen == [None] + assert rid not in runtime._ACTIVE_RUNS + + +@pytest.mark.asyncio +async def test_a_task_run_is_cancelled_by_shutdown_like_a_subprocess_run(monkeypatch): + """The task is in ``_ACTIVE_RUNS`` so ``dev_fleet_cleanup`` reaches it.""" + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + gate = asyncio.Event() + + async def forever(): + await gate.wait() + return {"ok": True} + + rid = await runtime._start_task_run("create probe", forever) + task, proc = runtime._ACTIVE_RUNS[rid] + assert proc is None + await asyncio.sleep(0) # the worker is at its await, like a create mid-fetch + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + run = runtime._RUNS[rid] + assert run["status"] == "done" + assert run["exit_code"] == -1 + assert run["output"] == ["[shutdown] run aborted: gateway stopping"] + + +@pytest.mark.asyncio +async def test_a_task_run_is_refused_once_shutdown_began(monkeypatch): + monkeypatch.setattr(runtime, "_RUNS", {}) + monkeypatch.setattr(runtime, "_ACTIVE_RUNS", {}) + monkeypatch.setattr(runtime, "_SHUTDOWN_IN_PROGRESS", True) + called = [] + + async def work(): + called.append(True) + return {"ok": True} + + with pytest.raises(RuntimeError, match="shutdown in progress"): + await runtime._start_task_run("create probe", work) + await asyncio.sleep(0) + assert called == [] + assert runtime._RUNS == {} + assert runtime._ACTIVE_RUNS == {} + + +@pytest.mark.asyncio +async def test_a_worktree_route_still_reads_its_target_from_the_body(monkeypatch): + """The static target is opt-in per route, not a change to how the others behave.""" + recorded: list[dict] = [] + + class _Sel: + def log_tool_invocation(self, **kw): + recorded.append(kw) + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + + @http_api._audited("dev_fleet_probe") + async def handler(request): + from aiohttp import web + + return web.json_response({"ok": True}) + + await handler(_FakeRequest(b'{"name": "kirocrew-wt-feature"}')) + assert recorded[0]["resources"] == "kirocrew-wt-feature" + + +@pytest.mark.asyncio +async def test_a_refusal_answers_409_and_an_unexpected_git_failure_500(monkeypatch): + """The status is picked from the result's code, so a client can tell the two apart.""" + + class _Sel: + def log_tool_invocation(self, **kw): + return None + + monkeypatch.setattr(runtime, "_sel", lambda: _Sel()) + for code, expected in ( + ("exists", 409), + ("path_exists", 409), + ("busy", 409), + ("fetch_failed", 409), + ("unpublished", 409), + ("provenance_mismatch", 409), + ("git_failed", 500), + (None, 500), + ): + + async def refused(on_finish=None, _code=code): + return {"ok": False, "code": _code, "error": "nope"} + + monkeypatch.setattr(worktree_ops, "_release_channel_create_start", refused) + resp = await http_api.api_dev_fleet_release_channel_create(_FakeRequest()) + assert resp.status == expected, code + + +def test_every_refusal_code_the_mutation_emits_is_classified(): + """A code the frozenset does not list silently becomes a 500 the caller cannot act on.""" + import inspect + import re + + source = inspect.getsource(worktree_ops._release_channel_preflight) + source += inspect.getsource(worktree_ops._release_channel_create_start) + source += inspect.getsource(worktree_ops._release_channel_create_locked) + source += inspect.getsource(worktree_ops._stage_and_adopt) + # Every code literal a ``"code":`` value can take, not just the first: the + # resolve refusal picks between two codes in one conditional expression. The + # match stops at the value's comma so a one-line dict's ``"error"`` key is + # not read as a code. + emitted = { + code + for line in source.splitlines() + if '"code":' in line + for code in re.findall(r'"([a-z_]+)"', line.split('"code":', 1)[1].split(",", 1)[0]) + } + assert emitted - {"git_failed"} == set(worktree_ops._RELEASE_CHANNEL_REFUSALS) diff --git a/test/test_dev_fleet_release_channel_pin.py b/test/test_dev_fleet_release_channel_pin.py new file mode 100644 index 00000000000..db4f6b42733 --- /dev/null +++ b/test/test_dev_fleet_release_channel_pin.py @@ -0,0 +1,718 @@ +"""The release channel resolves to the ref stable actually published. + +The defect these tests exist to prevent is a SILENT one: a lane that resolves to the +wrong ref still produces a worktree that builds, boots as a pod and serves a +dashboard, so "stable" showing a prerelease looks exactly like success. Every +assertion below is therefore about the resolver's *answer*, not about whether it ran. + +Tag fixtures use this repository's real tag vocabulary (``v0.5.0``, +``v0.6.0-insider.6``) so a rename of the release workflow's tag shape shows up here +rather than in production. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from kiro_crew.apps.builtins.dev_fleet import release_channel_pin as rcp +from kiro_crew.apps.builtins.dev_fleet import repository, runtime + +# Newest first by DATE, which is the order a `--sort=-creatordate` listing would give. +# The interleaving is the point: insider's tip is NEWER than stable's tip, so a +# resolver that ignored the shape filter and simply took the first line would return an +# insider tag for stable -- and that is the real shape of this repo's tag history, not a +# contrived case. +_TAGS_NEWEST_FIRST = [ + "v0.6.0-insider.6", + "v0.6.0-insider.5", + "v0.5.0", + "v0.5.0-insider.11", + "v0.4.1", +] + +# Every resolver test fakes `runtime._run_cmd`, so no argv reaches git and the checkout +# path is inert -- any string does. It is passed explicitly rather than left to a +# default because `resolve(repo=...)` is required: a test that omitted it would be +# resolving against whatever checkout the ambient Dev Fleet configuration happens to +# name, which is a dependency these tests do not want. +_FAKE_REPO = "/fake/checkout" + + +def _fake_git(tags: list[str] | None = None, *, oids: dict[str, str] | None = None): + """A git stand-in answering only what the resolver asks. + + ``tags`` stands for the contents of the MIRROR (``rcp._TAG_NS``), not of + ``refs/tags/``. The resolver reads only the mirror, so a fixture that seeded local + tags would be describing a namespace it never queries. + """ + tags = _TAGS_NEWEST_FIRST if tags is None else tags + oids = oids or {} + + async def fake_run(cmd, **kw): + if "for-each-ref" in cmd and any(rcp._TAG_NS in part for part in cmd): + # `--points-at HEAD` narrows the same namespace to the checked-out commit; + # the resolver's listing does not pass it. Fixtures that need the narrowed + # answer override this stand-in. + return 0, "\n".join(tags) + "\n", "" + if "rev-parse" in cmd: + target = cmd[-1] + if target in oids: + return 0, oids[target] + "\n", "" + # Deterministic stand-in oid derived from the ref, so assertions can tie a + # returned oid back to the ref it was resolved from. + return 0, f"oid-{target}\n", "" + return 1, "", f"unexpected argv: {cmd}" + + return fake_run + + +@pytest.fixture(autouse=True) +def _pinned_remote(_floor_monkeypatch): + """Pin the remote so no test resolves it from an ambient checkout's git config. + + Through ``_floor_monkeypatch``, not the test's own ``monkeypatch``: an autouse + patch on the shared stack is lifted by any test that calls ``monkeypatch.undo()``, + while the floor's stack is undone independently (testing-conventions D11). + """ + _floor_monkeypatch.setattr(repository, "_UPSTREAM_REMOTE", "origin") + _floor_monkeypatch.setattr(repository, "BASE_BRANCH", "main") + + +# -------------------------------------------------------------------------- +# naming +# -------------------------------------------------------------------------- +def test_the_worktree_name_carries_the_channel_and_is_spelled_once(): + """One naming rule, on the backend only. + + The fleet payload publishes this string, so the frontend never rebuilds it -- a + second copy of the rule is what would let a change here desync a row's label from + the directory it names. + + The name is a literal rather than a prefix composed with ``rcp.CHANNEL``, so what is + asserted is the PROPERTY that matters: the basename still names its channel. An + equality against a composition of two constants in this same module would be a + tautology. + """ + assert rcp.WORKTREE_NAME == "release-channel-stable" + assert rcp.WORKTREE_NAME.endswith(rcp.CHANNEL) + + +def test_worktree_name_is_a_valid_pod_identity(): + """The basename becomes ``kirocrew-pod@.service``. + + A name that fails the pod name rule would surface as a pod that cannot be brought + up -- long after the worktree was created and built. + """ + from kiro_crew.pod.runtime import _NAME_RE + + assert _NAME_RE.match(rcp.WORKTREE_NAME), rcp.WORKTREE_NAME + + +def test_worktree_path_is_a_sibling_of_the_primary_checkout(): + # Compared as PATHS, not as strings: `worktree_path` returns a native path, so a + # POSIX string literal would assert the separator rather than the placement. + repo = Path("/Users/me/Projects/KiroCrew") # brand-ok: directory name, not prose + got = Path(rcp.worktree_path(str(repo))) + assert got == repo.parent / "release-channel-stable" + assert got.parent == repo.parent + assert got.name == rcp.WORKTREE_NAME + + +# -------------------------------------------------------------------------- +# tag classification +# -------------------------------------------------------------------------- +@pytest.mark.parametrize("tag", ["v0.5.0", "v0.4.9", "v1.0.0", "v0.10.0", "v12.3.45"]) +def test_a_release_tag_is_decided_by_shape_and_never_disagrees_with_the_shared_rule(tag): + """The regex IS the classification here, and it answers what the repo answers. + + ``is_release_tag`` does not call ``release_channel.channel``: a bare ``vX.Y.Z`` has + no suffix left to read, so the call could only agree. What still has to hold is that + the two never DIVERGE -- a release-shaped tag this module admits must be one the + shared rule also calls stable, or the worktree would pin to a build the rest of the + product does not treat as a release. + """ + from kiro_crew import release_channel + + assert rcp.is_release_tag(tag) is True + assert release_channel.channel(tag[1:]) == rcp.CHANNEL + + +@pytest.mark.parametrize( + "tag", + [ + "v0.6.0-insider.6", + "v0.6.0-rc.1", + "0.5.0", + "v0.5", + "v0.5.0.1", + "release-v0.5.0", + "v0.5.0-", + "", + ], +) +def test_a_non_release_tag_is_rejected_at_the_shape_check(tag): + assert rcp.is_release_tag(tag) is False + + +# -------------------------------------------------------------------------- +# resolution +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_resolve_skips_newer_prerelease_tags(monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _fake_git()) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is True + assert got["ref"] == f"{rcp._TAG_NS}/v0.5.0" + assert got["version"] == "0.5.0" + assert got["lane"] == rcp.CHANNEL + + +@pytest.mark.asyncio +async def test_a_prerelease_tag_never_becomes_the_tip_even_when_it_is_the_only_newer_one( + monkeypatch, +): + monkeypatch.setattr(runtime, "_run_cmd", _fake_git(["v0.9.0-insider.1", "v0.4.1"])) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["version"] == "0.4.1" + + +@pytest.mark.asyncio +async def test_resolve_orders_by_version_not_by_listing_order(monkeypatch): + """A backport cut after a newer line is the newest tag by DATE and an older release. + + ``v0.4.2`` first in the listing is exactly what ``--sort=-creatordate`` would hand + over for that history, and pinning it would put the row on a release stable users + are not on. + """ + monkeypatch.setattr(runtime, "_run_cmd", _fake_git(["v0.4.2", "v0.5.0"])) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["version"] == "0.5.0" + + +@pytest.mark.asyncio +async def test_resolve_compares_version_parts_numerically(monkeypatch): + """``v0.10.0`` beats ``v0.9.0``; a lexicographic sort says the opposite.""" + monkeypatch.setattr(runtime, "_run_cmd", _fake_git(["v0.9.0", "v0.10.0"])) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["version"] == "0.10.0" + + +def test_release_candidates_is_deterministic_under_a_reordered_listing(): + """Two orderings of one tag set resolve to the same sequence, so a repeat agrees.""" + forward = rcp._release_candidates(["v0.4.1", "v0.5.0", "v1.0.0"]) + backward = rcp._release_candidates(["v1.0.0", "v0.5.0", "v0.4.1"]) + assert forward == backward == ["v1.0.0", "v0.5.0", "v0.4.1"] + + +def test_the_channel_is_a_real_release_channel(): + """A lane name the rest of the product does not know is a row nobody can read.""" + from kiro_crew import release_channel + + assert release_channel.channel("1.2.3") == rcp.CHANNEL + + +@pytest.mark.asyncio +async def test_resolve_reports_the_oid_of_the_tagged_commit(monkeypatch): + """The oid is the one the MIRROR ref resolves to, peeled to a commit.""" + monkeypatch.setattr( + runtime, + "_run_cmd", + _fake_git(["v0.5.0"], oids={f"{rcp._TAG_NS}/v0.5.0^{{commit}}": "a" * 40}), + ) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["oid"] == "a" * 40 + + +@pytest.mark.asyncio +async def test_an_unfetched_mirror_reads_as_unpublished_not_as_an_error(monkeypatch): + """The ordinary state of a checkout that has not fetched yet is not an incident.""" + monkeypatch.setattr(runtime, "_run_cmd", _fake_git([])) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is False + assert got["unpublished"] is True + assert rcp.CHANNEL in got["error"] + + +@pytest.mark.asyncio +async def test_resolve_does_not_flag_a_git_failure_as_unpublished(monkeypatch): + """A failed listing is an error the operator is shown, a different row entirely.""" + + async def fake_run(cmd, **kw): + if "for-each-ref" in cmd: + return 128, "", "fatal: not a git repository" + return 1, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is False + assert "unpublished" not in got + + +@pytest.mark.asyncio +async def test_resolve_skips_a_mirror_ref_that_will_not_resolve(monkeypatch): + """One broken ref must not hide the real release behind an empty channel.""" + + async def fake_run(cmd, **kw): + if "for-each-ref" in cmd: + return 0, "v0.9.0\nv0.5.0\n", "" + if "rev-parse" in cmd and cmd[-1].endswith("v0.9.0^{commit}"): + return 128, "", "fatal: bad revision" + if "rev-parse" in cmd: + return 0, "b" * 40 + "\n", "" + return 1, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is True + assert got["version"] == "0.5.0" + + +@pytest.mark.asyncio +async def test_a_local_only_tag_is_never_a_channel_candidate(monkeypatch): + """``refs/tags/`` is a namespace the operator writes to; the mirror is not. + + A hand-cut ``v99.0.0`` sorts newest and would badge an unpublished commit as the + shipped release. The listing reads the mirror, so the local tag is invisible. + """ + read: list[list[str]] = [] + + async def fake_run(cmd, **kw): + read.append(list(cmd)) + if "for-each-ref" in cmd: + return 0, "v0.5.0\n", "" + if "rev-parse" in cmd: + return 0, "c" * 40 + "\n", "" + return 1, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["version"] == "0.5.0" + assert not any("tag" in cmd for cmd in read), read + listing = next(cmd for cmd in read if "for-each-ref" in cmd) + assert f"{rcp._TAG_NS}/" in listing + + +@pytest.mark.asyncio +async def test_the_reported_ref_is_the_mirror_ref_that_resolved(monkeypatch): + """The row names the ref it resolved from, and reads nothing under ``refs/tags/``. + + Nothing in this module writes the operator's ``refs/tags/`` (``fetch_refs`` names + only the mirror), so whether ``refs/tags/`` exists locally or names this + commit is not a fact a resolve can vouch for -- it is not consulted, and it is not + published. The mirror ref is checkout-able as it stands. + """ + read: list[list[str]] = [] + fake = _fake_git(["v0.5.0"]) + + async def recording(cmd, **kw): + read.append(list(cmd)) + return await fake(cmd, **kw) + + monkeypatch.setattr(runtime, "_run_cmd", recording) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is True + assert got["ref"] == f"{rcp._TAG_NS}/v0.5.0" + assert not any(a.startswith("refs/tags/") for cmd in read for a in cmd), read + + +# -------------------------------------------------------------------------- +# fetch +# -------------------------------------------------------------------------- +def _fetch_recorder(*, mirror=(0, "", "")): + calls: list[list[str]] = [] + + async def fake_run(cmd, **kw): + calls.append(list(cmd)) + return mirror + + return calls, fake_run + + +@pytest.mark.asyncio +async def test_fetch_refs_populates_the_mirror_it_resolves_from(monkeypatch): + calls, fake_run = _fetch_recorder() + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + assert await rcp.fetch_refs(_FAKE_REPO) is None + mirror = next(cmd for cmd in calls if rcp._TAG_REFSPEC in cmd) + assert rcp._TAG_REFSPEC == f"+refs/tags/v*:{rcp._TAG_NS}/v*" + assert "origin" in mirror + + +@pytest.mark.asyncio +async def test_fetch_refs_is_one_spawn_naming_the_mirror_and_nothing_else(monkeypatch): + """One fetch, one refspec, one destination namespace. + + Not the base branch: the refresher fetches that in its own spawn, so a mirror the + remote cannot serve leaves Behind moving and a base branch that will not fetch + leaves the channel honest. Not the operator's ``refs/tags/``: nothing here writes + a ref the operator did not ask for, so no ``--tags`` and no ``refs/tags/*`` + destination -- a tag that reaches ``refs/tags/`` by any route cannot become a tip. + """ + calls, fake_run = _fetch_recorder() + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + await rcp.fetch_refs(_FAKE_REPO) + fetches = [cmd for cmd in calls if "fetch" in cmd] + assert len(fetches) == 1, fetches + (mirror,) = fetches + refspecs = mirror[mirror.index("origin") + 1 :] + assert refspecs == [rcp._TAG_REFSPEC], mirror + assert repository.BASE_BRANCH not in mirror + assert "--tags" not in mirror + # Auto-follow would store remote tags under refs/tags/ even with no refs/tags/ + # destination named; it is off by flag, not left to `remote..tagOpt`. + assert "--no-tags" in mirror + assert all(not a.startswith("refs/tags/") for a in mirror), mirror + + +@pytest.mark.asyncio +async def test_fetch_refs_prunes_the_mirror_and_never_asks_for_prune_tags(monkeypatch): + """``--prune`` is scoped to the destinations of the refspecs given. + + Without it a hand-written mirror ref survives every additive fetch and outranks + every real release. ``--prune-tags`` would add an implicit + ``+refs/tags/*:refs/tags/*`` and destroy the operator's own tags, so its absence is + part of the contract, not an omission. + """ + calls, fake_run = _fetch_recorder() + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + await rcp.fetch_refs(_FAKE_REPO) + mirror = next(cmd for cmd in calls if rcp._TAG_REFSPEC in cmd) + assert "--prune" in mirror + assert all("--prune-tags" not in cmd for cmd in calls), calls + assert "--tags" not in mirror, "the pruning fetch must not name tags as a destination" + + +@pytest.mark.asyncio +async def test_the_pruning_fetch_pins_no_prune_tags_against_repo_config(monkeypatch): + """``fetch.pruneTags`` in the repository's own ``.git/config`` would make ``--prune`` + with explicit refspecs also prune ``refs/tags/`` -- the operator's local-only tags + gone on every refresher cycle, from a file an agent can write. The flag is said + explicitly so the scoping the docstring promises holds whatever the config says.""" + calls, fake_run = _fetch_recorder() + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + await rcp.fetch_refs(_FAKE_REPO) + mirror = next(cmd for cmd in calls if rcp._TAG_REFSPEC in cmd) + assert "--no-prune-tags" in mirror + assert mirror.index("--no-prune-tags") < mirror.index(rcp._TAG_REFSPEC) + + +@pytest.mark.asyncio +async def test_two_fetches_in_one_loop_run_one_at_a_time(monkeypatch): + """The refresher and a Create fetch the same refs; concurrent fetches contend for + the same ref locks and the loser's ``cannot lock ref`` would refuse a Create that a + second later succeeds. Serialized per event loop.""" + inside = 0 + overlapped = False + + async def fake_run(cmd, **kw): + nonlocal inside, overlapped + if "fetch" in cmd: + inside += 1 + overlapped = overlapped or inside > 1 + await asyncio.sleep(0) + inside -= 1 + return 0, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + results = await asyncio.gather(rcp.fetch_refs(_FAKE_REPO), rcp.fetch_refs(_FAKE_REPO)) + assert results == [None, None] + assert overlapped is False + + +@pytest.mark.asyncio +async def test_a_failed_mirror_fetch_fails_the_refresh(monkeypatch): + _calls, fake_run = _fetch_recorder(mirror=(128, "", "fatal: could not read from remote")) + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.fetch_refs(_FAKE_REPO) + assert got is not None + assert "could not read from remote" in got + + +@pytest.mark.asyncio +async def test_fetch_refs_redacts_the_error_it_returns(monkeypatch): + """The string becomes the lane's ``error`` on the page, so it is scrubbed first.""" + secret = "https://x-access-token:ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@github.com/o/r" + _calls, fake_run = _fetch_recorder(mirror=(128, "", f"fatal: unable to access {secret}")) + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.fetch_refs(_FAKE_REPO) + assert got is not None + assert "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" not in got + + +# -------------------------------------------------------------------------- +# provenance +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_advertised_release_commit_reads_the_peel_line_of_an_annotated_tag(monkeypatch): + """An annotated tag's plain entry is the TAG OBJECT, not the commit. + + A resolver holding the commit compared against the plain entry would refuse every + annotated release, which is how releases are normally cut. + """ + tag_object, commit = "d" * 40, "e" * 40 + + async def fake_run(cmd, **kw): + assert "ls-remote" in cmd + return ( + 0, + f"{tag_object}\trefs/tags/v0.5.0\n{commit}\trefs/tags/v0.5.0^{{}}\n", + "", + ) + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got, err = await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") + assert (got, err) == (commit, None) + + +@pytest.mark.asyncio +async def test_advertised_release_commit_accepts_a_lightweight_tag(monkeypatch): + """A lightweight tag advertises its commit on the plain entry and has no peel.""" + + async def fake_run(cmd, **kw): + return 0, f"{'f' * 40}\trefs/tags/v0.5.0\n", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got, err = await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") + assert (got, err) == ("f" * 40, None) + + +@pytest.mark.asyncio +async def test_an_unadvertised_tag_is_absence_not_an_error(monkeypatch): + """``ls-remote`` exits 0 and prints nothing for a tag the remote does not carry.""" + + async def fake_run(cmd, **kw): + return 0, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + assert await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") == (None, None) + + +@pytest.mark.asyncio +async def test_a_failed_provenance_query_fails_closed(monkeypatch): + async def fake_run(cmd, **kw): + return 128, "", "fatal: could not read from remote repository" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got, err = await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") + assert got is None + assert err and "could not read from remote" in err + + +@pytest.mark.asyncio +async def test_the_remote_is_asked_for_only_the_resolved_tag_and_its_peel(monkeypatch): + """Provenance does not need to enumerate every release tag the remote carries.""" + seen: list[list[str]] = [] + + async def fake_run(cmd, **kw): + seen.append(list(cmd)) + return 0, f"{'1' * 40}\trefs/tags/v0.5.0\n", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") + assert seen[0][-2:] == ["refs/tags/v0.5.0", "refs/tags/v0.5.0^{}"] + + +@pytest.mark.asyncio +async def test_provenance_retains_only_the_requested_tags_oids(monkeypatch): + """A remote answering with unrelated refs cannot substitute one of its oids.""" + + async def fake_run(cmd, **kw): + return ( + 0, + f"{'9' * 40}\trefs/tags/v9.9.9\n{'2' * 40}\trefs/tags/v0.5.0\nnot-an-oid\trefs/tags/x\n", + "", + ) + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got, err = await rcp.advertised_release_commit(_FAKE_REPO, "v0.5.0") + assert (got, err) == ("2" * 40, None) + + +@pytest.mark.asyncio +async def test_provenance_refuses_a_non_release_tag_before_any_network_call(monkeypatch): + """The tag reaches an argv, so its shape is checked before it is interpolated.""" + called: list[list[str]] = [] + + async def fake_run(cmd, **kw): + called.append(list(cmd)) + return 0, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + with pytest.raises(ValueError): + await rcp.advertised_release_commit(_FAKE_REPO, "--upload-pack=evil") + assert called == [] + + +# -------------------------------------------------------------------------- +# worktree state +# -------------------------------------------------------------------------- +def _tree_git(*, head: str, symref_rc: int = 1, points_at: str = ""): + """A git stand-in for the three reads ``worktree_state`` makes.""" + + async def fake_run(cmd, **kw): + if "symbolic-ref" in cmd: + if symref_rc == 0: + return 0, "refs/heads/main\n", "" + if symref_rc == 1: + return 1, "", "not a symbolic ref" + return symref_rc, "", "timeout (6s)" + if "for-each-ref" in cmd: + return 0, (points_at + "\n" if points_at else ""), "" + if "rev-parse" in cmd: + return (0, head + "\n", "") if head else (128, "", "fatal: bad revision") + return 1, "", "" + + return fake_run + + +@pytest.mark.asyncio +async def test_a_tree_at_the_tip_reports_at_tip_and_the_tips_version(monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="t" * 40)) + got = await rcp.worktree_state( + "/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0", "ref": "refs/tags/v0.5.0"} + ) + assert got == {"at_tip": True, "detached": True, "version": "0.5.0"} + + +@pytest.mark.asyncio +async def test_a_stale_tree_reports_its_own_release_not_the_tips(monkeypatch): + """The moment a newer release ships the tip moves and the tree does not. + + A row showing the resolved version would rename the operator's checkout to a build + it does not contain. + """ + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, points_at="v0.4.1")) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got == {"at_tip": False, "detached": True, "version": "0.4.1"} + + +@pytest.mark.asyncio +async def test_a_tree_on_no_release_tag_reports_no_version(monkeypatch): + """Adoption is for being DETACHED, not for being at a release.""" + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, points_at="")) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got["version"] is None + assert got["detached"] is True + + +@pytest.mark.asyncio +async def test_a_tree_on_a_local_only_tag_reports_no_release(monkeypatch): + """``--points-at`` reads the mirror, so an operator's own tag cannot rename the row.""" + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, points_at="v99.0.0-local")) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got["version"] is None + + +@pytest.mark.asyncio +async def test_a_trees_own_release_survives_a_failed_tip_lookup(monkeypatch): + """Answered from the local mirror, so a failed resolve is no reason to withhold it.""" + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, points_at="v0.4.1")) + got = await rcp.worktree_state("/wt", {}) + assert got == {"at_tip": False, "detached": True, "version": "0.4.1"} + + +@pytest.mark.asyncio +async def test_an_attached_head_is_reported_as_not_detached(monkeypatch): + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, symref_rc=0)) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got["detached"] is False + assert got["version"] is None + + +@pytest.mark.asyncio +async def test_an_unreadable_head_is_neither_detached_nor_on_a_branch(monkeypatch): + """``symbolic-ref`` exits non-zero for a detached HEAD AND for a failed read. + + Reading the first as "detached" would publish a checkout of unknown shape as a + confirmed channel pin, which is the one thing the caller must never be told. + """ + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="")) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got == {"at_tip": False, "detached": None, "version": None} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("symref_rc", [-1, 128]) +async def test_a_symref_probe_that_did_not_answer_leaves_the_shape_unknown(monkeypatch, symref_rc): + """A readable HEAD does not make a failed ``symbolic-ref`` a detached HEAD. + + Git exits 1 for a detached HEAD under ``--quiet``; ``_run_cmd`` answers -1 for a + timeout or a refusal and git 128 for a ``fatal:``. Reading those as "detached" + adopted a BRANCH checkout holding the reserved name as the pin -- PR and Behind + replaced by n/a, Rebase hidden -- with no error reported. The shape stays unknown + and the caller publishes the probe failure instead. + """ + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="h" * 40, symref_rc=symref_rc)) + got = await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) + assert got["detached"] is None + # Not the tip, and no release is named for a tree whose shape is unknown. + assert got["version"] is None and got["at_tip"] is False + + +# -------------------------------------------------------------------------- +# mirror bounds +# -------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_the_mirror_listing_is_bounded_by_git_before_it_reaches_this_process(monkeypatch): + seen: list[list[str]] = [] + + async def fake_run(cmd, **kw): + seen.append(list(cmd)) + return 0, "v0.5.0\n", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + await rcp.list_release_tags(_FAKE_REPO) + assert f"--count={rcp._MAX_RELEASE_REFS + 1}" in seen[0] + + +def test_an_overflowing_listing_refuses_instead_of_sorting_a_truncated_set(): + """A truncated tail would sort and could name a release that is not the tip.""" + rows = "\n".join(f"v0.0.{n}" for n in range(rcp._MAX_RELEASE_REFS + 1)) + with pytest.raises(rcp.MirrorOverflow): + rcp._bounded_mirror_rows(rows) + + +def test_an_overlong_ref_name_is_overflow_not_a_silent_drop(): + """A name longer than a release tag is something else wearing the namespace.""" + with pytest.raises(rcp.MirrorOverflow): + rcp._bounded_mirror_rows("v0.5.0\n" + "v" + "0" * (rcp._MAX_REF_NAME_LEN + 1)) + + +def test_overflow_is_decided_on_the_raw_rows_before_any_name_is_filtered(): + """Filtering first would let an overlength row pass as an absent one.""" + rows = "\n".join(["not-a-release-tag"] * (rcp._MAX_RELEASE_REFS + 1)) + with pytest.raises(rcp.MirrorOverflow): + rcp._bounded_mirror_rows(rows) + + +def test_a_listing_at_the_cap_is_not_overflow(): + rows = "\n".join(f"v0.0.{n}" for n in range(rcp._MAX_RELEASE_REFS)) + assert len(rcp._bounded_mirror_rows(rows)) == rcp._MAX_RELEASE_REFS + + +@pytest.mark.asyncio +async def test_resolve_reports_an_overflowing_mirror_as_an_error(monkeypatch): + rows = "\n".join(f"v0.0.{n}" for n in range(rcp._MAX_RELEASE_REFS + 1)) + + async def fake_run(cmd, **kw): + if "for-each-ref" in cmd: + return 0, rows + "\n", "" + return 1, "", "" + + monkeypatch.setattr(runtime, "_run_cmd", fake_run) + got = await rcp.resolve_tip(repo=_FAKE_REPO) + assert got["ok"] is False + assert "unpublished" not in got + assert "refusing to resolve" in got["error"] + + +@pytest.mark.asyncio +async def test_worktree_state_refuses_an_overflowing_points_at_listing(monkeypatch): + """The caller turns this into the row's error rather than naming a release.""" + rows = "\n".join(f"v0.0.{n}" for n in range(rcp._MAX_RELEASE_REFS + 1)) + monkeypatch.setattr(runtime, "_run_cmd", _tree_git(head="o" * 40, points_at=rows)) + with pytest.raises(rcp.MirrorOverflow): + await rcp.worktree_state("/wt", {"ok": True, "oid": "t" * 40, "version": "0.5.0"}) diff --git a/website/capture/devfleet-release-channel.html b/website/capture/devfleet-release-channel.html new file mode 100644 index 00000000000..f10ef2f6f14 --- /dev/null +++ b/website/capture/devfleet-release-channel.html @@ -0,0 +1,11 @@ + + + + + Dev Fleet release-channel rows capture + + +
+ + + diff --git a/website/capture/devfleet-release-channel.tsx b/website/capture/devfleet-release-channel.tsx new file mode 100644 index 00000000000..5d52101e3dd --- /dev/null +++ b/website/capture/devfleet-release-channel.tsx @@ -0,0 +1,340 @@ +/** + * Isolated capture entry for the Dev Fleet release-channel worktree rows. + * + * WHY ISOLATED: reaching /dev-fleet through the full SPA needs a live gateway + * with real worktrees, a fetched release tag and a registered checkout — none of + * which exist in a capture run, and a half-stubbed shell renders its prerequisite + * gate instead of the table, which is worse evidence than none. This mounts the + * REAL DevFleetPage against the real stylesheet and theme tokens, with `fetch` + * stubbed at the network seam to serve the same `/fleet` payload shape the + * backend sends. Every row state under review is decided by that payload, so the + * code path under review — channelFor, channelNameTakenFor, the placeholder + * branch, the n/a Behind cell — executes exactly as it does against a real + * backend; the stub replaces the backend, not the component. + * + * Scene + theme come from the query string: ?scene=adopted&theme=dark + * Scenes are the row states the reviewer named as required evidence: + * adopted — the lane is materialized: warn-variant version badge whose + * TEXT names the newer release (`0.5.0 · latest 0.5.3`); Behind + * is the n/a marker + * at-tip — the same row at the channel tip: ok-variant badge; Behind is + * the same n/a marker (never a dash, which means up to date + * with main on every other row) + * no-release — the lane tree is detached at NO release tag: "not on a + * release" badge whose tooltip names the tip; Behind is the + * n/a marker + * placeholder — the lane exists but has no worktree: muted row offering Create, + * its status line naming the version Create checks out + * unpublished — nothing resolvable here yet, stated as information: Create LIVE + * blocked — a genuine resolver failure, in BOTH variants it can occur in: + * on the placeholder (Create disabled) and on an adopted row + * taken — a BRANCH checkout occupies the reserved basename: ordinary row, + * no version badge and no warn pill; in the name cell a muted + * status SENTENCE (the placeholder row's own status slot) says the + * checkout is on a branch, that this is why the stable release + * cannot use the name, and that renaming or removing frees it; + * behind-main count, explanation on the row that exists + * version-unknown — an adopted row whose release PROBE failed (the tip is known): + * the badge reads "version unknown", never "not on a release", + * and the probe's error is in the row's ErrorNotice + * unreadable — the reserved directory exists but its HEAD could not be read: + * neither adopted nor on a branch, so it is an ordinary row with + * NO version badge, NO Create and NO placeholder row (one directory, + * one row) — the backend's error reaches the row's ErrorNotice + * menu-open — the runner's scene, not a payload: it loads `adopted` and opens + * the release row's "..." menu to show Rebase onto main is absent + * + * Create's OUTCOMES layer on the placeholder scene: ?scene=placeholder&create= + * The click is the one thing the payload cannot decide — it is a POST and a + * re-render — so the stub answers `/release-channel/create` per mode and the + * runner performs the click before shooting. The handler under review is the + * page's own: rcBusy, notify(), setReleaseChannelCreateError(), invalidateFleet(). + * The POST answers a RUN ID (202), never the result: the create outlives the app + * proxy's 30 s deadline, so the page polls `/run?id=` and reads `result` off the + * record once the run leaves `running`. The stub mirrors that: the POST starts, + * the run answers per mode. + * create=busy — the run stays `running`: the button is disabled and reads + * "Creating…" for as long as the frame is open + * create=ok — the backend adopts the lane: the success toast names the release + * and the next control ("next: Provision"); the /fleet refetch that + * follows serves a freshly-materialized AT-TIP row with has_dist + * false, so under the toast the placeholder has become the real row + * AND the Provision button the toast points at is on it. The + * create=* scenes resolve at the tip (0.5.3): a placeholder behind + * the tip would create a row already wearing `latest`, and the + * adopted payload's has_dist:true had left "next: Provision" + * pointing at no button + * create=fail — the backend passes git's refusal through: the row keeps its + * placeholder shape, Create is live again, and the failure lands + * in a row-scoped ErrorNotice with Dismiss and the agent hand-off + */ +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' + +import { initI18n } from '../src/i18n' +import { store } from '../src/store' +import DevFleetPage from '../src/pages/DevFleetPage' +import '../src/index.css' + +const params = new URLSearchParams(location.search) +const scene = params.get('scene') || 'adopted' +const theme = params.get('theme') || 'dark' + +document.documentElement.setAttribute('data-theme', theme === 'light' ? 'kiro-light' : 'kiro-dark') + +const NOW = Date.now() / 1000 + +const MAIN = { + name: 'main', is_main: true, running: false, has_dist: true, behind: 0, + branch: 'main', last_updated_at: NOW - 1800, +} + +// An ordinary feature worktree, present in every scene so the release row is +// always shown next to the row class it must be distinguishable from — a Behind +// cell counted from main (`↓12`) sitting above one that says the column does +// not apply. +const FEATURE = { + name: 'kirocrew-wt-update-freshness', is_main: false, running: false, + has_dist: true, behind: 12, branch: 'fix/update-freshness', + last_updated_at: NOW - 3600, + pr: { number: 9367, state: 'OPEN', url: 'https://github.com/kirodotdev/KiroCrew/pull/9367', isDraft: false }, +} + +// The reserved basename as a DETACHED checkout — behind-main is large by +// construction on a release worktree, which is exactly the number the row must +// not show. +const STABLE_DETACHED = { + name: 'release-channel-stable', is_main: false, running: false, + has_dist: true, behind: 412, last_updated_at: NOW - 86400 * 2, +} + +// The same basename as an ordinary BRANCH checkout: it keeps a branch, and its +// Behind is an ordinary behind-main figure. +const STABLE_ON_BRANCH = { + ...STABLE_DETACHED, behind: 5, branch: 'release-channel-stable', +} + +// A genuine git failure, quoted the way the backend passes it through rather +// than paraphrased: this is the class the page routes to the shared ErrorNotice +// instead of a cell or a Badge title, and the only class that blocks Create. Kept +// verbatim so the frame shows how a real one-clause git message sits in the row's +// width, which is what the short cell label plus full notice exist to handle. +const RESOLVER_ERROR = + "git ls-remote --tags: fatal: unable to access 'https://github.com/kirodotdev/KiroCrew/': Could not resolve host: github.com" + +const CHANNEL = { + lane: 'stable', + name: 'release-channel-stable', + worktree: 'release-channel-stable', + ref: 'refs/dev-fleet/release-tags/v0.5.0', + version: '0.5.0', + tip_version: '0.5.3', + error: null, + at_tip: false, + name_taken_by_branch: false, +} + +const SCENES: Record> = { + // Materialized lane. at_tip=false so the badge takes its warn variant and its + // TEXT names the newer release (`0.5.0 · latest 0.5.3`) — currency is legible + // without colour or hover. The payload carries no lane distance; the Behind cell is n/a. + adopted: { + base_branch: 'main', + worktrees: [MAIN, STABLE_DETACHED, FEATURE], + release_channel: CHANNEL, + }, + // The steady state a lane sits in for most of its life: the tree holds the + // channel tip. The badge flips to its ok variant, and that variant is what says + // "nothing to do here" — the Behind cell is the same n/a marker as in every + // other channel state, so a dash there can never be mistaken for "up to date + // with main". The frame the behind-tip warn variant cannot stand in for. + 'at-tip': { + base_branch: 'main', + worktrees: [MAIN, STABLE_DETACHED, FEATURE], + release_channel: { ...CHANNEL, version: '0.5.3', ref: 'refs/dev-fleet/release-tags/v0.5.3', at_tip: true }, + }, + // Adoption is by SHAPE (detached), not by being at a release, so an operator who + // checked out an arbitrary commit in the lane holds NO release. The badge must + // neither borrow the tip's version nor fall back to the lane word — `stable` on + // a row already named `release-channel-stable` reads as a version — so it reads + // "not on a release" — the TREE's condition, so it is not read as the same + // worry as a failed lookup — with the tip named only in its tooltip. + // `version: null` is the whole scene; the tip is still resolved and Behind + // still renders the n/a marker. + 'no-release': { + base_branch: 'main', + worktrees: [MAIN, STABLE_DETACHED, FEATURE], + release_channel: { ...CHANNEL, version: null, ref: 'refs/dev-fleet/release-tags/v0.5.3', at_tip: false }, + }, + // Lane with no worktree. The placeholder is the ONLY place the feature is + // discoverable — the design has no header control — so this frame is what + // settles whether a muted row with a live button reads as an offer. + placeholder: { + base_branch: 'main', + worktrees: [MAIN, FEATURE], + release_channel: { ...CHANNEL, worktree: null, at_tip: null }, + }, + // Nothing resolvable here yet — a shallow clone, or a fork before its first + // release. Benign and documented, so it reads as information and Create stays + // LIVE: Create fetches first, which is the action that resolves the state. No + // version pill renders because no ref resolved — the status line says it, and + // never the lane name. + unpublished: { + base_branch: 'main', + worktrees: [MAIN, FEATURE], + release_channel: { + ...CHANNEL, worktree: null, version: null, ref: null, tip_version: null, + at_tip: null, unpublished: true, + }, + }, + // The failure variant of the SAME placeholder row: git could not be read, so + // Create is disabled and the full message goes to the row's ErrorNotice while + // the cell keeps a short label it can ellipsise without cutting a clause. + 'blocked-placeholder': { + base_branch: 'main', + worktrees: [MAIN, FEATURE], + release_channel: { + ...CHANNEL, worktree: null, version: null, ref: null, tip_version: null, + at_tip: null, error: RESOLVER_ERROR, + }, + }, + // The second variant, which the placeholder frame cannot show: an ADOPTED row + // whose resolve() failed. The tree still holds a known release so the badge + // stays, but the tip is unknown — the row keeps its own facts and the failure + // routes to the same ErrorNotice rather than a Badge title. + 'blocked-adopted': { + base_branch: 'main', + worktrees: [MAIN, STABLE_DETACHED, FEATURE], + release_channel: { ...CHANNEL, tip_version: null, at_tip: null, error: RESOLVER_ERROR }, + }, + // The probe that reads which release the ADOPTED tree sits on failed — the + // `--points-at HEAD` listing itself, not the tip resolve, so the tip is still + // known. `version: null` alone would be the `no-release` state (a positive claim + // about the tree); `version_unknown: true` is what tells the badge to stop + // asserting and read "version unknown", with the backend's error in the row's + // ErrorNotice. The error text is the shape `fleet_state` wraps the probe's + // failure in. + 'version-unknown': { + base_branch: 'main', + worktrees: [MAIN, STABLE_DETACHED, FEATURE], + release_channel: { + ...CHANNEL, version: null, at_tip: false, version_unknown: true, + error: 'release-channel-stable: the release this tree sits on could not be read; refusing to name the release this tree holds', + }, + }, + // Reserved basename occupied by a branch checkout: NOT adopted. One directory + // is one row, so the explanation lands on the existing row and no second + // placeholder row prints the same name twice. + taken: { + base_branch: 'main', + worktrees: [MAIN, STABLE_ON_BRANCH, FEATURE], + release_channel: { ...CHANNEL, worktree: null, at_tip: null, name_taken_by_branch: true }, + }, + // The THIRD classification: the reserved directory is in the fleet (no branch, + // so not `taken`) but `git rev-parse HEAD` failed inside it, so the backend + // could classify it as neither adopted nor branch-occupied — `worktree: null`, + // `at_tip: null`, `name_taken_by_branch: false`, and the git failure in `error`. + // The page keys the placeholder off the NAME being present in the unfiltered + // fleet, so this renders as one ordinary row with the error under it, never a + // second Create row for a directory that exists. The error text is the shape + // git actually emits for a worktree whose gitdir pointer is dangling. + unreadable: { + base_branch: 'main', + worktrees: [MAIN, { ...STABLE_DETACHED, behind: 0, last_updated_at: NOW - 3600 }, FEATURE], + release_channel: { + ...CHANNEL, worktree: null, at_tip: null, name_taken_by_branch: false, + error: 'git rev-parse HEAD: fatal: not a git repository: ../KiroCrew/.git/worktrees/release-channel-stable', + }, + }, +} + +// What the create=* scenes start from and land on. The placeholder resolves AT the +// tip (the newest release is what Create checks out, so a fresh worktree is never +// behind), and success flips to the row that a real POST produces: the same +// directory, now detached at that tag, at_tip, and NOT yet provisioned — has_dist +// false is what puts the Provision button on the row, and that button is the +// control the toast names as "next". Reusing the ADOPTED payload here had put a +// behind-the-tip badge and has_dist:true under a toast pointing at a Provision +// button that did not exist. +const CREATE_PLACEHOLDER = { + base_branch: 'main', + worktrees: [MAIN, FEATURE], + release_channel: { ...CHANNEL, worktree: null, at_tip: null, version: '0.5.3', ref: 'refs/dev-fleet/release-tags/v0.5.3' }, +} +const CREATE_MATERIALIZED = { + base_branch: 'main', + worktrees: [MAIN, { ...STABLE_DETACHED, has_dist: false }, FEATURE], + release_channel: { ...CHANNEL, version: '0.5.3', ref: 'refs/dev-fleet/release-tags/v0.5.3', at_tip: true }, +} + +// Mutable on purpose: a successful Create is followed by a /fleet refetch, and the +// MATERIALIZED payload is what that refetch must serve for the placeholder to turn +// into the row the toast is describing. A create=* run starts from the at-tip +// placeholder whatever `scene` says, so the three outcome frames share one origin. +const create = params.get('create') +let FLEET = { ...(create ? CREATE_PLACEHOLDER : SCENES[scene] || SCENES.adopted), pods_available: true } + +// What the backend's `/release-channel/create` answers, by mode. The fail text is a +// real git refusal quoted verbatim — the class the handler prefixes with its own +// "Could not create…" clause — so the frame shows how a one-clause git message sits +// in the row-scoped notice, which is what the notice exists to hold. +const CREATE_RUN_ID = 'rc-capture-run' +const CREATE_STARTED = { ok: true, run_id: CREATE_RUN_ID } +const CREATE_OK = { ok: true, lane: 'stable', version: '0.5.3', ref: 'refs/dev-fleet/release-tags/v0.5.3' } +const CREATE_FAIL = { + ok: false, + code: 'git_failed', + error: "git worktree add: fatal: 'release-channel-stable' is a missing but already registered worktree; use 'add -f' to override, or 'prune' or 'remove' to clear", +} +// The run record as `/run?id=` serves it: `result` is the dict the create returned. +const RUN_RUNNING = { status: 'running', exit_code: null, output: [] } +const runDone = (result: { ok: boolean }) => ({ status: 'done', exit_code: result.ok ? 0 : 1, output: [], result }) + +const json = (body: unknown, status = 200) => + Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })) + +const realFetch = globalThis.fetch.bind(globalThis) +globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + // Before the `/api/` catch-all: the page's own base is `/apps/dev-fleet/api`, so + // the catch-all would otherwise answer this POST with `{}` — neither ok nor an + // error — and the harness would be photographing a state the backend never sends. + if (url.includes('/release-channel/create') && create) { + return json(CREATE_STARTED, 202) + } + if (url.includes('/run?id=' + CREATE_RUN_ID)) { + if (create === 'busy') return json(RUN_RUNNING) + if (create === 'ok') { + FLEET = { ...CREATE_MATERIALIZED, pods_available: true } + return json(runDone(CREATE_OK)) + } + if (create === 'fail') return json(runDone(CREATE_FAIL)) + } + if (url.includes('/fleet')) { + return json(FLEET) + } + if (url.includes('/disk')) { + return json({ total_mb: 51200 }) + } + if (url.includes('/api/')) { + return Promise.resolve(new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })) + } + return realFetch(input, init) +}) as typeof globalThis.fetch + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + +initI18n('en') +createRoot(document.getElementById('root')!).render( + + + +
+ +
+
+
+
, +) diff --git a/website/scripts/capture-devfleet-release-channel.mjs b/website/scripts/capture-devfleet-release-channel.mjs new file mode 100644 index 00000000000..c5a0e91a1dc --- /dev/null +++ b/website/scripts/capture-devfleet-release-channel.mjs @@ -0,0 +1,360 @@ +/** + * Screenshots of the Dev Fleet release-channel worktree rows (#23344). + * + * Drives the ISOLATED capture entry (website/capture/devfleet-release-channel.html), + * which mounts the REAL DevFleetPage with `fetch` stubbed at the network seam to + * serve the `/fleet` payload. Every state under review is decided by that payload, + * so each page load here IS the scenario the reviewer asked to see. + * + * Each scene asserts its headline state — and, where the claim is an ABSENCE, + * also asserts the thing that must not be there — before shooting, so this can + * never quietly emit a screenshot of an error boundary, a prerequisite gate, or + * the wrong row class. The last three scenes click Create first, since its + * outcomes are the one thing the payload cannot decide. The frames are then + * compared for distinct byte sizes: two identical frames mean the fixture + * failed, not that the states match. + * + * Usage: + * npx vite --host 127.0.0.1 --port 6812 --strictPort # in another shell + * node scripts/capture-devfleet-release-channel.mjs http://127.0.0.1:6812 ../temp-screenshots/release-channel + */ +import { chromium } from 'playwright' +import { mkdirSync, statSync } from 'node:fs' + +const BASE = process.argv[2] || 'http://127.0.0.1:6812' +const OUT = process.argv[3] || '../temp-screenshots/release-channel' +mkdirSync(OUT, { recursive: true }) + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }) + +const shot = [] + +// Whether Create is live is the entire difference between the benign row and the +// blocked one, and the two look alike in a still. Returns an assertion the scene +// runs before shooting, so a frame can never claim a state the run did not check. +// A tooltip is an attribute, not text, so `text=` cannot see it: assert it by title. +const titled = (title, want) => async (page, scene) => { + const n = await page.getByTitle(title, { exact: true }).count() + if (n !== want) throw new Error(`scene=${scene}: expected ${want} element(s) titled "${title}", found ${n}`) +} +// Every channel row renders BEHIND as the n/a marker with one tooltip, whatever +// its state, so the same assertion runs on the adopted, at-tip, unresolved and +// off-tag frames: exactly two n/a cells on the page (PR and Behind of the one +// channel row) and exactly one carrying the Behind tooltip. +const behindNa = async (page, scene) => { + const na = await page.getByText('n/a', { exact: true }).count() + if (na !== 2) throw new Error(`scene=${scene}: expected 2 n/a cells (PR + Behind), found ${na}`) + await titled('Release rows are measured by version — see the badge', 1)(page, scene) +} +const createEnabled = (want) => async (page, scene) => { + const btn = page.getByRole('button', { name: 'Create worktree' }) + const disabled = await btn.isDisabled() + if (disabled === want) { + throw new Error(`scene=${scene}: Create worktree must be ${want ? 'enabled' : 'disabled'}`) + } +} + +async function shoot(scene, mustSee, mustNotSee, name, assert) { + await page.goto(`${BASE}/capture/devfleet-release-channel.html?scene=${scene}&theme=dark`) + for (const text of mustSee) { + await page.waitForSelector(`text=${text}`, { timeout: 15000 }) + } + // Absence is only meaningful once the row itself is on screen, which the + // mustSee loop above has just established. + for (const text of mustNotSee) { + const n = await page.locator(`text=${text}`).count() + if (n !== 0) throw new Error(`scene=${scene}: "${text}" must NOT be present, found ${n}`) + } + // Some claims are about a control's STATE, not about text: whether Create is + // live is the whole difference between the benign and blocked rows, and a + // screenshot of a disabled button is indistinguishable from a muted one at a + // glance. Assert it here so the frame is evidence of a state the run verified. + if (assert) await assert(page, scene) + // Park the pointer away from the table: a hovered row raises its action + // toolbar over the row name in every one of these harnesses. + await page.mouse.move(4, 4) + await page.waitForTimeout(400) // let the relative timestamps settle + const path = `${OUT}/${name}` + await page.screenshot({ path, fullPage: false }) + shot.push({ name, size: statSync(path).size }) + console.log(`captured ${name}`) +} + +// The lane is materialized: the badge carries the release this checkout sits on +// AND, in its text, the newer release (`0.5.0 · latest 0.5.3`) — behind-the-tip +// is legible without the warn colour or a hover, and "latest" is the word an +// update banner uses, not git's "tip", which read as a branch head. The Behind +// cell is the n/a marker — never `↓3`, `↓3 newer` or `↓412`: the column counts +// commits behind main, a release row is measured by version, and the badge says so. +await shoot('adopted', ['release-channel-stable', '0.5.0 · latest 0.5.3'], ['↓3', '↓412', 'newer', 'tip 0.5'], + '01-release-channel-adopted-dark.png', behindNa) + +// The lane exists with no worktree: a muted placeholder row is the only surface +// the feature is discoverable from, and its Create button must read as live. The +// status line SAYS what Create does, naming the version in visible text, rather +// than leaving that to the version pill's tooltip. +await shoot('placeholder', ['release-channel-stable', 'No worktree yet — Create checks out 0.5.0', 'Create'], [], + '02-release-channel-placeholder-create-dark.png') + +// A BRANCH checkout occupying the reserved basename is NOT adopted: ordinary +// controls, an ordinary behind-main count, and in the name cell a muted status +// SENTENCE rather than a pill. Three pill wordings ("Not a release worktree", "Not +// the channel", "Name reserved — on a branch") each failed a cold read: a pill holds +// a verdict but not the situation and the way out, and the verdict alone read as +// the pin degrading. The sentence sits in the slot the placeholder row uses for its +// own status line and says all three things in visible text; the tooltip names the +// controls that exist (Prune merged, or git worktree move/remove). No warn Badge on +// the row: nothing is broken, it is a feature worktree with an unlucky name. +await shoot('taken', + ['release-channel-stable', '↓5', "On a branch, so the stable release can't use this name — rename or remove it to free it"], + ['0.5.0', 'Not a release worktree', 'Name reserved', 'Not the channel'], + '03-release-channel-name-taken-branch-dark.png', + async (page, scene) => { + const sentence = page.getByTestId('release-channel-name-taken-stable') + if ((await sentence.count()) !== 1) throw new Error(`scene=${scene}: expected one name-taken status sentence`) + const title = await sentence.getAttribute('title') + for (const want of ['Prune merged', 'git worktree remove']) { + if (!title || !title.includes(want)) throw new Error(`scene=${scene}: tooltip must name "${want}", got "${title}"`) + } + // The status sentence is a bare span, never inside a Badge: the row must carry + // no warn pill at all (a warn pill on this row is the verdict reading that + // failed). `bg-warn-subtle` is the warn variant's class in components/ui.tsx; + // the row is the sentence's nearest grid ancestor. + const row = sentence.locator('xpath=ancestor::div[contains(@style, "grid")][1]') + const warn = await row.locator('.bg-warn-subtle').count() + if (warn !== 0) throw new Error(`scene=${scene}: the name-taken row must carry no warn badge, found ${warn}`) + const inBadge = await sentence.locator('xpath=ancestor-or-self::*[contains(@class, "rounded-full")]').count() + if (inBadge !== 0) throw new Error(`scene=${scene}: the status sentence must not be rendered inside a Badge`) + }) + +// The steady state: the tree is AT the channel tip, so the badge takes its ok +// variant and the bare version — no `latest` suffix, which is the behind state's +// word (and no `tip`, the word it replaced). The Behind cell is the SAME n/a +// marker as in shot 01 — not a dash, which on every other row means "up to date +// with main" — so at-tip is legible from the badge alone; the warn variant in +// shot 01 cannot stand in for this. +await shoot('at-tip', ['release-channel-stable', '0.5.3'], ['↓0', '↓412', 'newer', 'latest 0.5', 'tip 0.5'], + '04-release-channel-at-tip-dark.png', behindNa) + +// Benign and documented, NOT an incident: nothing fetched here yet, said as plain +// information that names Create as the fetch, with Create still live and carrying +// the same answer as its tooltip, NO version pill (the status line is the whole +// statement; a "not on a release" pill restated it in the adopted off-tag +// badge's words), and no ErrorNotice anywhere. +await shoot('unpublished', ['release-channel-stable', 'Create fetches the newest one'], + ['could not be resolved', 'not on a release'], '05-release-channel-unpublished-create-live-dark.png', + async (page, scene) => { + await createEnabled(true)(page, scene) + await titled('Create fetches the stable release tags and checks out the newest one, detached', 1)(page, scene) + }) + +// The failure variant of that same row: Create disabled, the short label in the +// cell (the lane framed as a channel, not a bare `stable`), and the full git +// message in the row-scoped ErrorNotice below it. +await shoot('blocked-placeholder', + ['release-channel-stable', 'release channel could not be resolved', 'Could not resolve host'], [], + '06-release-channel-blocked-placeholder-dark.png', createEnabled(false)) + +// The second variant the placeholder frame cannot show: an ADOPTED row whose +// resolve() failed. It keeps the release its tree holds and routes the failure to +// the same ErrorNotice rather than to a Badge title, which is not keyboard-reachable. +// The badge is the bare version — no `latest ?`, no `latest` at all, since the +// tip is unknown and the tooltip says so. Its Behind cell is the same n/a marker +// as every other channel state — never a `?` cell and never a dash. +await shoot('blocked-adopted', + ['release-channel-stable', '0.5.0', 'Could not resolve host'], ['latest 0.5', 'latest ?', 'tip 0.5', 'tip ?'], + '07-release-channel-blocked-adopted-dark.png', + async (page, scene) => { + await behindNa(page, scene) + await titled('On 0.5.0; the stable channel tip could not be resolved', 1)(page, scene) + }) + +// Adoption is by shape, so a lane tree detached at NO release tag is still the +// lane's row. The version slot reads "not on a release" — the TREE's condition, +// so it is not read as the resolver-error row's worry, and never the bare lane +// word, which on a row already named `release-channel-stable` reads as a +// version — and the tip is named only in the tooltip; Behind is the n/a marker +// here too. +await shoot('no-release', + ['release-channel-stable', 'not on a release'], ['0.5.0', '0.5.3', '↓7', '↓412'], + '08-release-channel-no-release-dark.png', behindNa) + +// The probe that reads which release the adopted tree sits on FAILED. A null +// version alone is the frame above — a positive claim about the tree — so this +// row must say "version unknown" and nothing else in the slot: not "not on a +// release", not the tip, not a `?`. The failure is in the row's ErrorNotice, +// the sole error surface, and the badge's tooltip points there. +await shoot('version-unknown', + ['release-channel-stable', 'version unknown', 'could not be read'], + ['not on a release', '0.5.0', '0.5.3', 'latest 0.5', 'latest ?', '↓7', '↓412'], + '14-release-channel-version-unknown-dark.png', + async (page, scene) => { + await behindNa(page, scene) + await titled('The release this stable checkout sits on could not be read — see the notice below the row', 1)(page, scene) + const notice = page.getByTestId('release-channel-error-stable') + if ((await notice.count()) !== 1) throw new Error(`scene=${scene}: expected the row-scoped ErrorNotice`) + const text = await notice.innerText() + if (!text.includes('could not be read; refusing to name the release')) { + throw new Error(`scene=${scene}: the notice must carry the probe's error text, got "${text}"`) + } + }) + +// ── Create's three outcomes ────────────────────────────────────────────────── +// Every frame above is a payload the page rendered; these three are the CLICK. +// The button is the placeholder's only control and the feature's only entry +// point, so what it does while working, when it works, and when git refuses is +// the half of the row a still of the resting state cannot show. Each scene loads +// the placeholder with `create=` (the entry answers the POST with a run id +// and the run's record per mode), waits for the row, clicks the real button, +// asserts the outcome, then shoots. +const CREATE_URL = `${BASE}/capture/devfleet-release-channel.html?scene=placeholder&theme=dark` +const CREATE_BTN = { name: 'Create worktree' } + +// The create=* fixture resolves the placeholder AT the tip: Create checks out the +// newest release, so a fresh worktree is never behind, and the row it produces is +// the at-tip one (bare `0.5.3`, no `latest`) with Provision on it. +async function clickCreate(mode) { + await page.goto(`${CREATE_URL}&create=${mode}`) + await page.waitForSelector('text=No worktree yet — Create checks out 0.5.3', { timeout: 15000 }) + const btn = page.getByRole('button', CREATE_BTN) + if (await btn.isDisabled()) throw new Error(`create=${mode}: Create must be live before the click`) + await btn.click() +} + +async function shootAfterCreate(mode, name, assert) { + await assert(page, `placeholder&create=${mode}`) + await page.mouse.move(4, 4) + const path = `${OUT}/${name}` + await page.screenshot({ path, fullPage: false }) + shot.push({ name, size: statSync(path).size }) + console.log(`captured ${name}`) +} + +// In flight: the POST answers a run id at once and the run stays `running`, so +// the frame holds the busy state as long as it likes. The same button, now +// disabled, reads "Creating…" — and "Create worktree" is gone from the page, so a +// double click has nothing to land on. +await clickCreate('busy') +await shootAfterCreate('busy', '09-release-channel-create-busy-dark.png', async (page, scene) => { + const busy = page.getByRole('button', { name: 'Creating…', exact: true }) + await busy.waitFor({ timeout: 15000 }) + if (!(await busy.isDisabled())) throw new Error(`scene=${scene}: the busy button must be disabled`) + const live = await page.getByRole('button', CREATE_BTN).count() + if (live !== 0) throw new Error(`scene=${scene}: "Create worktree" must be gone while creating, found ${live}`) +}) + +// Success: the toast names the release the lane was created at and the next +// CONTROL by its own label ("next: Provision"), and the /fleet refetch that +// follows serves the freshly-materialized row — detached AT the tip (bare `0.5.3`, +// no `latest`: a just-created worktree cannot be behind the release it was just +// created at) and NOT yet provisioned, so the Provision button the toast points at +// is on the row under it. ToastHost dismisses after 4 s; the assertions run inside +// that window, so the toast is in the frame. +await clickCreate('ok') +await shootAfterCreate('ok', '10-release-channel-create-success-dark.png', async (page, scene) => { + await page.waitForSelector('text=Created stable worktree at 0.5.3', { timeout: 15000 }) + const toast = await page.locator('[role="status"]', { hasText: 'Created stable worktree' }).innerText() + if (!toast.includes('next: Provision')) throw new Error(`scene=${scene}: toast must name the next control, got "${toast}"`) + const badge = page.getByText('0.5.3', { exact: true }) + await badge.waitFor({ timeout: 15000 }) + if ((await badge.count()) !== 1) throw new Error(`scene=${scene}: expected one bare 0.5.3 badge, found ${await badge.count()}`) + // `latest 0.5`, not bare `latest`: the page's help paragraph uses the word. + for (const text of ['latest 0.5', 'tip 0.5', '0.5.0']) { + const n = await page.locator(`text=${text}`).count() + if (n !== 0) throw new Error(`scene=${scene}: "${text}" must NOT be present on the at-tip row, found ${n}`) + } + // The control the toast names must exist on the page, else "next: Provision" + // points at nothing. + const provision = await page.getByRole('button', { name: 'Provision', exact: true }).count() + if (provision !== 1) throw new Error(`scene=${scene}: expected one Provision button (the toast's "next"), found ${provision}`) + const placeholder = await page.getByTestId('release-channel-placeholder-stable').count() + if (placeholder !== 0) throw new Error(`scene=${scene}: the placeholder must be replaced by the materialized row, found ${placeholder}`) +}) + +// Refusal: git's own message, prefixed by what did not happen, in a row-scoped +// ErrorNotice — not a toast, which would self-dismiss and leave the unchanged +// placeholder as the only record. Dismiss and the agent hand-off are on it, and +// Create is live again so the retry is one click away. +await clickCreate('fail') +await shootAfterCreate('fail', '11-release-channel-create-failed-dark.png', async (page, scene) => { + const notice = page.getByTestId('release-channel-create-error-stable') + await notice.waitFor({ timeout: 15000 }) + const text = await notice.innerText() + for (const want of ['Could not create the release-channel worktree', 'missing but already registered worktree']) { + if (!text.includes(want)) throw new Error(`scene=${scene}: notice must contain "${want}", got "${text}"`) + } + for (const name of ['Dismiss', 'Ask the agent']) { + const n = await notice.getByRole('button', { name }).count() + if (n !== 1) throw new Error(`scene=${scene}: expected one "${name}" button on the notice, found ${n}`) + } + await createEnabled(true)(page, scene) +}) + +// ── Two more row states ────────────────────────────────────────────────────── + +// The reserved directory exists but its HEAD could not be read: neither adopted +// (no `worktree`) nor branch-occupied (no `name_taken_by_branch`). The placeholder +// is keyed off the NAME being in the fleet, so the directory is ONE ordinary row — +// no version badge (nothing is known about what it holds), no Create (the +// directory exists), no placeholder testid — and the git failure reaches the +// row's ErrorNotice with the agent hand-off, never a Badge title. +await shoot('unreadable', + ['release-channel-stable', 'not a git repository'], + ['0.5.0', '0.5.3', 'latest 0.5', 'No worktree yet', 'not on a release', 'Create worktree'], + '12-release-channel-unreadable-head-dark.png', + async (page, scene) => { + const names = await page.getByText('release-channel-stable', { exact: true }).count() + if (names !== 1) throw new Error(`scene=${scene}: one directory is one row; found ${names} elements named release-channel-stable`) + const create = await page.getByRole('button', { name: 'Create worktree' }).count() + if (create !== 0) throw new Error(`scene=${scene}: Create must not be offered for a directory that exists, found ${create}`) + const placeholder = await page.getByTestId('release-channel-placeholder-stable').count() + if (placeholder !== 0) throw new Error(`scene=${scene}: no placeholder row beside the real directory, found ${placeholder}`) + const notice = page.getByTestId('release-channel-error-stable') + if ((await notice.count()) !== 1) throw new Error(`scene=${scene}: expected the row-scoped ErrorNotice`) + const text = await notice.innerText() + if (!text.includes('git rev-parse HEAD: fatal: not a git repository')) { + throw new Error(`scene=${scene}: the notice must carry the backend's error text, got "${text}"`) + } + }) + +// The release row's "..." menu, open: Rebase onto main is SUPPRESSED on a release +// worktree (rebasing a detached tag onto main is not a coherent request, and the +// backend refuses it anyway), so the open menu is the only frame that shows the +// item's absence rather than the closed trigger. The other items stay, so the +// frame also shows this is a pruned menu, not an empty one. No mouse-park after +// opening: MenuBtn closes on an outside click/pointer move, and a closed menu is +// not the evidence. +await page.goto(`${BASE}/capture/devfleet-release-channel.html?scene=adopted&theme=dark`) +await page.waitForSelector('text=0.5.0 · latest 0.5.3', { timeout: 15000 }) +{ + const scene = 'menu-open' + // The release row is the grid row whose name cell reads release-channel-stable; + // its trailing actions cell holds the one "More actions" trigger on that row. + const row = page.locator('div', { has: page.getByText('release-channel-stable', { exact: true }) }).filter({ has: page.getByRole('button', { name: 'More actions' }) }).last() + const trigger = row.getByRole('button', { name: 'More actions' }) + if ((await trigger.count()) !== 1) throw new Error(`scene=${scene}: expected one "More actions" trigger on the release row, found ${await trigger.count()}`) + await trigger.click() + const menu = page.getByRole('menu', { name: 'More actions' }) + await menu.waitFor({ timeout: 15000 }) + // Menu items are Clickable divs with role="button". + const itemCount = await menu.getByRole('button').count() + if (itemCount < 1) throw new Error(`scene=${scene}: the open menu must contain at least one item`) + const rebase = await menu.getByText('Rebase onto main').count() + if (rebase !== 0) throw new Error(`scene=${scene}: Rebase onto main must be suppressed on the release row, found ${rebase}`) + const name = '13-release-channel-row-menu-open-dark.png' + const path = `${OUT}/${name}` + await page.waitForTimeout(300) + await page.screenshot({ path, fullPage: false }) + shot.push({ name, size: statSync(path).size }) + console.log(`captured ${name}`) +} + +// Two frames of identical size are what a silently-failed fixture looks like. +const sizes = new Set(shot.map((s) => s.size)) +if (sizes.size !== shot.length) { + throw new Error(`frames are not distinct: ${shot.map((s) => `${s.name}=${s.size}`).join(', ')}`) +} +console.log(shot.map((s) => `${s.name} ${s.size}B`).join('\n')) + +await browser.close() diff --git a/website/src/components/appstore/appManifest.ts b/website/src/components/appstore/appManifest.ts index b78a5aaf59b..e3d4bd5c00b 100644 --- a/website/src/components/appstore/appManifest.ts +++ b/website/src/components/appstore/appManifest.ts @@ -215,6 +215,7 @@ export const APP_MANIFEST_KEY: Record = { 'apps.devFleet.manifest.highlight_5', 'apps.devFleet.manifest.highlight_6', 'apps.devFleet.manifest.highlight_7', + 'apps.devFleet.manifest.highlight_8', ], useCases: ['apps.devFleet.manifest.use_case_1'], configuration: ['apps.devFleet.manifest.configuration_1'], diff --git a/website/src/i18n/en.context.json b/website/src/i18n/en.context.json index 49e4361cab6..224a588d12c 100644 --- a/website/src/i18n/en.context.json +++ b/website/src/i18n/en.context.json @@ -417,6 +417,14 @@ "pages.contextBreakdown.turn_button_chars": "Character count on the right of a session-start row: an already-formatted number followed by the unit word.", "pages.contextBreakdown.turn_latest": "Heading of the Context Breakdown detail section when the newest turn is selected: the turn number plus a marker meaning \"the most recent one\". A turn is one user message and the reply to it.", "pages.contextBreakdown.unit_chars": "Unit word shown right after the large character count of the selected turn in the Context Breakdown panel (\"2,400 characters\"): lowercase, plural, noun only.", + "pages.devFleetPage.not_applicable_short": "Dev Fleet's PR and Behind columns on a release-channel worktree row. Short for \"not applicable\": such a worktree is detached at a release tag, so it has no branch, can never have a pull request, and is not measured in commits behind main (its currency is the version badge). Distinct from the em dash used on other rows, which means \"no PR yet\" or \"up to date\". Must stay short — it sits in 64px- and 48px-wide grid columns.", + "pages.devFleetPage.release_channel_no_release": "Lowercase label inside a small version pill on a Dev Fleet release-channel row, in the slot where a version number like 0.5.0 normally sits, when the checkout is detached at a commit that is not a release tag. States the tree's condition — \"this tree is not sitting on a release\" — as distinct from a failed lookup; keep it short enough to fit a pill.", + "pages.devFleetPage.release_channel_version_unknown": "Lowercase label inside a small version pill on a Dev Fleet release-channel row, in the slot where a version number like 0.5.0 normally sits, when the probe that reads which release the checkout is on FAILED (timed out, could not read git refs). It must NOT say the checkout is on no release — that is a different, positive state with its own label (release_channel_no_release). Two short words: the version is unknown. The error itself is shown in a notice under the row.", + "pages.devFleetPage.release_channel_version_unknown_title": "Tooltip on that \"version unknown\" pill. {{lane}} is the release channel, e.g. \"stable\". Says the release could not be read and points at the error notice rendered below the row. Full sentence.", + "pages.devFleetPage.release_channel_version_behind_tip": "Text of a small version pill on a Dev Fleet release-channel row when a newer release has shipped: the version this checkout holds, a middle dot, then \"latest\" and the newest release's version, e.g. \"0.5.0 · latest 0.5.3\". \"latest\" is the everyday word for the newest published release — the same word an update banner uses — deliberately NOT git's \"tip\", which readers took for a branch head or for advice. Keep it pill-short.", + "pages.devFleetPage.reserved_name_on_a_branch": "Muted inline status sentence (NOT a badge or pill) in the worktree name cell of a Dev Fleet row whose directory NAME is the one reserved for a release-channel worktree, but whose checkout is on an ordinary git branch. Same slot and styling as the placeholder row's \"No worktree yet — Create checks out {{version}}\" line. It states the situation (this checkout is on a branch), the consequence (the {{lane}} release channel — e.g. \"stable\" — cannot use this name), and the way out (rename or remove the checkout). \"On a branch\" is about THIS checkout, not about the release. Nothing is broken on the row; keep the tone neutral, not a warning.", + "pages.devFleetPage.release_channel_name_taken_by_branch": "Tooltip on that status sentence, giving the full explanation. {{name}} is the worktree directory name (e.g. release-channel-stable); {{lane}} is the release channel, e.g. \"stable\". \"Prune merged\" is the label of an existing button on the Dev Fleet page — translate it exactly as the prune_merged key is translated in this catalog. `git worktree move` and `git worktree remove` are shell commands: keep them verbatim inside the backticks. The sentence must name only these real actions; the row has no Remove or Rename control of its own.", + "pages.devFleetPage.release_channel_created": "Success toast after Create on the Dev Fleet release-channel row. {{lane}} is the release channel (e.g. \"stable\"), {{ref}} the release it checked out (e.g. 0.11.0), and {{action}} is the label of the control to use next, inserted verbatim from the provision key (so the words in the toast are the words on the button). The word before {{action}} is a noun meaning \"the next step\", not the verb \"to continue\" and not an ordinal.", "pages.devFleetPage.mb": "The unit \"MB\" = megabytes, rendered after a numeric size.", "pages.devFleetPage.pod": "A Kiro Crew dev \"pod\" — an isolated development environment. Product terminology; see the do-not-translate glossary before translating.", "pages.devFleetPage.pod_2": "A Kiro Crew dev \"pod\" — an isolated development environment. Product terminology; see the do-not-translate glossary before translating.", diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 185ec96785b..1f7627aaaab 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -1750,7 +1750,8 @@ "highlight_5": "UI থেকেই pod-এর লগ স্ট্রিম করো আর pod অ্যাক্সেস টোকেন তৈরি করো", "highlight_6": "যারা Kiro Crew নিজেই ডেভেলপ করে তাদের জন্য বানানো — রোজকার ব্যবহারে দরকার নেই", "highlight_7": "Pod চলে Linux (systemd --user) ও macOS (launchd)-এ — তবে macOS pod-এ মেমরি/CPU-র কোনো বাধ্যতামূলক সীমা নেই; Make Live সব প্ল্যাটফর্মে পয়েন্টার স্টেজ করে, আর স্বয়ংক্রিয় রিস্টার্টের জন্য systemd --user ইউনিট বা macOS LaunchAgent লাগে; ফ্লিট ভিউ, PR অবস্থা, সিঙ্ক, rebase ও prune সব প্ল্যাটফর্মেই কাজ করে", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "সবচেয়ে নতুন স্টেবল রিলিজে পিন করা একটি worktree, যাতে তুমি তোমার ব্রাঞ্চের পাশেই একটি শিপড বিল্ডের pod চালাতে পারো; এতে তোমার নিজের ইনস্টল কোন চ্যানেল থেকে আপডেট হয় তা কখনো বদলায় না" } }, "designTweak": { @@ -13818,7 +13819,35 @@ "orphan_pods_label": "অনাথ পড হোম: {{value}}", "total_disk_used_by_running_pod_homes": "চলমান পড হোম দ্বারা ব্যবহৃত মোট ডিস্ক", "pod_homes_left_on_disk_with_no_live_pod": "কোনও সক্রিয় পড ছাড়াই ডিস্কে থাকা পড হোম", - "disk_usage_unavailable": "ডিস্ক ব্যবহার অনুপলব্ধ" + "disk_usage_unavailable": "ডিস্ক ব্যবহার অনুপলব্ধ", + "creating": "তৈরি হচ্ছে…", + "release_channel_unpublished": "{{lane}} রিলিজ এখনো আনা হয়নি — তৈরি করো সবচেয়ে নতুনটি আনবে", + "release_channel_create_fetches": "তৈরি করো {{lane}} রিলিজ ট্যাগ আনে এবং সবচেয়ে নতুন রিলিজটি detached অবস্থায় চেকআউট করে", + "release_channel_no_release": "কোনো রিলিজে নেই", + "release_channel_version_unknown": "সংস্করণ অজানা", + "release_channel_unresolved_short": "{{lane}} রিলিজ চ্যানেল নির্ধারণ করা যায়নি", + "no_worktree_yet": "এখনো কোনো worktree নেই", + "not_applicable_short": "প্রযোজ্য নয়", + "release_channel_has_no_pr": "রিলিজ worktree একটি ট্যাগে detached অবস্থায় থাকে, তাই এর কোনো পুল রিকোয়েস্ট থাকে না।", + "release_channel_create_failed": "রিলিজ চ্যানেলের worktree তৈরি করা যায়নি", + "release_channel_create_run_lost": "তৈরির রান হারিয়ে গেছে (গেটওয়ে রিস্টার্ট হয়েছে) — আবার চেষ্টার আগে ফ্লিট রিফ্রেশ করো", + "create_worktree": "worktree তৈরি করো", + "release_channel_created": "{{ref}}-এ {{lane}} worktree তৈরি হয়েছে — পরবর্তী: {{action}}", + "release_channel_would_create_at": "তৈরি করো সবচেয়ে নতুন {{lane}} রিলিজ {{version}} detached অবস্থায় চেকআউট করে", + "release_channel_pinned_at": "{{lane}} রিলিজ চ্যানেলের {{ref}}-এ পিন করা", + "release_channel_unresolved": "{{lane}} রিলিজ চ্যানেল নির্ধারণ করা যায়নি: {{error}}", + "release_channel_on_older_release": "{{version}}-এ আছে; {{lane}} চ্যানেলের সর্বশেষ রিলিজ এখন {{tip}}", + "release_channel_version_behind_tip": "{{version}} · সর্বশেষ {{tip}}", + "release_channel_on_release_tip_unknown": "{{version}}-এ আছে; {{lane}} চ্যানেলের সর্বশেষ রিলিজ নির্ধারণ করা যায়নি", + "release_channel_on_no_release": "{{lane}} রিলিজ ট্যাগে নেই; চ্যানেলের সর্বশেষ রিলিজ {{tip}}", + "release_channel_on_no_release_tip_unknown": "{{lane}} রিলিজ ট্যাগে নেই, এবং চ্যানেলের সর্বশেষ রিলিজও নির্ধারণ করা যায়নি", + "release_channel_version_unknown_title": "এই {{lane}} চেকআউট কোন রিলিজে আছে তা পড়া যায়নি — সারির নিচের নোটিশ দেখো", + "release_channel_name_taken_by_branch": "{{name}} {{lane}} রিলিজ worktree-র জন্য সংরক্ষিত, কিন্তু এই চেকআউট একটি ব্রাঞ্চে আছে। নাম খালি করতে চেকআউটের নাম বদলাও বা সরিয়ে দাও: মার্জ হওয়াগুলো ছাঁটাই করো এটির PR মার্জ হলে এটি সরিয়ে দেয়, অথবা শেলে `git worktree move` / `git worktree remove` চালাও", + "reserved_name_on_a_branch": "একটি ব্রাঞ্চে আছে, তাই {{lane}} রিলিজ এই নাম ব্যবহার করতে পারে না — নাম বদলাও বা সরিয়ে দাও যাতে এটি খালি হয়", + "release_rows_measured_by_version": "রিলিজ সারি সংস্করণ দিয়ে মাপা হয় — ব্যাজ দেখো", + "no_worktree_yet_create_checks_out": "এখনো কোনো worktree নেই — তৈরি করো {{version}} চেকআউট করে", + "rows_pending_one": "{{count}}টি worktree · {{pending}}টি তৈরি করতে হবে", + "rows_pending_other": "{{count}}টি worktree · {{pending}}টি তৈরি করতে হবে" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 983a045c1c1..86d8f4931bd 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -1978,7 +1978,8 @@ "highlight_7": "Pods laufen unter Linux (systemd --user) und macOS (launchd) — der macOS-Pod hat allerdings keine erzwungene Speicher-/CPU-Obergrenze; „Live schalten“ setzt den Zeiger auf jeder Plattform, ein automatischer Neustart braucht eine systemd --user-Unit oder einen macOS-LaunchAgent; Flottenansicht, PR-Status, Synchronisieren, Rebasen und Bereinigen funktionieren auf jeder Plattform", "page_label": "Dev Fleet", "use_case_1": "Verwalte Kiro-Crew-Feature-Worktrees und isolierte Vorschau-Pods, ohne das Live-Gateway zu verändern.", - "configuration_1": "Verweise die App auf einen Checkout von Kiro Crew; PR-Aktionen benötigen ein authentifiziertes gh, Pods benötigen systemd --user unter Linux oder launchd unter macOS und müssen zuerst provisioniert werden." + "configuration_1": "Verweise die App auf einen Checkout von Kiro Crew; PR-Aktionen benötigen ein authentifiziertes gh, Pods benötigen systemd --user unter Linux oder launchd unter macOS und müssen zuerst provisioniert werden.", + "highlight_8": "Ein Worktree, der auf das neueste stabile Release fixiert ist, damit du einen ausgelieferten Build neben deinem Branch als Pod starten kannst; welcher Channel deine eigene Installation aktualisiert, ändert sich dadurch nie" } }, "fileExplorer": { @@ -13818,7 +13819,35 @@ "orphan_pods_label": "Verwaiste Pod-Homes: {{value}}", "total_disk_used_by_running_pod_homes": "Gesamter von laufenden Pod-Homes genutzter Speicher", "pod_homes_left_on_disk_with_no_live_pod": "Pod-Homes auf der Festplatte ohne aktiven Pod", - "disk_usage_unavailable": "Speicherbelegung nicht verfügbar" + "disk_usage_unavailable": "Speicherbelegung nicht verfügbar", + "creating": "Wird erstellt…", + "release_channel_unpublished": "Noch kein {{lane}}-Release geholt — Erstellen holt das neueste", + "release_channel_create_fetches": "Erstellen holt die {{lane}}-Release-Tags und checkt das neueste detached aus", + "release_channel_no_release": "nicht auf einem Release", + "release_channel_version_unknown": "Version unbekannt", + "release_channel_unresolved_short": "Der {{lane}}-Release-Channel konnte nicht aufgelöst werden", + "no_worktree_yet": "noch kein Worktree", + "not_applicable_short": "k. A.", + "release_channel_has_no_pr": "Ein Release-Worktree ist detached auf einem Tag und hat daher keinen Pull Request", + "release_channel_create_failed": "Der Release-Channel-Worktree konnte nicht erstellt werden", + "release_channel_create_run_lost": "Die Erstellung ging verloren (Gateway neu gestartet) — Fleet aktualisieren, dann erneut versuchen", + "create_worktree": "Worktree erstellen", + "release_channel_created": "{{lane}}-Worktree auf {{ref}} erstellt — nächster Schritt: {{action}}", + "release_channel_would_create_at": "Erstellen checkt das neueste {{lane}}-Release, {{version}}, detached aus", + "release_channel_pinned_at": "Auf den {{lane}}-Release-Channel bei {{ref}} fixiert", + "release_channel_unresolved": "Der {{lane}}-Release-Channel konnte nicht aufgelöst werden: {{error}}", + "release_channel_on_older_release": "Auf {{version}}; die Spitze des {{lane}}-Channels ist jetzt {{tip}}", + "release_channel_version_behind_tip": "{{version}} · neueste {{tip}}", + "release_channel_on_release_tip_unknown": "Auf {{version}}; die Spitze des {{lane}}-Channels konnte nicht aufgelöst werden", + "release_channel_on_no_release": "Nicht auf einem {{lane}}-Release-Tag; die Spitze des Channels ist {{tip}}", + "release_channel_on_no_release_tip_unknown": "Nicht auf einem {{lane}}-Release-Tag, und die Spitze des Channels konnte nicht aufgelöst werden", + "release_channel_version_unknown_title": "Das Release, auf dem dieser {{lane}}-Checkout liegt, konnte nicht gelesen werden – siehe den Hinweis unter der Zeile", + "release_channel_name_taken_by_branch": "{{name}} ist für den {{lane}}-Release-Worktree reserviert, aber dieser Checkout liegt auf einem Branch. Um den Namen freizugeben, benenne den Checkout um oder entferne ihn: Zusammengeführte bereinigen entfernt ihn, sobald sein PR zusammengeführt ist, oder führe `git worktree move` / `git worktree remove` in einer Shell aus", + "reserved_name_on_a_branch": "Auf einem Branch, daher kann das {{lane}}-Release diesen Namen nicht nutzen — benenne ihn um oder entferne ihn, um ihn freizugeben", + "release_rows_measured_by_version": "Release-Zeilen werden nach Version gemessen — siehe das Badge", + "no_worktree_yet_create_checks_out": "Noch kein Worktree — Erstellen checkt {{version}} aus", + "rows_pending_one": "{{count}} Worktree · {{pending}} zu erstellen", + "rows_pending_other": "{{count}} Worktrees · {{pending}} zu erstellen" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 4ed5599ac3e..0c023a1ad76 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -1792,7 +1792,8 @@ "highlight_7": "[Þøðş ŕùñ øñ Ĺìñùẋ (şýşţèɱð --ùşèŕ) àñð ɱàçØŞ (ĺàùñçĥð) — ƀùţ ţĥè ɱàçØŞ þøð ĥàş ñø èñƒøŕçèð ɱèɱøŕý/ÇÞÙ çèìĺìñğ; Ṁàķè Ĺìṽè şţàğèş ìţş þøìñţèŕ øñ èṽèŕý þĺàţƒøŕɱ, àñð àùţøɱàţìç ŕèşţàŕţ ñèèðş à şýşţèɱð --ùşèŕ ùñìţ øŕ à ɱàçØŞ ĹàùñçĥÀğèñţ; ţĥè ƒĺèèţ ṽìèẁ, ÞŔ şţàţùş, şýñç, ŕèƀàşè àñð þŕùñè ẁøŕķ øñ àñý þĺàţƒøŕɱ ···························································································]", "page_label": "[Ðèṽ Ƒĺèèţ ··············]", "use_case_1": "[Øþèŕàţè Ķìŕø Çŕèẁ ƒèàţùŕè ẁøŕķţŕèèş àñð ìşøĺàţèð þŕèṽìèẁ þøðş ẁĥìĺè ķèèþìñğ ţĥè ĺìṽè ğàţèẁàý ùñţøùçĥèð. ·······························]", - "configuration_1": "[Þøìñţ ţĥè àþþ àţ à Ķìŕø Çŕèẁ çĥèçķøùţ; ÞŔ àçţìøñş ñèèð àùţĥèñţìçàţèð ğĥ, ẁĥìĺè þøðş ñèèð şýşţèɱð --ùşèŕ øñ Ĺìñùẋ øŕ ĺàùñçĥð øñ ɱàçØŞ àñð ɱùşţ ƀè þŕøṽìşìøñèð ƒìŕşţ. ·················································]" + "configuration_1": "[Þøìñţ ţĥè àþþ àţ à Ķìŕø Çŕèẁ çĥèçķøùţ; ÞŔ àçţìøñş ñèèð àùţĥèñţìçàţèð ğĥ, ẁĥìĺè þøðş ñèèð şýşţèɱð --ùşèŕ øñ Ĺìñùẋ øŕ ĺàùñçĥð øñ ɱàçØŞ àñð ɱùşţ ƀè þŕøṽìşìøñèð ƒìŕşţ. ·················································]", + "highlight_8": "[À ẁøŕķţŕèè þìññèð ţø ţĥè ñèẁèşţ şţàƀĺè ŕèĺèàşè, şø ýøù çàñ þøð à şĥìþþèð ƀùìĺð ƀèşìðè ýøùŕ ƀŕàñçĥ; ìţ ñèṽèŕ çĥàñğèş ẁĥìçĥ çĥàññèĺ ýøùŕ øẁñ ìñşţàĺĺ ùþðàţèş ƒŕøɱ ················································]" } }, "fileExplorer": { @@ -13372,7 +13373,35 @@ "no_checkout_found_help": "[Ðèṽ Ƒĺèèţ ɱàñàğèş ţĥè ğìţ ẁøŕķţŕèèş øƒ à {{productName}} şøùŕçè çĥèçķøùţ. Ñøñè ẁàş ƒøùñð ìñ ýøùŕ þŕøĵèçţ ðìŕèçţøŕý, ìñ ţĥè çĥèçķøùţ şèŕṽìñğ ţĥìş ðàşĥƀøàŕð, øŕ ìñ ţĥè ùşùàĺ çĺøñè ĺøçàţìøñş. ····················································]", "no_checkout_found_env_example": "[ĶÌŔØÇŔÈẀ_ÐÈṼƑĹÈÈŢ_ŔÈÞØ=/þàţĥ/ţø/ýøùŕ/çĥèçķøùţ ·······················]", "no_checkout_found_action": "[Àðð ðèṽ_ƒĺèèţ.ŕèþø_þàţĥ ţø çøñƒìğ.ĵşøñ ìñ ýøùŕ {{productName}} ðàţà ðìŕèçţøŕý (~/.ķìŕø/çŕèẁ ƀý ðèƒàùĺţ), àñð ţĥìş þàğè þìçķş ìţ ùþ øñ ìţş ñèẋţ ŕèƒŕèşĥ. Ţĥè èñṽìŕøñɱèñţ ṽàŕìàƀĺè ƀèĺøẁ ẁøŕķş ţøø, ƀùţ à ŕùññìñğ ğàţèẁàý çàññøţ şèè à ṽàŕìàƀĺè şèţ àƒţèŕ ìţ şţàŕţèð, şø ţĥàţ ŕøùţè ñèèðş à ŕèşţàŕţ. ···················································································]", - "disk_usage_unavailable": "[Ðìşķ ùşàğè ùñàṽàìĺàƀĺè ···············]" + "disk_usage_unavailable": "[Ðìşķ ùşàğè ùñàṽàìĺàƀĺè ···············]", + "creating": "[Çŕèàţìñğ… ··············]", + "release_channel_unpublished": "[Ñø {{lane}} ŕèĺèàşè ƒèţçĥèð ýèţ — Çŕèàţè ƒèţçĥèş ţĥè ñèẁèşţ øñè ···················]", + "release_channel_create_fetches": "[Çŕèàţè ƒèţçĥèş ţĥè {{lane}} ŕèĺèàşè ţàğş àñð çĥèçķş øùţ ţĥè ñèẁèşţ øñè, ðèţàçĥèð ······················]", + "release_channel_no_release": "[ñøţ øñ à ŕèĺèàşè ··············]", + "release_channel_version_unknown": "[ṽèŕşìøñ ùñķñøẁñ ··············]", + "release_channel_unresolved_short": "[Ţĥè {{lane}} ŕèĺèàşè çĥàññèĺ çøùĺð ñøţ ƀè ŕèşøĺṽèð ·····················]", + "no_worktree_yet": "[ñø ẁøŕķţŕèè ýèţ ··············]", + "not_applicable_short": "[ñ/à ·····]", + "release_channel_has_no_pr": "[À ŕèĺèàşè ẁøŕķţŕèè ìş ðèţàçĥèð àţ à ţàğ, şø ìţ ĥàş ñø þùĺĺ ŕèǫùèşţ ·······················]", + "release_channel_create_failed": "[Çøùĺð ñøţ çŕèàţè ţĥè ŕèĺèàşè-çĥàññèĺ ẁøŕķţŕèè ·······················]", + "release_channel_create_run_lost": "[Ĺøşţ ţŕàçķ øƒ ţĥè çŕèàţè ŕùñ (ţĥè ğàţèẁàý ŕèşţàŕţèð) — ŕèƒŕèşĥ ţĥè ƒĺèèţ ƀèƒøŕè ţŕýìñğ àğàìñ ····························]", + "create_worktree": "[Çŕèàţè ẁøŕķţŕèè ··············]", + "release_channel_created": "[Çŕèàţèð {{lane}} ẁøŕķţŕèè àţ {{ref}} — ñèẋţ: {{action}} ·····················]", + "release_channel_would_create_at": "[Çŕèàţè çĥèçķş øùţ ţĥè ñèẁèşţ {{lane}} ŕèĺèàşè, {{version}}, ðèţàçĥèð ·························]", + "release_channel_pinned_at": "[Þìññèð ţø ţĥè {{lane}} ŕèĺèàşè çĥàññèĺ àţ {{ref}} ·················]", + "release_channel_unresolved": "[Ţĥè {{lane}} ŕèĺèàşè çĥàññèĺ çøùĺð ñøţ ƀè ŕèşøĺṽèð: {{error}} ······················]", + "release_channel_on_older_release": "[Øñ {{version}}; ţĥè {{lane}} çĥàññèĺ ţìþ ìş ñøẁ {{tip}} ····················]", + "release_channel_version_behind_tip": "[{{version}} · ĺàţèşţ {{tip}} ···············]", + "release_channel_on_release_tip_unknown": "[Øñ {{version}}; ţĥè {{lane}} çĥàññèĺ ţìþ çøùĺð ñøţ ƀè ŕèşøĺṽèð ······················]", + "release_channel_on_no_release": "[Ñøţ øñ à {{lane}} ŕèĺèàşè ţàğ; ţĥè çĥàññèĺ ţìþ ìş {{tip}} ·····················]", + "release_channel_on_no_release_tip_unknown": "[Ñøţ øñ à {{lane}} ŕèĺèàşè ţàğ, àñð ţĥè çĥàññèĺ ţìþ çøùĺð ñøţ ƀè ŕèşøĺṽèð ······················]", + "release_channel_version_unknown_title": "[Ţĥè ŕèĺèàşè ţĥìş {{lane}} çĥèçķøùţ şìţş øñ çøùĺð ñøţ ƀè ŕèàð — şèè ţĥè ñøţìçè ƀèĺøẁ ţĥè ŕøẁ ·························]", + "release_channel_name_taken_by_branch": "[{{name}} ìş ŕèşèŕṽèð ƒøŕ ţĥè {{lane}} ŕèĺèàşè ẁøŕķţŕèè, ƀùţ ţĥìş çĥèçķøùţ ìş øñ à ƀŕàñçĥ. Ţø ƒŕèè ţĥè ñàɱè, ŕèñàɱè øŕ ŕèɱøṽè ţĥè çĥèçķøùţ: Þŕùñè ɱèŕğèð ŕèɱøṽèş ìţ øñçè ìţş ÞŔ ĥàş ɱèŕğèð, øŕ ŕùñ `ğìţ ẁøŕķţŕèè ɱøṽè` / `ğìţ ẁøŕķţŕèè ŕèɱøṽè` ìñ à şĥèĺĺ ······································································]", + "reserved_name_on_a_branch": "[Øñ à ƀŕàñçĥ, şø ţĥè {{lane}} ŕèĺèàşè çàñ'ţ ùşè ţĥìş ñàɱè — ŕèñàɱè øŕ ŕèɱøṽè ìţ ţø ƒŕèè ìţ ························]", + "release_rows_measured_by_version": "[Ŕèĺèàşè ŕøẁş àŕè ɱèàşùŕèð ƀý ṽèŕşìøñ — şèè ţĥè ƀàðğè ··················]", + "no_worktree_yet_create_checks_out": "[Ñø ẁøŕķţŕèè ýèţ — Çŕèàţè çĥèçķş øùţ {{version}} ··················]", + "rows_pending_one": "[{{count}} ẁøŕķţŕèè · {{pending}} ţø çŕèàţè ···············]", + "rows_pending_other": "[{{count}} ẁøŕķţŕèèş · {{pending}} ţø çŕèàţè ················]" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index 47230d2a33a..0e1417eee08 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -1066,7 +1066,8 @@ "highlight_7": "Pods run on Linux (systemd --user) and macOS (launchd) — but the macOS pod has no enforced memory/CPU ceiling; Make Live stages its pointer on every platform, and automatic restart needs a systemd --user unit or a macOS LaunchAgent; the fleet view, PR status, sync, rebase and prune work on any platform", "page_label": "Dev Fleet", "use_case_1": "Operate Kiro Crew feature worktrees and isolated preview pods while keeping the live gateway untouched.", - "configuration_1": "Point the app at a Kiro Crew checkout; PR actions need authenticated gh, while pods need systemd --user on Linux or launchd on macOS and must be provisioned first." + "configuration_1": "Point the app at a Kiro Crew checkout; PR actions need authenticated gh, while pods need systemd --user on Linux or launchd on macOS and must be provisioned first.", + "highlight_8": "A worktree pinned to the newest stable release, so you can pod a shipped build beside your branch; it never changes which channel your own install updates from" } }, "fileExplorer": { diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json index dfe30479ebb..a46820213a2 100644 --- a/website/src/i18n/locales/en.manual.json +++ b/website/src/i18n/locales/en.manual.json @@ -6496,7 +6496,35 @@ "no_checkout_found_help": "Dev Fleet manages the git worktrees of a {{productName}} source checkout. None was found in your project directory, in the checkout serving this dashboard, or in the usual clone locations.", "no_checkout_found_env_example": "KIROCREW_DEVFLEET_REPO=/path/to/your/checkout", "no_checkout_found_action": "Add dev_fleet.repo_path to config.json in your {{productName}} data directory (~/.kiro/crew by default), and this page picks it up on its next refresh. The environment variable below works too, but a running gateway cannot see a variable set after it started, so that route needs a restart.", - "disk_usage_unavailable": "Disk usage unavailable" + "disk_usage_unavailable": "Disk usage unavailable", + "creating": "Creating…", + "release_channel_unpublished": "No {{lane}} release fetched yet — Create fetches the newest one", + "release_channel_create_fetches": "Create fetches the {{lane}} release tags and checks out the newest one, detached", + "release_channel_no_release": "not on a release", + "release_channel_version_unknown": "version unknown", + "release_channel_unresolved_short": "The {{lane}} release channel could not be resolved", + "no_worktree_yet": "no worktree yet", + "not_applicable_short": "n/a", + "release_channel_has_no_pr": "A release worktree is detached at a tag, so it has no pull request", + "release_channel_create_failed": "Could not create the release-channel worktree", + "release_channel_create_run_lost": "Lost track of the create run (the gateway restarted) — refresh the fleet before trying again", + "create_worktree": "Create worktree", + "release_channel_created": "Created {{lane}} worktree at {{ref}} — next: {{action}}", + "release_channel_would_create_at": "Create checks out the newest {{lane}} release, {{version}}, detached", + "release_channel_pinned_at": "Pinned to the {{lane}} release channel at {{ref}}", + "release_channel_unresolved": "The {{lane}} release channel could not be resolved: {{error}}", + "release_channel_on_older_release": "On {{version}}; the {{lane}} channel tip is now {{tip}}", + "release_channel_version_behind_tip": "{{version}} · latest {{tip}}", + "release_channel_on_release_tip_unknown": "On {{version}}; the {{lane}} channel tip could not be resolved", + "release_channel_on_no_release": "Not on a {{lane}} release tag; the channel tip is {{tip}}", + "release_channel_on_no_release_tip_unknown": "Not on a {{lane}} release tag, and the channel tip could not be resolved", + "release_channel_version_unknown_title": "The release this {{lane}} checkout sits on could not be read — see the notice below the row", + "release_channel_name_taken_by_branch": "{{name}} is reserved for the {{lane}} release worktree, but this checkout is on a branch. To free the name, rename or remove the checkout: Prune merged removes it once its PR has merged, or run `git worktree move` / `git worktree remove` in a shell", + "reserved_name_on_a_branch": "On a branch, so the {{lane}} release can't use this name — rename or remove it to free it", + "release_rows_measured_by_version": "Release rows are measured by version — see the badge", + "no_worktree_yet_create_checks_out": "No worktree yet — Create checks out {{version}}", + "rows_pending_one": "{{count}} worktree · {{pending}} to create", + "rows_pending_other": "{{count}} worktrees · {{pending}} to create" }, "developerPage": { "tabs": { diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index 05f44fa7924..dd4a0091dbb 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -1798,7 +1798,8 @@ "highlight_7": "Los pods corren en Linux (systemd --user) y macOS (launchd) — pero el pod de macOS no tiene un límite de memoria/CPU aplicado; Activar prepara el puntero en todas las plataformas, y el reinicio automático requiere una unidad systemd --user o un LaunchAgent de macOS; la vista de flota, el estado del PR, la sincronización, el rebase y la purga funcionan en cualquier plataforma", "page_label": "Dev Fleet", "use_case_1": "Gestiona los worktrees de funciones de Kiro Crew y pods de vista previa aislados sin alterar el gateway en producción.", - "configuration_1": "Apunta la app a un checkout de Kiro Crew; las acciones de PR necesitan gh autenticado, mientras que los pods requieren systemd --user en Linux o launchd en macOS y deben aprovisionarse primero." + "configuration_1": "Apunta la app a un checkout de Kiro Crew; las acciones de PR necesitan gh autenticado, mientras que los pods requieren systemd --user en Linux o launchd en macOS y deben aprovisionarse primero.", + "highlight_8": "Un worktree fijado a la release estable más reciente, para levantar un pod con una build publicada junto a tu rama; nunca cambia el canal desde el que se actualiza tu propia instalación" } }, "designTweak": { @@ -14052,7 +14053,36 @@ "orphan_pods_label": "Homes de pods huérfanos: {{value}}", "total_disk_used_by_running_pod_homes": "Disco total usado por los homes de pods en ejecución", "pod_homes_left_on_disk_with_no_live_pod": "Homes de pods que quedan en disco sin un pod activo", - "disk_usage_unavailable": "Uso de disco no disponible" + "disk_usage_unavailable": "Uso de disco no disponible", + "creating": "Creando…", + "release_channel_unpublished": "Aún no se ha obtenido ninguna release {{lane}} — Crear obtiene la más reciente", + "release_channel_create_fetches": "Crear obtiene las etiquetas de release {{lane}} y hace checkout de la más reciente en modo detached", + "release_channel_no_release": "no está en una release", + "release_channel_version_unknown": "versión desconocida", + "release_channel_unresolved_short": "No se pudo resolver el canal de releases {{lane}}", + "no_worktree_yet": "aún sin worktree", + "not_applicable_short": "n/d", + "release_channel_has_no_pr": "Un worktree de release está en detached sobre una etiqueta, así que no tiene pull request", + "release_channel_create_failed": "No se pudo crear el worktree del canal de releases", + "release_channel_create_run_lost": "Se perdió la ejecución de la creación (el gateway se reinició) — actualiza la flota antes de volver a intentarlo", + "create_worktree": "Crear worktree", + "release_channel_created": "Worktree {{lane}} creado en {{ref}} — siguiente: {{action}}", + "release_channel_would_create_at": "Crear hace checkout de la release {{lane}} más reciente, {{version}}, en modo detached", + "release_channel_pinned_at": "Fijado al canal de releases {{lane}} en {{ref}}", + "release_channel_unresolved": "No se pudo resolver el canal de releases {{lane}}: {{error}}", + "release_channel_on_older_release": "En {{version}}; la punta del canal {{lane}} ahora es {{tip}}", + "release_channel_version_behind_tip": "{{version}} · última {{tip}}", + "release_channel_on_release_tip_unknown": "En {{version}}; no se pudo resolver la punta del canal {{lane}}", + "release_channel_on_no_release": "No está en una etiqueta de release {{lane}}; la punta del canal es {{tip}}", + "release_channel_on_no_release_tip_unknown": "No está en una etiqueta de release {{lane}} y no se pudo resolver la punta del canal", + "release_channel_version_unknown_title": "No se pudo leer la release en la que está este checkout {{lane}}; mira el aviso debajo de la fila", + "release_channel_name_taken_by_branch": "{{name}} está reservado para el worktree de release {{lane}}, pero este checkout está en una rama. Para liberar el nombre, renombra o elimina el checkout: Purgar fusionados lo elimina en cuanto su PR se fusiona, o ejecuta `git worktree move` / `git worktree remove` en una shell", + "reserved_name_on_a_branch": "En una rama, así que la release {{lane}} no puede usar este nombre — renómbralo o elimínalo para liberarlo", + "release_rows_measured_by_version": "Las filas de release se miden por versión — mira la insignia", + "no_worktree_yet_create_checks_out": "Aún sin worktree — Crear hace checkout de {{version}}", + "rows_pending_one": "{{count}} worktree · {{pending}} por crear", + "rows_pending_many": "{{count}} worktrees · {{pending}} por crear", + "rows_pending_other": "{{count}} worktrees · {{pending}} por crear" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index d059fddf66b..b84c5ab7188 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -2027,7 +2027,8 @@ "highlight_7": "Les pods tournent sur Linux (systemd --user) et macOS (launchd) — mais le pod macOS n’a aucun plafond mémoire/CPU imposé ; « Mettre en service » prépare le pointeur sur toutes les plateformes, et le redémarrage automatique exige une unité systemd --user ou un LaunchAgent macOS ; la vue de flotte, l’état des PR, la synchronisation, le rebase et l’élagage fonctionnent sur toutes les plateformes", "page_label": "Dev Fleet", "use_case_1": "Gère les worktrees de fonctionnalités de Kiro Crew et des pods de prévisualisation isolés sans toucher à la passerelle en production.", - "configuration_1": "Indique à l'app un checkout Kiro Crew ; les actions sur les PR nécessitent une CLI gh authentifiée, tandis que les pods exigent systemd --user sous Linux ou launchd sous macOS et doivent d'abord être provisionnés." + "configuration_1": "Indique à l'app un checkout Kiro Crew ; les actions sur les PR nécessitent une CLI gh authentifiée, tandis que les pods exigent systemd --user sous Linux ou launchd sous macOS et doivent d'abord être provisionnés.", + "highlight_8": "Un worktree épinglé à la release stable la plus récente, pour lancer un pod sur une build publiée à côté de ta branche ; cela ne change jamais le canal depuis lequel ton installation se met à jour" } }, "fileExplorer": { @@ -14052,7 +14053,36 @@ "orphan_pods_label": "Homes de pods orphelins : {{value}}", "total_disk_used_by_running_pod_homes": "Disque total utilisé par les homes de pods en cours d'exécution", "pod_homes_left_on_disk_with_no_live_pod": "Homes de pods restés sur le disque sans pod actif", - "disk_usage_unavailable": "Utilisation du disque indisponible" + "disk_usage_unavailable": "Utilisation du disque indisponible", + "creating": "Création…", + "release_channel_unpublished": "Aucune release {{lane}} récupérée pour l’instant — Créer récupère la plus récente", + "release_channel_create_fetches": "Créer récupère les tags de release {{lane}} et extrait la plus récente en mode detached", + "release_channel_no_release": "pas sur une release", + "release_channel_version_unknown": "version inconnue", + "release_channel_unresolved_short": "Le canal de release {{lane}} n’a pas pu être résolu", + "no_worktree_yet": "pas encore de worktree", + "not_applicable_short": "s. o.", + "release_channel_has_no_pr": "Un worktree de release est detached sur un tag, il n’a donc pas de pull request", + "release_channel_create_failed": "Le worktree du canal de release n’a pas pu être créé", + "release_channel_create_run_lost": "L’exécution de la création a été perdue (la passerelle a redémarré) — actualise la flotte avant de réessayer", + "create_worktree": "Créer un worktree", + "release_channel_created": "Worktree {{lane}} créé sur {{ref}} — ensuite : {{action}}", + "release_channel_would_create_at": "Créer extrait la release {{lane}} la plus récente, {{version}}, en mode detached", + "release_channel_pinned_at": "Épinglé au canal de release {{lane}} sur {{ref}}", + "release_channel_unresolved": "Le canal de release {{lane}} n’a pas pu être résolu : {{error}}", + "release_channel_on_older_release": "Sur {{version}} ; la pointe du canal {{lane}} est maintenant {{tip}}", + "release_channel_version_behind_tip": "{{version}} · dernière {{tip}}", + "release_channel_on_release_tip_unknown": "Sur {{version}} ; la pointe du canal {{lane}} n’a pas pu être résolue", + "release_channel_on_no_release": "Pas sur un tag de release {{lane}} ; la pointe du canal est {{tip}}", + "release_channel_on_no_release_tip_unknown": "Pas sur un tag de release {{lane}}, et la pointe du canal n’a pas pu être résolue", + "release_channel_version_unknown_title": "La release sur laquelle se trouve ce checkout {{lane}} n’a pas pu être lue ; voir l’avis sous la ligne", + "release_channel_name_taken_by_branch": "{{name}} est réservé au worktree de release {{lane}}, mais ce checkout est sur une branche. Pour libérer le nom, renomme ou supprime le checkout : Élaguer les fusionnés le supprime dès que sa PR est fusionnée, ou lance `git worktree move` / `git worktree remove` dans un shell", + "reserved_name_on_a_branch": "Sur une branche, donc la release {{lane}} ne peut pas utiliser ce nom — renomme-le ou supprime-le pour le libérer", + "release_rows_measured_by_version": "Les lignes de release se mesurent à la version — voir le badge", + "no_worktree_yet_create_checks_out": "Pas encore de worktree — Créer extrait {{version}}", + "rows_pending_one": "{{count}} worktree · {{pending}} à créer", + "rows_pending_many": "{{count}} worktrees · {{pending}} à créer", + "rows_pending_other": "{{count}} worktrees · {{pending}} à créer" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 36b66d4620d..5e27466574b 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -1750,7 +1750,8 @@ "highlight_5": "UI से पॉड लॉग स्ट्रीम करो और पॉड ऐक्सेस टोकन बनाओ", "highlight_6": "खुद Kiro Crew बनाने वालों के लिए — रोज़मर्रा के इस्तेमाल के लिए ज़रूरी नहीं", "highlight_7": "पॉड Linux (systemd --user) और macOS (launchd) पर चलते हैं — लेकिन macOS पॉड पर मेमोरी/CPU की कोई तय सीमा लागू नहीं होती; Make Live हर प्लैटफ़ॉर्म पर पॉइंटर स्टेज करता है, और अपने-आप रीस्टार्ट के लिए systemd --user यूनिट या macOS LaunchAgent चाहिए; फ़्लीट व्यू, PR स्थिति, सिंक, रीबेस और प्रून हर प्लैटफ़ॉर्म पर काम करते हैं", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "सबसे नई स्टेबल रिलीज़ पर पिन की गई एक वर्कट्री, जिससे तुम अपनी ब्रांच के साथ-साथ एक शिप्ड बिल्ड का पॉड चला सको; इससे यह कभी नहीं बदलता कि तुम्हारा अपना इंस्टॉल किस चैनल से अपडेट होता है" } }, "designTweak": { @@ -13818,7 +13819,35 @@ "orphan_pods_label": "अनाथ पॉड होम: {{value}}", "total_disk_used_by_running_pod_homes": "चल रहे पॉड होम द्वारा उपयोग की गई कुल डिस्क", "pod_homes_left_on_disk_with_no_live_pod": "बिना किसी सक्रिय पॉड के डिस्क पर बचे पॉड होम", - "disk_usage_unavailable": "डिस्क उपयोग उपलब्ध नहीं" + "disk_usage_unavailable": "डिस्क उपयोग उपलब्ध नहीं", + "creating": "बना रहे हैं…", + "release_channel_unpublished": "{{lane}} रिलीज़ अभी तक नहीं ली गई — बनाओ सबसे नई रिलीज़ ले आता है", + "release_channel_create_fetches": "बनाओ {{lane}} रिलीज़ टैग लाता है और सबसे नई रिलीज़ को detached में चेकआउट करता है", + "release_channel_no_release": "किसी रिलीज़ पर नहीं", + "release_channel_version_unknown": "संस्करण अज्ञात", + "release_channel_unresolved_short": "{{lane}} रिलीज़ चैनल हल नहीं हो सका", + "no_worktree_yet": "अभी कोई वर्कट्री नहीं", + "not_applicable_short": "लागू नहीं", + "release_channel_has_no_pr": "रिलीज़ वर्कट्री एक टैग पर detached रहती है, इसलिए उसका कोई पुल रिक्वेस्ट नहीं होता।", + "release_channel_create_failed": "रिलीज़ चैनल की वर्कट्री नहीं बनाई जा सकी", + "release_channel_create_run_lost": "बनाने का रन खो गया (गेटवे पुनः आरंभ हुआ) — दोबारा कोशिश से पहले फ़्लीट रीफ़्रेश करो", + "create_worktree": "वर्कट्री बनाओ", + "release_channel_created": "{{ref}} पर {{lane}} वर्कट्री बन गई — आगे: {{action}}", + "release_channel_would_create_at": "बनाओ सबसे नई {{lane}} रिलीज़ {{version}} को detached में चेकआउट करता है", + "release_channel_pinned_at": "{{lane}} रिलीज़ चैनल के {{ref}} पर पिन किया गया", + "release_channel_unresolved": "{{lane}} रिलीज़ चैनल हल नहीं हो सका: {{error}}", + "release_channel_on_older_release": "{{version}} पर है; {{lane}} चैनल की नई रिलीज़ अब {{tip}} है", + "release_channel_version_behind_tip": "{{version}} · नवीनतम {{tip}}", + "release_channel_on_release_tip_unknown": "{{version}} पर है; {{lane}} चैनल की नई रिलीज़ हल नहीं हो सकी", + "release_channel_on_no_release": "{{lane}} रिलीज़ टैग पर नहीं है; चैनल की नई रिलीज़ {{tip}} है", + "release_channel_on_no_release_tip_unknown": "{{lane}} रिलीज़ टैग पर नहीं है, और चैनल की नई रिलीज़ भी हल नहीं हो सकी", + "release_channel_version_unknown_title": "यह {{lane}} चेकआउट किस रिलीज़ पर है, यह पढ़ा नहीं जा सका — पंक्ति के नीचे की सूचना देखो", + "release_channel_name_taken_by_branch": "{{name}} {{lane}} रिलीज़ वर्कट्री के लिए आरक्षित है, लेकिन यह चेकआउट एक ब्रांच पर है। नाम खाली करने के लिए चेकआउट का नाम बदलो या उसे हटाओ: मर्ज्ड प्रून बटन उसे उसके PR मर्ज होने पर हटा देता है, या शेल में `git worktree move` / `git worktree remove` चलाओ", + "reserved_name_on_a_branch": "एक ब्रांच पर है, इसलिए {{lane}} रिलीज़ इस नाम का उपयोग नहीं कर सकती — नाम बदलो या हटाओ ताकि यह खाली हो जाए", + "release_rows_measured_by_version": "रिलीज़ पंक्तियां वर्शन से मापी जाती हैं — बैज देखो", + "no_worktree_yet_create_checks_out": "अभी कोई वर्कट्री नहीं — बनाओ {{version}} चेकआउट करता है", + "rows_pending_one": "{{count}} वर्कट्री · {{pending}} बनानी है", + "rows_pending_other": "{{count}} वर्कट्री · {{pending}} बनानी हैं" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index ee4e81d34d8..dd39c711cbe 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -1798,7 +1798,8 @@ "highlight_7": "I pod funzionano su Linux (systemd --user) e macOS (launchd) — ma il pod su macOS non ha un limite di memoria/CPU imposto; Make Live prepara il puntatore su ogni piattaforma, e il riavvio automatico richiede un’unità systemd --user o un LaunchAgent di macOS; vista della flotta, stato della PR, sincronizzazione, rebase ed eliminazione funzionano su qualsiasi piattaforma", "page_label": "Dev Fleet", "use_case_1": "Gestisci i worktree delle funzionalità di Kiro Crew e pod di anteprima isolati senza modificare il gateway live.", - "configuration_1": "Indica all'app un checkout di Kiro Crew; le azioni sulle PR richiedono gh autenticato, mentre i pod richiedono systemd --user su Linux o launchd su macOS e devono prima essere predisposti." + "configuration_1": "Indica all'app un checkout di Kiro Crew; le azioni sulle PR richiedono gh autenticato, mentre i pod richiedono systemd --user su Linux o launchd su macOS e devono prima essere predisposti.", + "highlight_8": "Un worktree fissato alla release stabile più recente, così puoi avviare un pod su una build pubblicata accanto al tuo branch; non cambia mai il canale da cui si aggiorna la tua installazione" } }, "designTweak": { @@ -14052,7 +14053,36 @@ "orphan_pods_label": "Home di pod orfani: {{value}}", "total_disk_used_by_running_pod_homes": "Disco totale usato dagli home dei pod in esecuzione", "pod_homes_left_on_disk_with_no_live_pod": "Home di pod rimasti su disco senza un pod attivo", - "disk_usage_unavailable": "Utilizzo del disco non disponibile" + "disk_usage_unavailable": "Utilizzo del disco non disponibile", + "creating": "Creazione…", + "release_channel_unpublished": "Nessuna release {{lane}} ancora recuperata — Crea recupera la più recente", + "release_channel_create_fetches": "Crea recupera i tag di release {{lane}} ed esegue il checkout della più recente in modalità detached", + "release_channel_no_release": "non su una release", + "release_channel_version_unknown": "versione sconosciuta", + "release_channel_unresolved_short": "Non è stato possibile risolvere il canale di release {{lane}}", + "no_worktree_yet": "nessun worktree ancora", + "not_applicable_short": "n/d", + "release_channel_has_no_pr": "Un worktree di release è detached su un tag, quindi non ha una pull request", + "release_channel_create_failed": "Non è stato possibile creare il worktree del canale di release", + "release_channel_create_run_lost": "L’esecuzione della creazione è andata perduta (il gateway si è riavviato) — aggiorna la flotta prima di riprovare", + "create_worktree": "Crea worktree", + "release_channel_created": "Worktree {{lane}} creato su {{ref}} — poi: {{action}}", + "release_channel_would_create_at": "Crea esegue il checkout della release {{lane}} più recente, {{version}}, in modalità detached", + "release_channel_pinned_at": "Fissato al canale di release {{lane}} su {{ref}}", + "release_channel_unresolved": "Non è stato possibile risolvere il canale di release {{lane}}: {{error}}", + "release_channel_on_older_release": "Su {{version}}; la punta del canale {{lane}} ora è {{tip}}", + "release_channel_version_behind_tip": "{{version}} · ultima {{tip}}", + "release_channel_on_release_tip_unknown": "Su {{version}}; non è stato possibile risolvere la punta del canale {{lane}}", + "release_channel_on_no_release": "Non su un tag di release {{lane}}; la punta del canale è {{tip}}", + "release_channel_on_no_release_tip_unknown": "Non su un tag di release {{lane}} e non è stato possibile risolvere la punta del canale", + "release_channel_version_unknown_title": "Non è stato possibile leggere la release su cui si trova questo checkout {{lane}}; vedi l’avviso sotto la riga", + "release_channel_name_taken_by_branch": "{{name}} è riservato al worktree di release {{lane}}, ma questo checkout è su un branch. Per liberare il nome, rinomina o rimuovi il checkout: Elimina i worktree uniti lo rimuove appena la sua PR è unita, oppure esegui `git worktree move` / `git worktree remove` in una shell", + "reserved_name_on_a_branch": "Su un branch, quindi la release {{lane}} non può usare questo nome — rinominalo o rimuovilo per liberarlo", + "release_rows_measured_by_version": "Le righe di release si misurano per versione — guarda il badge", + "no_worktree_yet_create_checks_out": "Nessun worktree ancora — Crea esegue il checkout di {{version}}", + "rows_pending_one": "{{count}} worktree · {{pending}} da creare", + "rows_pending_many": "{{count}} worktree · {{pending}} da creare", + "rows_pending_other": "{{count}} worktree · {{pending}} da creare" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index f6e5fa5e084..10a296c905b 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -1929,7 +1929,8 @@ "highlight_5": "UIからポッドログをストリーム配信してポッドアクセストークンを発行", "highlight_6": "Kiro Crew自体を開発している人向けに構築 — 日常の使用には不要", "highlight_7": "ポッドは Linux(systemd --user)と macOS(launchd)で実行されます。ただし、macOS のポッドにはメモリ/CPU 上限が強制されません。Make Live はすべてのプラットフォームでポインタをステージし、自動再起動には systemd --user ユニットまたは macOS LaunchAgent が必要です。フリート表示、PR 状態、同期、リベース、削除はどのプラットフォームでも利用できます", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "最新の安定版リリースに固定されたワークツリーです。出荷済みのビルドを自分のブランチと並べてポッドとして起動できます。自分のインストールがどのチャネルから更新されるかは変わりません" } }, "fileExplorer": { @@ -13584,7 +13585,34 @@ "orphan_pods_label": "孤立した Pod ホーム: {{value}}", "total_disk_used_by_running_pod_homes": "実行中の Pod ホームが使用するディスク合計", "pod_homes_left_on_disk_with_no_live_pod": "稼働中の Pod がないままディスクに残る Pod ホーム", - "disk_usage_unavailable": "ディスク使用量を取得できません" + "disk_usage_unavailable": "ディスク使用量を取得できません", + "creating": "作成中…", + "release_channel_unpublished": "{{lane}} リリースはまだ取得していません — 作成すると最新のリリースを取得します", + "release_channel_create_fetches": "作成すると {{lane}} リリースのタグを取得し、最新のリリースを detached でチェックアウトします", + "release_channel_no_release": "リリースではありません", + "release_channel_version_unknown": "バージョン不明", + "release_channel_unresolved_short": "{{lane}} リリースチャネルを解決できませんでした", + "no_worktree_yet": "ワークツリーなし", + "not_applicable_short": "対象外", + "release_channel_has_no_pr": "リリースワークツリーはタグに detached で接続されるため、プルリクエストはありません", + "release_channel_create_failed": "リリースチャネルのワークツリーを作成できませんでした", + "release_channel_create_run_lost": "作成の実行が失われました(ゲートウェイが再起動)— フリートを更新してからやり直してください", + "create_worktree": "ワークツリーを作成", + "release_channel_created": "{{ref}} に {{lane}} ワークツリーを作成しました — 次: {{action}}", + "release_channel_would_create_at": "作成すると最新の {{lane}} リリース {{version}} を detached でチェックアウトします", + "release_channel_pinned_at": "{{lane}} リリースチャネルの {{ref}} に固定されています", + "release_channel_unresolved": "{{lane}} リリースチャネルを解決できませんでした: {{error}}", + "release_channel_on_older_release": "{{version}} を使用中です。{{lane}} チャネルの最新リリースは {{tip}} です", + "release_channel_version_behind_tip": "{{version}} · 最新 {{tip}}", + "release_channel_on_release_tip_unknown": "{{version}} を使用中です。{{lane}} チャネルの最新リリースは解決できませんでした", + "release_channel_on_no_release": "{{lane}} リリースタグ上にありません。チャネルの最新リリースは {{tip}} です", + "release_channel_on_no_release_tip_unknown": "{{lane}} リリースタグ上になく、チャネルの最新リリースも解決できませんでした", + "release_channel_version_unknown_title": "この {{lane}} チェックアウトがどのリリース上にあるか読み取れませんでした。行の下の通知を確認してください", + "release_channel_name_taken_by_branch": "{{name}} は {{lane}} リリースワークツリー用に予約された名前ですが、このチェックアウトはブランチ上にあります。名前を空けるには、チェックアウトの名前を変更するか削除してください: マージ済み削除は PR がマージされた後に削除します。シェルで `git worktree move` / `git worktree remove` を実行することもできます", + "reserved_name_on_a_branch": "ブランチ上にあるため {{lane}} リリースはこの名前を使用できません — 名前を変更するか削除すると空きます", + "release_rows_measured_by_version": "リリース行はバージョンで判断します — バッジを参照してください", + "no_worktree_yet_create_checks_out": "ワークツリーはまだありません — 作成すると {{version}} をチェックアウトします", + "rows_pending_other": "ワークツリー {{count}} 件 · 作成可能 {{pending}} 件" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index bdf6e69f92c..e1de74880ae 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -1929,7 +1929,8 @@ "highlight_5": "UI에서 포드 로그를 스트리밍하고 포드 액세스 토큰을 발급", "highlight_6": "Kiro Crew 자체를 개발하는 사람을 위해 만들었습니다 — 일상적인 사용에는 필요하지 않습니다", "highlight_7": "포드는 Linux(systemd --user)와 macOS(launchd)에서 실행됩니다 — 다만 macOS 포드에는 메모리/CPU 상한이 적용되지 않습니다. Make Live는 모든 플랫폼에서 포인터를 준비하고, 자동 재시작에는 systemd --user 유닛이나 macOS LaunchAgent가 필요하며, 플릿 화면, PR 상태, 동기화, 리베이스, 정리는 모든 플랫폼에서 동작합니다", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "최신 안정 릴리스에 고정된 워크트리로, 출시된 빌드를 내 브랜치와 나란히 파드로 실행할 수 있습니다. 내 설치본이 어느 채널에서 업데이트되는지는 바뀌지 않습니다" } }, "fileExplorer": { @@ -13584,7 +13585,34 @@ "orphan_pods_label": "고아 파드 홈: {{value}}", "total_disk_used_by_running_pod_homes": "실행 중인 파드 홈이 사용하는 총 디스크", "pod_homes_left_on_disk_with_no_live_pod": "활성 파드 없이 디스크에 남은 파드 홈", - "disk_usage_unavailable": "디스크 사용량을 확인할 수 없습니다" + "disk_usage_unavailable": "디스크 사용량을 확인할 수 없습니다", + "creating": "생성 중…", + "release_channel_unpublished": "{{lane}} 릴리스를 아직 가져오지 않았습니다 — 생성하면 최신 릴리스를 가져옵니다", + "release_channel_create_fetches": "생성하면 {{lane}} 릴리스 태그를 가져와 최신 릴리스를 detached 상태로 체크아웃합니다", + "release_channel_no_release": "릴리스가 아님", + "release_channel_version_unknown": "버전 알 수 없음", + "release_channel_unresolved_short": "{{lane}} 릴리스 채널을 확인할 수 없습니다", + "no_worktree_yet": "워크트리 없음", + "not_applicable_short": "해당 없음", + "release_channel_has_no_pr": "릴리스 워크트리는 태그에 detached 상태로 연결되므로 풀 리퀘스트가 없습니다", + "release_channel_create_failed": "릴리스 채널 워크트리를 생성할 수 없습니다", + "release_channel_create_run_lost": "생성 실행이 유실되었습니다(게이트웨이가 재시작됨) — 플릿을 새로 고친 뒤 다시 시도하세요", + "create_worktree": "워크트리 생성", + "release_channel_created": "{{ref}}에 {{lane}} 워크트리를 생성했습니다 — 다음: {{action}}", + "release_channel_would_create_at": "생성하면 최신 {{lane}} 릴리스 {{version}}을(를) detached 상태로 체크아웃합니다", + "release_channel_pinned_at": "{{lane}} 릴리스 채널의 {{ref}}에 고정됨", + "release_channel_unresolved": "{{lane}} 릴리스 채널을 확인할 수 없습니다: {{error}}", + "release_channel_on_older_release": "{{version}}에 있으며, {{lane}} 채널의 최신 릴리스는 이제 {{tip}}입니다", + "release_channel_version_behind_tip": "{{version}} · 최신 {{tip}}", + "release_channel_on_release_tip_unknown": "{{version}}에 있으며, {{lane}} 채널의 최신 릴리스를 확인할 수 없습니다", + "release_channel_on_no_release": "{{lane}} 릴리스 태그에 있지 않으며, 채널의 최신 릴리스는 {{tip}}입니다", + "release_channel_on_no_release_tip_unknown": "{{lane}} 릴리스 태그에 있지 않으며, 채널의 최신 릴리스도 확인할 수 없습니다", + "release_channel_version_unknown_title": "이 {{lane}} 체크아웃이 어떤 릴리스에 있는지 읽을 수 없습니다. 행 아래의 알림을 확인하세요", + "release_channel_name_taken_by_branch": "{{name}}은(는) {{lane}} 릴리스 워크트리용으로 예약된 이름이지만, 이 체크아웃은 브랜치에 있습니다. 이름을 비우려면 체크아웃의 이름을 바꾸거나 삭제하세요: 병합된 항목 정리는 PR이 병합된 뒤에 삭제하며, 셸에서 `git worktree move` / `git worktree remove` 명령을 실행할 수도 있습니다", + "reserved_name_on_a_branch": "브랜치에 있어 {{lane}} 릴리스가 이 이름을 사용할 수 없습니다 — 이름을 바꾸거나 삭제하면 비워집니다", + "release_rows_measured_by_version": "릴리스 행은 버전으로 판단합니다 — 배지를 참고하세요", + "no_worktree_yet_create_checks_out": "워크트리가 아직 없습니다 — 생성하면 {{version}}을(를) 체크아웃합니다", + "rows_pending_other": "워크트리 {{count}}개 · 생성 대기 {{pending}}개" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index f3150fe6bfa..9687e0d66a0 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -1798,7 +1798,8 @@ "highlight_7": "Os pods rodam em Linux (systemd --user) e macOS (launchd) — mas o pod do macOS não tem limite de memória/CPU aplicado; Make Live prepara o ponteiro em todas as plataformas, e o reinício automático exige uma unidade systemd --user ou um LaunchAgent do macOS; a visão da frota, o status de PR, a sincronização, o rebase e a limpeza funcionam em qualquer plataforma", "page_label": "Dev Fleet", "use_case_1": "Opere worktrees de recursos do Kiro Crew e pods isolados de pré-visualização sem alterar o gateway ativo.", - "configuration_1": "Aponte o app para um checkout do Kiro Crew; ações de PR precisam do gh autenticado, enquanto pods precisam de systemd --user no Linux ou launchd no macOS e devem ser provisionados primeiro." + "configuration_1": "Aponte o app para um checkout do Kiro Crew; ações de PR precisam do gh autenticado, enquanto pods precisam de systemd --user no Linux ou launchd no macOS e devem ser provisionados primeiro.", + "highlight_8": "Uma worktree fixada na release estável mais recente, para você subir um pod com uma build publicada ao lado do seu branch; isso nunca muda de qual canal a sua própria instalação se atualiza" } }, "designTweak": { @@ -14052,7 +14053,36 @@ "orphan_pods_label": "Homes de pods órfãos: {{value}}", "total_disk_used_by_running_pod_homes": "Disco total usado pelos homes de pods em execução", "pod_homes_left_on_disk_with_no_live_pod": "Homes de pods deixados no disco sem um pod ativo", - "disk_usage_unavailable": "Uso de disco indisponível" + "disk_usage_unavailable": "Uso de disco indisponível", + "creating": "Criando…", + "release_channel_unpublished": "Nenhuma release {{lane}} obtida ainda — Criar obtém a mais recente", + "release_channel_create_fetches": "Criar obtém as tags de release {{lane}} e faz checkout da mais recente em modo detached", + "release_channel_no_release": "não está em uma release", + "release_channel_version_unknown": "versão desconhecida", + "release_channel_unresolved_short": "Não foi possível resolver o canal de releases {{lane}}", + "no_worktree_yet": "ainda sem worktree", + "not_applicable_short": "n/d", + "release_channel_has_no_pr": "Uma worktree de release fica detached em uma tag, então não tem pull request", + "release_channel_create_failed": "Não foi possível criar a worktree do canal de releases", + "release_channel_create_run_lost": "A execução da criação foi perdida (o gateway reiniciou) — atualize a frota antes de tentar de novo", + "create_worktree": "Criar worktree", + "release_channel_created": "Worktree {{lane}} criada em {{ref}} — próximo: {{action}}", + "release_channel_would_create_at": "Criar faz checkout da release {{lane}} mais recente, {{version}}, em modo detached", + "release_channel_pinned_at": "Fixada no canal de releases {{lane}} em {{ref}}", + "release_channel_unresolved": "Não foi possível resolver o canal de releases {{lane}}: {{error}}", + "release_channel_on_older_release": "Em {{version}}; a ponta do canal {{lane}} agora é {{tip}}", + "release_channel_version_behind_tip": "{{version}} · mais recente {{tip}}", + "release_channel_on_release_tip_unknown": "Em {{version}}; não foi possível resolver a ponta do canal {{lane}}", + "release_channel_on_no_release": "Não está em uma tag de release {{lane}}; a ponta do canal é {{tip}}", + "release_channel_on_no_release_tip_unknown": "Não está em uma tag de release {{lane}} e não foi possível resolver a ponta do canal", + "release_channel_version_unknown_title": "Não foi possível ler a release em que este checkout {{lane}} está; veja o aviso abaixo da linha", + "release_channel_name_taken_by_branch": "{{name}} é reservado para a worktree de release {{lane}}, mas este checkout está em um branch. Para liberar o nome, renomeie ou remova o checkout: Limpar mescladas o remove assim que seu PR for mesclado, ou rode `git worktree move` / `git worktree remove` em um shell", + "reserved_name_on_a_branch": "Em um branch, então a release {{lane}} não pode usar este nome — renomeie ou remova para liberar", + "release_rows_measured_by_version": "As linhas de release são medidas por versão — veja o selo", + "no_worktree_yet_create_checks_out": "Ainda sem worktree — Criar faz checkout de {{version}}", + "rows_pending_one": "{{count}} worktree · {{pending}} para criar", + "rows_pending_many": "{{count}} worktrees · {{pending}} para criar", + "rows_pending_other": "{{count}} worktrees · {{pending}} para criar" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index b360a832dee..5ff341df8d3 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -1846,7 +1846,8 @@ "highlight_5": "Поток логов pod и выпуск ключей доступа к pod прямо в интерфейсе", "highlight_6": "Сделано для тех, кто разрабатывает сам Kiro Crew — для повседневной работы не нужно", "highlight_7": "Pod работает на Linux (systemd --user) и macOS (launchd) — но у pod на macOS нет принудительного лимита памяти/CPU; Make Live готовит указатель на любой платформе, а для автоматического перезапуска нужен юнит systemd --user или LaunchAgent macOS; обзор флота, статус PR, синхронизация, перебазирование и очистка работают на любой платформе", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "Worktree, закреплённый на самом новом стабильном релизе, чтобы можно было запустить pod с выпущенной сборкой рядом с твоей веткой; канал обновления твоей собственной установки это никогда не меняет" } }, "designTweak": { @@ -14286,7 +14287,37 @@ "orphan_pods_label": "Осиротевшие домашние каталоги подов: {{value}}", "total_disk_used_by_running_pod_homes": "Всего диска, занятого домашними каталогами работающих подов", "pod_homes_left_on_disk_with_no_live_pod": "Домашние каталоги подов на диске без активного пода", - "disk_usage_unavailable": "Данные об использовании диска недоступны" + "disk_usage_unavailable": "Данные об использовании диска недоступны", + "creating": "Создание…", + "release_channel_unpublished": "Релиз {{lane}} ещё не получен — «Создать» получит самый новый", + "release_channel_create_fetches": "«Создать» получит теги релизов {{lane}} и переключится на самый новый в detached-состоянии", + "release_channel_no_release": "не на релизе", + "release_channel_version_unknown": "версия неизвестна", + "release_channel_unresolved_short": "Не удалось определить канал релизов {{lane}}", + "no_worktree_yet": "worktree ещё нет", + "not_applicable_short": "н/д", + "release_channel_has_no_pr": "Worktree релиза находится в detached-состоянии на теге, поэтому у него нет пул-реквеста", + "release_channel_create_failed": "Не удалось создать worktree канала релизов", + "release_channel_create_run_lost": "Запуск создания потерян (шлюз перезапустился) — обнови флот, прежде чем повторять", + "create_worktree": "Создать worktree", + "release_channel_created": "Worktree {{lane}} создан на {{ref}} — далее: {{action}}", + "release_channel_would_create_at": "«Создать» переключится на самый новый релиз {{lane}} ({{version}}) в detached-состоянии", + "release_channel_pinned_at": "Закреплён на канале релизов {{lane}} на {{ref}}", + "release_channel_unresolved": "Не удалось определить канал релизов {{lane}}: {{error}}", + "release_channel_on_older_release": "Сейчас {{version}}; вершина канала {{lane}} теперь {{tip}}", + "release_channel_version_behind_tip": "{{version}} · новейшая {{tip}}", + "release_channel_on_release_tip_unknown": "Сейчас {{version}}; вершину канала {{lane}} определить не удалось", + "release_channel_on_no_release": "Не на теге релиза {{lane}}; вершина канала — {{tip}}", + "release_channel_on_no_release_tip_unknown": "Не на теге релиза {{lane}}, и вершину канала определить не удалось", + "release_channel_version_unknown_title": "Не удалось прочитать, на каком релизе находится этот checkout {{lane}} — см. уведомление под строкой", + "release_channel_name_taken_by_branch": "{{name}} зарезервировано для worktree релиза {{lane}}, но этот checkout находится на ветке. Чтобы освободить имя, переименуй или удали checkout: «Очистить влитые» удалит его после влития его PR, либо запусти `git worktree move` / `git worktree remove` в оболочке", + "reserved_name_on_a_branch": "На ветке, поэтому релиз {{lane}} не может занять это имя — переименуй или удали его, чтобы освободить", + "release_rows_measured_by_version": "Строки релизов измеряются по версии — смотри бейдж", + "no_worktree_yet_create_checks_out": "Worktree ещё нет — «Создать» переключится на {{version}}", + "rows_pending_one": "worktree: {{count}} · к созданию: {{pending}}", + "rows_pending_few": "worktree: {{count}} · к созданию: {{pending}}", + "rows_pending_many": "worktree: {{count}} · к созданию: {{pending}}", + "rows_pending_other": "worktree: {{count}} · к созданию: {{pending}}" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 1d6b1e757b6..15a283ce8bd 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -1702,7 +1702,8 @@ "highlight_5": "在界面上流式查看 Pod 日志,并签发 Pod 访问令牌", "highlight_6": "给开发 Kiro Crew 本身的人用 — 日常使用并不需要它", "highlight_7": "Pod 可以跑在 Linux(systemd --user)和 macOS(launchd)上 — 但 macOS 上的 Pod 没有强制的内存/CPU 上限;Make Live 在所有平台都会暂存指针,自动重启需要 systemd --user 单元或 macOS LaunchAgent;机群视图、PR 状态、同步、变基和清理在任何平台都可用", - "page_label": "Dev Fleet" + "page_label": "Dev Fleet", + "highlight_8": "锁定到最新稳定版发布的工作树,可在你分支旁以 pod 运行已发布构建;不会改变本机安装所用的更新通道" } }, "designTweak": { @@ -13584,7 +13585,34 @@ "orphan_pods_label": "孤立 Pod 主目录:{{value}}", "total_disk_used_by_running_pod_homes": "运行中 Pod 主目录占用的磁盘总量", "pod_homes_left_on_disk_with_no_live_pod": "磁盘上没有对应运行 Pod 的遗留主目录", - "disk_usage_unavailable": "磁盘用量不可用" + "disk_usage_unavailable": "磁盘用量不可用", + "creating": "正在创建…", + "release_channel_unpublished": "尚未获取 {{lane}} 发布版本 — 创建会获取最新版本", + "release_channel_create_fetches": "创建会获取 {{lane}} 发布标签,并以 detached 方式检出最新版本", + "release_channel_no_release": "不在发布版本上", + "release_channel_version_unknown": "版本未知", + "release_channel_unresolved_short": "无法解析 {{lane}} 发布通道", + "no_worktree_yet": "尚无工作树", + "not_applicable_short": "不适用", + "release_channel_has_no_pr": "发布工作树以 detached 方式指向标签,因此没有拉取请求", + "release_channel_create_failed": "无法创建发布通道工作树", + "release_channel_create_run_lost": "创建运行已丢失(网关已重启),请先刷新机群再重试", + "create_worktree": "创建工作树", + "release_channel_created": "已在 {{ref}} 创建 {{lane}} 工作树,下一步:{{action}}", + "release_channel_would_create_at": "创建会以 detached 方式检出最新的 {{lane}} 发布版本 {{version}}", + "release_channel_pinned_at": "已锁定到 {{lane}} 发布通道的 {{ref}}", + "release_channel_unresolved": "无法解析 {{lane}} 发布通道:{{error}}", + "release_channel_on_older_release": "当前为 {{version}};{{lane}} 通道的最新版本现在是 {{tip}}", + "release_channel_version_behind_tip": "{{version}} · 最新 {{tip}}", + "release_channel_on_release_tip_unknown": "当前为 {{version}};无法解析 {{lane}} 通道的最新版本", + "release_channel_on_no_release": "不在 {{lane}} 发布标签上;通道的最新版本是 {{tip}}", + "release_channel_on_no_release_tip_unknown": "不在 {{lane}} 发布标签上,且无法解析通道的最新版本", + "release_channel_version_unknown_title": "无法读取此 {{lane}} 检出所在的发布版本,请查看行下方的通知", + "release_channel_name_taken_by_branch": "{{name}} 是为 {{lane}} 发布工作树保留的名称,但此检出位于分支上。要释放该名称,请重命名或移除此检出:清理已合并会在其 PR 合并后移除它,也可以在终端中运行 `git worktree move` / `git worktree remove`", + "reserved_name_on_a_branch": "位于分支上,因此 {{lane}} 发布无法使用此名称 — 重命名或移除即可释放", + "release_rows_measured_by_version": "发布行按版本衡量 — 请查看标签", + "no_worktree_yet_create_checks_out": "尚无工作树 — 创建会检出 {{version}}", + "rows_pending_other": "{{count}} 个工作树 · 待创建 {{pending}} 个" }, "developer": { "agentBackendTab": { diff --git a/website/src/i18n/pluralKeys.json b/website/src/i18n/pluralKeys.json index bb433af5fa2..1e022ca9eb7 100644 --- a/website/src/i18n/pluralKeys.json +++ b/website/src/i18n/pluralKeys.json @@ -194,6 +194,7 @@ "pages.chatSidebar.switch_session", "pages.chatSidebar.workflow_running", "pages.devFleetPage.commits_behind_main_2", + "pages.devFleetPage.rows_pending", "pages.discoverPage.updates_badge_label", "pages.knowledge.index.stats_entities", "pages.knowledge.index.stats_items", diff --git a/website/src/pages/DevFleetPage.owners.test.tsx b/website/src/pages/DevFleetPage.owners.test.tsx index 05434308fa8..1e798cf4ecd 100644 --- a/website/src/pages/DevFleetPage.owners.test.tsx +++ b/website/src/pages/DevFleetPage.owners.test.tsx @@ -63,10 +63,10 @@ describe('DevFleetPage facade', () => { }) describe('DevFleetPage owners', () => { - it('are the five files beside the facade', () => { + it('are the six files beside the facade', () => { expect(owners).toEqual([ - 'DevFleetPageMenus.tsx', 'DevFleetPageNotices.tsx', 'DevFleetPagePrune.tsx', 'DevFleetPageRow.tsx', - 'devFleetPageModel.ts', + 'DevFleetPageMenus.tsx', 'DevFleetPageNotices.tsx', 'DevFleetPagePrune.tsx', + 'DevFleetPageReleaseChannel.tsx', 'DevFleetPageRow.tsx', 'devFleetPageModel.ts', ]) }) diff --git a/website/src/pages/DevFleetPage.tsx b/website/src/pages/DevFleetPage.tsx index fab2f7731fa..eae2c5d94df 100644 --- a/website/src/pages/DevFleetPage.tsx +++ b/website/src/pages/DevFleetPage.tsx @@ -35,11 +35,16 @@ import { readDismissedUndoKey, persistDismissedUndoKey, POLL_MS, sleep, filterStepMarkers, syncFailureTail, _activeSyncPolls, awaitGatewayBackGlobal, type Worktree, type UndoTarget, type FleetData, type SyncRun, type ProvRun, type RebaseResult, type PruneDialog, type PruneProgress, + channelWorktreeName, channelForRow, channelNameTakenFor, channelErrorFor, channelPlaceholderFor, } from './devFleetPageModel' import { ProvLogPre, iconLabel, PodReadout, DetailPanel, DevFleetRowActions, DevFleetSyncStepper, DevFleetProvStepper, } from './DevFleetPageRow' +import { + ReleaseChannelBadge, ReleaseChannelPrCell, ReleaseChannelBehindCell, ReleaseChannelReservedName, + ReleaseChannelRowError, ReleaseChannelPlaceholder, ReleaseChannelCreateError, +} from './DevFleetPageReleaseChannel' import { DevFleetPruneReview, DevFleetPruneProgress } from './DevFleetPagePrune' import { DevFleetPageNotices } from './DevFleetPageNotices' export { gatewayRecovered, pruneVerdictLabel, PRUNE_STATUS_META } from './devFleetPageModel' @@ -213,6 +218,21 @@ export default function DevFleetPage() { }, [refetchFleetFresh, queryClient]) const [busy, setBusy] = useState>({}) + // The release-channel Create gets its OWN flag rather than a prefixed key in + // `busy`. The create path has no worktree yet, so there is no name to key on — + // and a synthetic prefixed name would collide with the row namespace `busy` + // uses for every other action. One flag, not a map: the backend publishes + // exactly one `release_channel`, so there is one placeholder row and one + // Create to be in flight. + const [rcBusy, setRcBusy] = useState(false) + // A failed Create. It is an outcome the operator must act on — the backend + // refused (name held by a branch, unresolvable tip, provenance mismatch) or + // the request never landed — and the fleet refetch that follows carries no + // trace of it: the placeholder row comes back exactly as before, worktree + // still uncreated. A toast would take the diagnosis and the agent hand-off + // with it, so the text stays on the row (through ErrorNotice, like + // `rebaseResult`) until the operator retries or dismisses it. + const [releaseChannelCreateError, setReleaseChannelCreateError] = useState(null) const [expanded, setExpanded] = useState>({}) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [detail, setDetail] = useState>({}) @@ -731,12 +751,13 @@ export default function DevFleetPage() { finally { setFlag(name + ':remove', false) } } - // Normalize a failed POST /sync into the shape the caller below already - // handles. The single-flight refusal is an HTTP 409 whose body names the run - // already in flight, so it arrives as a thrown error and never as a returned - // body — which is what left the `!ok && run_id` branch below unreachable. - // Every other status is a real failure carrying its message. - function syncPostFailure(e: unknown): { ok: false; run_id?: string; error: string } { + // Normalize a failed POST that starts a run (/sync, /release-channel/create) + // into the shape the caller already handles. The single-flight refusal is an + // HTTP 409 whose body names the run already in flight, so it arrives as a + // thrown error and never as a returned body — which is what left the + // `!ok && run_id` branch below unreachable. Every other status is a real + // failure carrying its message. + function singleFlightPostFailure(e: unknown): { ok: false; run_id?: string; error: string } { let rid: unknown if (e instanceof ApiError && e.status === 409) { try { rid = (JSON.parse(e.body) as { run_id?: unknown })?.run_id } catch { /* not JSON */ } @@ -751,7 +772,7 @@ export default function DevFleetPage() { async function syncMain() { setFlag('__syncmain', true) try { - const r = await api.post<{ ok?: boolean; run_id?: string; error?: string }>('/sync', {}).catch(syncPostFailure) + const r = await api.post<{ ok?: boolean; run_id?: string; error?: string }>('/sync', {}).catch(singleFlightPostFailure) if (!r?.ok && r?.run_id) { // Sync already running — reattach to the in-flight run instead of erroring. // A second press is a user who cannot see the run, so an error toast would @@ -781,6 +802,106 @@ export default function DevFleetPage() { finally { setFlag(name + ':rebase', false) } } + // Create the release-channel worktree. No confirm step: Create is reversible by + // Remove, and the dialog's body was the Create button's own tooltip repeated + // back — so it asked the operator to read the same sentence twice and click + // again to do what they just clicked. Nothing here MOVES an existing tree, so + // there is no destructive step for a confirm to guard. + // + // The POST only STARTS the create and answers with a run id: the fetch, the + // worktree add and the populate behind it take minutes, and the app proxy drops + // a request at 30 s, so the outcome is read from `/run?id=` like a provision's. + // A 409 whose body names a run is the single-flight refusal — a create already + // running, typically this page's own after a reload — and is attached to, not + // reported; every other refusal (`exists`, `ambiguous_name`) is decided before + // any run and arrives as a 409 with its message. + type CreateResult = { ok?: boolean; lane?: string; version?: string | null; ref?: string | null; error?: string } + async function createReleaseChannel() { + setRcBusy(true) + // A retry starts clean: the previous failure's notice describes an attempt + // this click supersedes. + setReleaseChannelCreateError(null) + try { + const started = await api.post<{ ok?: boolean; run_id?: string; error?: string }>('/release-channel/create', {}).catch(singleFlightPostFailure) + if (!started?.run_id) { + // A refusal is a 409 (and an unexpected git failure a 500) carrying + // `{ok: false, error}`, which the client raises as an ApiError whose message + // is that error text; a transport failure lands in the catch below with its + // own. Both get the same framing. + setReleaseChannelCreateError(frameCreateFailure(started?.error)) + return + } + const r = await pollReleaseChannelCreate(started.run_id) + if (r === null) return // the page is gone; the fleet row is the record now + // The next step is named by the CONTROL's own label, not as prose, so the + // words in the message are the words on the button and cannot drift from + // it. A fresh worktree always needs provisioning, so this half is + // unconditional. One key carries the whole sentence, with the label as a + // placeholder, so each locale orders and punctuates it its own way. + if (r?.ok) notify(i18nT('pages.devFleetPage.release_channel_created', { lane: r.lane || '', ref: r.version || r.ref || '?', action: i18nT('pages.devFleetPage.provision') }), { type: 'success' }) + else setReleaseChannelCreateError(frameCreateFailure(r?.error)) + } catch (e: unknown) { + setReleaseChannelCreateError(frameCreateFailure((e as Error)?.message || String(e))) + } finally { + // Refetch whatever happened: a refusal such as `exists` means the fleet has + // a row this page has not seen yet, and a finished run means a new row. + invalidateFleet() + setRcBusy(false) + } + } + + // Poll one create run to its terminal state and return its result. The run + // record carries `result` (the dict the create returned) once `status` leaves + // `running`; a record with no result ended by exception or shutdown, and its + // last output line is the only account of why. A 404 means the gateway + // restarted and dropped the run registry: the create may or may not have + // landed, so the receipt is lost and the FLEET decides (`lostCreateReceipt`). + // Stops at an unmount (`null`), like the provision loop: the create runs on + // server-side regardless, and the row it adds is what the next mount shows. + async function pollReleaseChannelCreate(rid: string): Promise { + for (let i = 0; i < 900; i++) { + await sleep(2000) + if (!pollAliveRef.current) return null + let run: { status?: string; exit_code?: number | null; output?: string[]; result?: CreateResult } | null = null + try { run = await api.get('/run?id=' + rid) } catch (e) { + if ((e as { status?: number })?.status === 404) return lostCreateReceipt() + continue + } + if (!run || run.status === 'running') continue + if (run.result) return run.result + const out = run.output || [] + return { ok: false, error: out.length ? out[out.length - 1] : (run.status || 'failed') } + } + return lostCreateReceipt() + } + + // The run registry is process-local, so a restart between the create's + // `worktree move` and the next poll loses the receipt of a create that + // LANDED. Reporting that as a failure put the new row on the page under a + // notice saying it could not be created. The fleet is the record that + // survives the restart: a rebuilt payload whose channel row carries a + // `worktree` is the create's success, and only a payload with no adopted tree + // is a loss worth telling the operator about. A fleet read that fails here + // stays a loss: nothing has been proven either way. + async function lostCreateReceipt(): Promise { + let fresh: FleetData | null = null + try { fresh = await api.get('/fleet?fresh=1') } catch { fresh = null } + const rc = fresh?.release_channel + if (rc?.worktree) return { ok: true, lane: rc.lane, version: rc.version ?? null, ref: rc.ref ?? null } + return { ok: false, error: i18nT('pages.devFleetPage.release_channel_create_run_lost') } + } + + // The backend string is a CAUSE, not a message: git/resolver text alone tells + // the operator what failed internally but not what did not happen. Framed the + // same way `restart_failed` and `make_live_failed` are in this file, so the + // notice reads as an outcome with a reason. NOT a toast: the fleet refetch + // re-renders the placeholder unchanged, so a toast was the only record of why + // the worktree is still missing, and it self-dismissed. + function frameCreateFailure(cause: string | undefined): string { + const head = i18nT('pages.devFleetPage.release_channel_create_failed') + return cause ? `${head}: ${cause}` : head + } + async function pruneShipped() { setFlag('__prune', true) try { @@ -1094,9 +1215,18 @@ export default function DevFleetPage() { if (s === 'CLOSED') return 3 return 2 // unknown state — rank with the PR-less rows } + // A channel that will not resolve says so ON ITS ROW, and that is the only + // place it says it. There is deliberately no page-level notice: this arrives on + // the 12s fleet poll rather than from a button, so an error-level toast fired + // on every checkout that has no `v*` tags at all — a shallow clone, a fork + // before its first release — for a feature that operator never opened. + const releaseChannel = fleet?.release_channel || null + const channelWorktree = channelWorktreeName(releaseChannel) const mainRows = wts.filter((w) => w.is_main) const legacyAll = wts.filter((w) => !w.is_main && w.legacy) - const others = wts.filter((w) => !w.is_main && matchesRow(w) && (showLegacy || !w.legacy)) + const selectable = wts.filter((w) => !w.is_main && matchesRow(w) && (showLegacy || !w.legacy)) + const channelRows = selectable.filter((w) => w.name === channelWorktree) + const others = selectable.filter((w) => w.name !== channelWorktree) others.sort((a, b) => sortBy === 'name' ? compareText(a.name, b.name) : sortBy === 'recent' @@ -1104,7 +1234,13 @@ export default function DevFleetPage() { : sortBy === 'behind' ? ((b.behind || 0) - (a.behind || 0)) || compareText(a.name, b.name) : (statusRank(a) - statusRank(b)) || (prRank(a) - prRank(b)) || compareText(a.name, b.name)) - const visible = [...mainRows, ...others] + // The channel row holds a FIXED position under `main` instead of joining the + // sort. Every sort key on offer describes feature-branch progress — recency, + // commits behind main, PR state — and a release worktree scores badly on all of + // them by design, so under `recent` it would sink below every active branch and + // under `behind` it would top the list for a distance that is not a backlog. + const pinnedRows = [...mainRows, ...channelRows] + const channelPlaceholder = channelPlaceholderFor(releaseChannel, wts) const reviewState = (w: Worktree) => { if (!w.pr) return null @@ -1158,6 +1294,14 @@ export default function DevFleetPage() { const isMainWithStepper = w.is_main && syncRun const pr = prov[w.name] const provActive = !w.is_main && !!pr + // The three ways this row can relate to the release channel, each decided by + // the BACKEND's classification rather than by the row's name: it IS the pin, + // it merely occupies the reserved name with a branch checkout, or its git + // state could not be read (folded with the adopted resolver failure into one + // error surface). + const channel = channelForRow(releaseChannel, w) + const channelNameTaken = w.is_main ? undefined : channelNameTakenFor(releaseChannel, w.name) + const channelErr = channelErrorFor(releaseChannel, w) return (
@@ -1179,6 +1323,11 @@ export default function DevFleetPage() { {w.is_main ? (fleet?.base_branch && w.branch && w.branch !== fleet.base_branch ? {i18nT('pages.devFleetPage.parked_on_branch', { branch: w.branch })} : {i18nT('pages.devFleetPage.main')}) : null} + {/* The release-channel pin's currency, and — on a row that merely + occupies the reserved name — the sentence saying so. Both are + keyed off the backend's classification, never the row's name. */} + {channel ? : null} + {channelNameTaken ? : null} {/* live_verified false: the pointer names this row but a packaged gateway cannot confirm it runs this checkout, so the badge asks, not asserts, and the reason is visible text rather than a hover-only tooltip. */} @@ -1211,8 +1360,8 @@ export default function DevFleetPage() { toggleProvLog={toggleProvLog} /> : ( <> - {rs && prUrl ? {rs.word} : {"\u2014"}} - 0 ? 1 : 0.5 }} title={(w.behind ?? 0) > 0 ? i18nT('pages.devFleetPage.commits_behind_main_2', { count: w.behind ?? 0 }) : i18nT('pages.devFleetPage.up_to_date_with_main')}>{(w.behind ?? 0) > 0 ? '\u2193' + w.behind : '\u2014'} + {channel ? : rs && prUrl ? {rs.word} : {"\u2014"}} + {channel ? : 0 ? 1 : 0.5 }} title={(w.behind ?? 0) > 0 ? i18nT('pages.devFleetPage.commits_behind_main_2', { count: w.behind ?? 0 }) : i18nT('pages.devFleetPage.up_to_date_with_main')}>{(w.behind ?? 0) > 0 ? '\u2193' + w.behind : '\u2014'}} {relTime(w.last_updated_at).replace(' ago', '')}
)}
{renderRebaseFailure(w)} + {channelErr ? : null} {w.is_main && syncRun && syncLogOpen ? (
{filterStepMarkers(syncRun.lines || []).join('\n') || '(no output yet)'}
) : null} @@ -1292,7 +1443,29 @@ export default function DevFleetPage() { testId="fleet-live-state-unknown" /> )} - {columnHeader}{visible.map(renderRow)}{legacyToggle} + {/* Order: main, the adopted channel row, the un-created channel + placeholder, then the sorted feature worktrees. The placeholder sits + WITH the channel row rather than at the end so the channel reads as one + group. */} + {columnHeader}{pinnedRows.map(renderRow)} + {channelPlaceholder ? ( + { void createReleaseChannel() }} + /> + ) : null} + {/* Beside the channel group, OUTSIDE the placeholder: the refetch after a + refused Create may replace the placeholder with the adopted row (a stale + tab's `exists`), and the notice has to outlive that swap. */} + {releaseChannel ? ( + setReleaseChannelCreateError(null)} + /> + ) : null} + {others.map(renderRow)}{legacyToggle}
) @@ -1355,7 +1528,22 @@ export default function DevFleetPage() {
setQ((e.target as HTMLInputElement).value)} aria-label={i18nT('pages.devFleetPage.filter_worktrees_2')} />
- {ql ? others.length + ' / ' : ''}{wts.length} {i18nT('pages.devFleetPage.rows')} + {/* With a release-channel placeholder on the table, this label + names BOTH populations instead of one total: "3 rows" beside a + "Worktrees (2)" heading and a WORKTREES card of 2 read as a + miscount, because the eye counts three lines and the page says + two. So the label says what the third line is — "2 worktrees · + 1 to create" — and the heading and stat card stay on + wts.length: a placeholder is an offer to create a worktree, + not a worktree. With no placeholder every line is a worktree + and the plain "N rows" total stands. The filter NUMERATOR + counts `selectable`, not `others`: the channel row is pinned + under main rather than sorted with the feature rows, so a + numerator read off `others` alone showed "0 / 3" for a filter + that matched the one visible channel row. */} + {ql ? selectable.length + ' / ' : ''}{channelPlaceholder + ? i18nT('pages.devFleetPage.rows_pending', { count: wts.length, pending: 1 }) + : <>{wts.length} {i18nT('pages.devFleetPage.rows')}} + {text} + + ) +} + +/** + * The PR cell on a channel row. + * + * A release worktree is detached at a tag, so it has no branch and can never + * have a pull request. Rendered as an explicit "n/a" rather than the PR-less em + * dash, which on every other row means "no PR yet" — a state that invites + * waiting for one. + */ +export function ReleaseChannelPrCell() { + return ( + + {i18nT('pages.devFleetPage.not_applicable_short')} + + ) +} + +/** + * The Behind cell on a channel row. + * + * The column counts commits behind main, and that figure on a release worktree + * is large by construction (a shipped tag is behind main by every commit merged + * since) and says nothing the operator can act on. Three attempts at a second + * denominator in this cell (`↓3 tip`, `↓3 newer`, tooltips) all left two rows + * reading `↓N` in one column with two meanings, so the cell says the column does + * not apply — the same `n/a` the PR cell uses — and points at the badge, which + * carries the row's currency in its text and tooltip. The payload carries no + * channel distance at all, so there is nothing here that could regain a count. + */ +export function ReleaseChannelBehindCell() { + return ( + + {i18nT('pages.devFleetPage.not_applicable_short')} + + ) +} + +/** + * This checkout holds the channel's reserved name but is on a branch, so it is + * NOT a channel pin and gets none of the channel treatment. Said on the row + * rather than as a second placeholder row, so one directory stays one row — and + * said at all, because otherwise the channel simply has no row and no + * explanation for why it cannot be created. + * + * A SENTENCE, not a pill. Three pill wordings ("Not a release worktree", "Not + * the channel", "Name reserved — on a branch") each failed a cold read: a badge + * has room for a verdict but not for the situation AND the way out, and a verdict + * alone read as the pin degrading. So this is the same muted inline status line + * the placeholder row uses for "No worktree yet — Create checks out X" — same + * slot, same styling — and it carries both halves in visible text: the row is on + * a branch, which is why the stable release cannot use this name, and freeing it + * means renaming or removing the checkout. Not a warn Badge: nothing is broken + * on this row; it is an ordinary feature worktree with an unlucky name. The + * tooltip names the controls that actually exist (Prune merged, or git worktree + * move/remove in a shell) — the row menu has no Remove or Rename, so the + * sentence must not point at one. + */ +export function ReleaseChannelReservedName({ rc, name }: { rc: ReleaseChannel; name: string }) { + return ( + + {i18nT('pages.devFleetPage.reserved_name_on_a_branch', { lane: rc.lane })} + + ) +} + +/** + * The sole error surface for a channel row: an ADOPTED row whose resolve() + * failed, OR the reserved directory whose HEAD could not be read + * (`channelErrorFor` folds both). A Badge title is not keyboard-reachable and + * carries no agent hand-off, so every channel error routes here, matching the + * placeholder row's own notice. Nothing on this page is typed, so the hand-off + * loses nothing. + */ +export function ReleaseChannelRowError({ rc }: { rc: ReleaseChannel }) { + return ( +
+ +
+ ) +} + +/** + * A channel with no worktree yet, rendered as a dimmed row carrying only Create. + * + * This placeholder is the ONLY place the feature is discoverable — there is no + * header control — so it is listed even though nothing exists on disk. Kept out + * of `worktrees` on the backend for the same reason it is a distinct renderer + * here: it has no path, and every real row's affordance assumes one. + */ +export function ReleaseChannelPlaceholder({ + rc, busy, onCreate, +}: { + rc: ReleaseChannel + busy: boolean + onCreate: () => void +}) { + // Two states this row tells apart, and they read differently on purpose. + // `error` is a GENUINE resolver failure — git could not be read — so it goes + // through the shared ErrorNotice, the same surface every other error on this + // page uses, and it blocks Create. `unpublished` is the benign documented + // state: this checkout has fetched no release tag, which is ordinary + // information, not an incident. The backend nulls `error` for that state, so a + // benign row leaves `blocked` empty and Create enabled — and Create fetches + // first, so it is the action that resolves the state. + const blocked = rc.error || null + const unpublished = !blocked && !!rc.unpublished + // Framed for the ErrorNotice and the disabled-button tooltip. Only a genuine + // failure produces it, so a benign checkout never renders as an incident. + const blockedText = blocked + ? i18nT('pages.devFleetPage.release_channel_unresolved', { lane: rc.lane, error: blocked }) + : null + // What Create would check out, when the backend resolved one. Absent (no tag + // fetched, or the resolver failed) there is NO version pill at all. It must not + // fall back to the lane word — `stable` beside a row already named + // `release-channel-stable` reads as a version called "stable" — and a "no + // release" label is not the answer either: the status line beside it already + // says nothing was fetched, so the pill only restated it, in the same words + // the adopted off-tag badge uses for a DIFFERENT state (a real tree sitting on + // no tag). One string for two states is one state too many; the adopted badge + // keeps the label, the placeholder keeps the sentence. + const wouldCreateAt = rc.version || rc.ref || null + // The cell's own status line. A genuine failure shows a SHORT plain label and + // hands the full text to the ErrorNotice below, so the cell can ellipsise + // without ever cutting the sentence a user needs mid-clause; the benign and + // resolved states read as plain informational text. The resolved state SAYS + // what Create does, in visible text: before this, a version pill plus "no + // worktree yet" left Create explained only by the pill's hover, and a reader + // who did not hover saw a bare version beside a button with no stated effect. + const statusText = blocked + ? i18nT('pages.devFleetPage.release_channel_unresolved_short', { lane: rc.lane }) + : unpublished + ? i18nT('pages.devFleetPage.release_channel_unpublished', { lane: rc.lane }) + : wouldCreateAt + ? i18nT('pages.devFleetPage.no_worktree_yet_create_checks_out', { version: wouldCreateAt }) + : i18nT('pages.devFleetPage.no_worktree_yet') + // Title only where the button cannot otherwise be understood. With a resolved + // version the pill beside it already says what Create checks out, and the + // success toast names provisioning as the next step — a second copy here + // restated the pill's first clause. The blocked case needs one because a + // disabled button cannot explain itself any other way; the unpublished case + // needs one because its status line says "nothing fetched yet" and an enabled + // button beside that sentence is only clickable with confidence once it says + // that clicking IS the fetch. + const createTitle = blockedText + || (unpublished ? i18nT('pages.devFleetPage.release_channel_create_fetches', { lane: rc.lane }) : undefined) + return ( +
+ {/* The row reads as a muted placeholder by dimming its cells directly, NOT + the grid container: an opacity < 1 on the container composites the whole + subtree as one group, and a descendant cannot exceed its ancestor's + opacity, so a container opacity dims the Create button with it. The + muting lives on the sibling cells — the pod-column dash, the worktree + cell (name, version pill, status) and the already-dimmed n/a columns — + so the actions cell holding Create sits outside any dimmed subtree at + full contrast. Keep every opacity on a CELL; none on an ancestor of + Create. */} + {/* The 7-column template is the one DevFleetPage.tsx's header and every + worktree row spell inline, so the placeholder reads as one aligned row of + the same table. Desktop-only by design (AUTOSDE narrow-viewport-required, + stated at the layout root): a lone responsive row would misalign. */} +
+ + {'\u2014'} +
+ {rc.name} + {/* Dashed border, not a filled pill: this names a ref that has been + RESOLVED but not checked out anywhere, so it must not read like the + solid version badge an adopted row carries. It carries its own + tooltip because only the Create button used to explain what the + version means, so a user hovering the version itself — the thing + they are trying to understand — got nothing. Rendered only with + something to name; see `wouldCreateAt`. */} + {wouldCreateAt ? ( + + {wouldCreateAt} + + ) : null} + {/* Never the bare backend string, and never the error path: `rc.error` + is git/resolver mechanism, so it belongs in the framed ErrorNotice + below with the agent hand-off. This cell carries only a short plain + status — a benign "no release yet", or a one-clause "could not be + resolved" whose full text is in the notice — so ellipsising it can + never cut the sentence a user is reading. Always muted: the danger + styling lives on the ErrorNotice, not here. */} + + {statusText} + +
+ + {'\u2014'} + {'\u2014'} +
+ + {busy ? i18nT('pages.devFleetPage.creating') : i18nT('pages.devFleetPage.create_worktree')} + +
+
+ {/* A resolver failure is the one error class on this page that reached the + user only as tooltip and cell text — every other one goes through + ErrorNotice with the agent hand-off. Row-scoped and BELOW the grid, not + page-level: a page-level surface would fire on every mount for a + checkout with no release tags at all (a shallow clone, a fork before its + first release), which is a normal state and not an incident. Here it + sits on the row that cannot resolve, where it is the answer to a + question the operator is already asking. */} + {blockedText ? ( +
+ +
+ ) : null} +
+ ) +} + +/** + * The notice for a Create that did not take. A DIFFERENT event from a resolver + * failure — the row resolved fine, the click did not — so it is its own notice + * with its own testId, never folded into the resolver one. + * + * Rendered by the PAGE beside the channel group, not inside the placeholder. + * A refusal is followed by a fleet refetch, and when the refusal was `exists` + * — a stale tab clicking Create after another client created the worktree — + * that refetch carries the reserved worktree, the placeholder unmounts, and a + * notice living inside it would vanish with its cause, its recovery steps and + * its agent hand-off, undismissed. The failure is page state about an action + * the operator took, so its surface outlives whichever row shape the channel + * has afterwards; it goes only when the operator retries or dismisses it. The + * inputs are all in git and on the backend, so the hand-off is on. + */ +export function ReleaseChannelCreateError({ + lane, error, onDismiss, +}: { + lane: string + error: string | null + onDismiss: () => void +}) { + if (!error) return null + return ( +
+ +
+ ) +} diff --git a/website/src/pages/DevFleetPageRow.tsx b/website/src/pages/DevFleetPageRow.tsx index b5ba9f0fcc8..39cdb91eea9 100644 --- a/website/src/pages/DevFleetPageRow.tsx +++ b/website/src/pages/DevFleetPageRow.tsx @@ -198,7 +198,7 @@ export function DetailPanel({ w, d, busy, onRemove, onLoadLogs, logs, logsLoadin export function DevFleetRowActions({ w, fleet, busy, rebaseResult, syncRun, gatewayMutating, liveStateUnknown, stagedWorktree, liveWorktree, dismissRebaseResult, act, launchQa, provision, syncMain, rebaseWorktree, restartGateway, makeLive, - podsAvailable, + podsAvailable, isReleaseChannel, }: { w: Worktree fleet: FleetData | undefined @@ -218,6 +218,9 @@ export function DevFleetRowActions({ restartGateway: () => Promise makeLive: (w: Worktree | UndoTarget, opts?: { undo?: boolean }) => Promise podsAvailable: boolean + /** This row IS the release-channel pin (the backend's classification, never + * the row's name). Suppresses Rebase onto main; see the menu below. */ + isReleaseChannel?: boolean }): ReactNode[] { if (w.is_main) { const out: ReactNode[] = [ @@ -300,7 +303,14 @@ export function DevFleetRowActions({ out.push(, onClick: () => act(w.name, 'up') } : null, podsAvailable && w.running ? { label: i18nT('pages.devFleetPage.restart_pod'), icon: , onClick: () => act(w.name, 'restart') } : null, - { label: i18nT('pages.devFleetPage.rebase_onto_main'), icon: , onClick: () => rebaseWorktree(w.name), disabled: !!busy[w.name + ':rebase'] }, + // Rebase is SUPPRESSED on a release worktree rather than replaced. Rebasing + // a detached release checkout onto main is not a coherent request — it would + // replay a shipped tag's history onto unreleased code, and the backend + // refuses it anyway (no branch to rebase). Moving the pin to a newer release + // is Remove followed by Create, which the page already offers. + isReleaseChannel + ? null + : { label: i18nT('pages.devFleetPage.rebase_onto_main'), icon: , onClick: () => rebaseWorktree(w.name), disabled: !!busy[w.name + ':rebase'] }, // Staging a cutover writes only the live-target pointer, so it needs no // pod support and no drivable service — gating it on podsAvailable would // hide it on exactly the hosts it exists to serve. diff --git a/website/src/pages/devFleetPageModel.ts b/website/src/pages/devFleetPageModel.ts index 9b5ce5c9816..05f18c898f6 100644 --- a/website/src/pages/devFleetPageModel.ts +++ b/website/src/pages/devFleetPageModel.ts @@ -301,7 +301,54 @@ export interface Worktree { pod_resources?: PodResources | null } export interface UndoTarget { name: string; path: string } -export interface FleetData { worktrees: Worktree[]; error?: string; needs_setup?: boolean; main_repo?: string; main_repo_inferred?: boolean; base_branch?: string; sync_run_id?: string; build_pending?: boolean; gateway_service_active?: boolean; gateway_service_reason?: string | null; pods_available?: boolean; pods_unavailable_reason?: string | null; serving_install_reason?: string | null; staged_target?: string | null; staged_cancel_available?: boolean; undo_target?: UndoTarget | null; live_state_known?: boolean; live_verified?: boolean; manual_restart?: string; fleet_totals?: FleetTotals } +// One published release channel and where its detached worktree sits. +// `worktree` is non-null ONLY for a tree the backend is willing to drive as a +// channel pin (it exists AND is detached), so this field — not the row's name — +// is what decides whether a row gets channel treatment. `name_taken_by_branch` +// is the third state: the reserved name is occupied by somebody's branch +// checkout, so Create cannot run and the UI has to say why. +export interface ReleaseChannel { + // The channel's name, for the sentences that name it on screen. Display data: + // no request carries it back, because there is exactly one channel and the + // endpoint takes no argument. + lane: string + // The basename this channel's worktree has or would have, supplied by the + // backend so the naming rule lives on ONE side of the boundary. Re-deriving + // it here would let a change to WORKTREE_NAME desync this label from the real + // path. + name: string + worktree?: string | null + ref?: string | null + // The release THIS ROW is on: on an adopted row the one its tree actually + // holds, on a placeholder the one Create would check out. `tip_version` is + // always the channel's resolved tip, so a behind row names both without + // either being ambiguous — and a `null` version on an adopted row is a real + // state (the tree is detached at no release tag), not a missing value to fill + // in from the tip. + version?: string | null + tip_version?: string | null + // A benign, documented state kept apart from `error`: this checkout has + // fetched no release tag yet. It renders as ordinary information and leaves + // Create enabled, because Create fetches first and a fetch is what resolves + // it. `error` stays reserved for a genuine git failure, which reaches the + // shared ErrorNotice. + unpublished?: boolean + error?: string | null + // At-tip is a boolean, not a distance: the payload carries no channel-tip + // commit count, because every rendering of one (`↓3`, `↓3 tip`, a second + // denominator in the Behind column) left two cells in one column meaning two + // things. The row states currency by naming both versions instead. + at_tip?: boolean | null + // On an adopted row a `null` version means one of TWO things, and the badge must + // not say the stronger one when only the weaker is known. `false`: the tree is + // detached at no published release — the positive answer, "not on a release". + // `true`: the probe that reads the tree's release failed (a timed-out or + // unreadable `--points-at` listing); the row carries the error and the badge + // says the version is unknown, asserting nothing about a tree nobody read. + version_unknown?: boolean + name_taken_by_branch?: boolean +} +export interface FleetData { worktrees: Worktree[]; error?: string; needs_setup?: boolean; main_repo?: string; main_repo_inferred?: boolean; base_branch?: string; sync_run_id?: string; build_pending?: boolean; gateway_service_active?: boolean; gateway_service_reason?: string | null; pods_available?: boolean; pods_unavailable_reason?: string | null; serving_install_reason?: string | null; staged_target?: string | null; staged_cancel_available?: boolean; undo_target?: UndoTarget | null; live_state_known?: boolean; live_verified?: boolean; manual_restart?: string; fleet_totals?: FleetTotals; release_channel?: ReleaseChannel | null } // `lastIsCause` distinguishes the two things `last` can hold. A gateway-composed // diagnosis is decision-critical prose ending in the action to take, so it must // not render in the muted 11.5px monospace the raw log tail uses. @@ -314,3 +361,100 @@ export interface RebaseResult { kind: 'ok' | 'conflict' | 'error'; text: string export type PruneDialog = { candidates: { name: string; code?: string; unmerged_commits?: boolean }[]; kept: { name: string; code?: string; dirty?: boolean; dirty_tracked?: boolean | null; dirty_untracked?: number; dirty_untracked_paths?: string[] }[]; scanned: number } export type PruneProgress = { names: string[]; items: Record; done: number; total: number; running: boolean } + +/* ─── Release channel row classification ─── */ +// Which row (if any) a `release_channel` payload describes is decided HERE, in +// one React-free place, so the page, the row cells and the tests all read the +// same rules. Every helper takes the payload explicitly and returns the channel +// it applies to (or null/undefined), so a caller cannot accidentally treat a +// row as a channel pin because its NAME looks like one. + +/** The adopted channel worktree's name, or null when no tree is adopted. */ +export function channelWorktreeName(rc: ReleaseChannel | null | undefined): string | null { + return rc?.worktree || null +} + +/** + * The channel this row IS, or undefined. + * + * Adopted only. A row is a channel pin because the BACKEND says its tree is + * detached at a resolved ref, never because its name looks like one — so a + * user's own `release-channel-stable` branch checkout keeps ordinary controls. + */ +export function channelForRow( + rc: ReleaseChannel | null | undefined, + w: Worktree, +): ReleaseChannel | undefined { + const adopted = channelWorktreeName(rc) + return !w.is_main && adopted && w.name === adopted ? (rc as ReleaseChannel) : undefined +} + +/** + * The channel whose reserved basename THIS row is occupying with a branch + * checkout, or undefined. + * + * The explanation lands on the row that actually exists: one directory is one + * row, and rendering a second placeholder for it printed the same name twice. + */ +export function channelNameTakenFor( + rc: ReleaseChannel | null | undefined, + name: string, +): ReleaseChannel | undefined { + return rc && !rc.worktree && rc.name_taken_by_branch && rc.name === name ? rc : undefined +} + +/** + * The third state the backend can emit: the reserved directory EXISTS and is a + * registered worktree (so it renders as an ordinary selectable row), but its git + * state could not be read — `worktree` is null, `name_taken_by_branch` is false, + * and only `error` is set. Neither `channelForRow` nor `channelNameTakenFor` + * fires for it, and because the row IS in the fleet the placeholder path is + * suppressed too, so without this the error string has no surface at all. + */ +export function channelUnreadableFor( + rc: ReleaseChannel | null | undefined, + name: string, +): ReleaseChannel | undefined { + return rc && !rc.worktree && !rc.name_taken_by_branch && rc.error && rc.name === name ? rc : undefined +} + +/** + * ONE derivation for every channel resolver error, so a single ErrorNotice is + * the sole surface for both cases: an ADOPTED row whose resolve() failed, and + * the reserved directory whose HEAD could not be read. A tooltip is neither + * keyboard-reachable nor an agent hand-off, so no error may live in a Badge + * title. + */ +export function channelErrorFor( + rc: ReleaseChannel | null | undefined, + w: Worktree, +): ReleaseChannel | undefined { + const adopted = channelForRow(rc, w) + if (adopted?.error) return adopted + return channelUnreadableFor(rc, w.name) +} + +/** + * The channel that needs a Create placeholder row, or null. + * + * Suppressed whenever a real directory already carries the reserved basename, + * whatever the backend made of it. `worktree` and `name_taken_by_branch` are + * both CLASSIFICATIONS, and a third state exists: an unreadable HEAD resolves to + * neither — so a guard testing only those two rendered the placeholder beside + * the very directory it was describing, and one directory became two rows. + * Testing the fleet for the NAME is the check that holds for every + * classification, including ones added later, because the name is the thing that + * can only belong to one row. + * + * Against the UNFILTERED fleet: the search box and the legacy toggle decide what + * is on screen, and a query that hides the channel row must not conjure a Create + * placeholder for a directory that exists. + */ +export function channelPlaceholderFor( + rc: ReleaseChannel | null | undefined, + wts: Worktree[], +): ReleaseChannel | null { + if (!rc || rc.worktree || rc.name_taken_by_branch) return null + const present = wts.some((w) => !w.is_main && w.name === rc.name) + return present ? null : rc +} diff --git a/website/src/test/DevFleetPageReleaseChannels.test.tsx b/website/src/test/DevFleetPageReleaseChannels.test.tsx new file mode 100644 index 00000000000..1aaf1b847b3 --- /dev/null +++ b/website/src/test/DevFleetPageReleaseChannels.test.tsx @@ -0,0 +1,1056 @@ +/** + * Release-channel worktree rows in the Dev Fleet table. + * + * The whole feature is a claim about WHICH RELEASE a checkout is sitting on, so + * every test here asserts on what the row states rather than on whether it + * rendered. Two failure modes are specifically guarded: + * + * - **Adopting on the name.** `release-channel-stable` is a reserved basename. A + * user's own branch checkout under that name must keep ordinary controls; only + * the backend's `worktree` field (set when the tree is detached at a resolved + * ref) confers channel treatment. + * - **Reusing a column with a different meaning silently.** BEHIND counts from + * main and is NOT rendered on these rows — the cell says the column does not + * apply and points at the version badge, whose tooltip carries the row's + * currency — and PR is inapplicable rather than merely absent. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { renderWithProviders } from './helpers' + +import DevFleetPage, { __resetDevFleetNoticesForTests } from '../pages/DevFleetPage' + +function renderPage() { + return renderWithProviders(, { route: '/dev-fleet' }) +} + +// A fixed instant, so the fixture never reads the wall clock: the rows render +// no relative age this file asserts on, and a pinned stamp keeps the +// Determinism contract's unpinned-date count at zero. +const FIXED_NOW = Date.UTC(2026, 9, 22, 12, 0, 0) / 1000 + +const MAIN = { + name: 'main', + is_main: true, + running: false, + has_dist: true, + behind: 0, + last_updated_at: FIXED_NOW, +} + +// A feature worktree carrying the repo's real naming convention, so the +// ordering assertions compare against what the fleet actually shows. +const FEATURE = { + name: 'kirocrew-wt-update-freshness', + is_main: false, + running: false, + has_dist: true, + behind: 12, + last_updated_at: FIXED_NOW - 3600, +} + +const STABLE_WT = { + name: 'release-channel-stable', + is_main: false, + running: false, + has_dist: true, + // Behind MAIN is large by construction on a release worktree — the row must + // not show this number. + behind: 412, + last_updated_at: FIXED_NOW - 86400 * 2, +} + +const CHANNELS = { + stable: { + lane: 'stable', + name: 'release-channel-stable', + worktree: 'release-channel-stable', + ref: 'refs/tags/v0.5.0', + version: '0.5.0', + tip_version: '0.5.0', + error: null, + at_tip: true, + name_taken_by_branch: false, + }, + // The SAME channel before it is materialized. There is one channel, so the + // placeholder cases use this rather than a second channel's row — which also + // keeps them honest: a placeholder is a state of a channel, not a kind of one. + uncreated: { + lane: 'stable', + name: 'release-channel-stable', + worktree: null, + ref: 'refs/tags/v0.5.0', + version: '0.5.0', + tip_version: '0.5.0', + error: null, + at_tip: null, + name_taken_by_branch: false, + }, +} + +// What POST /release-channel/create answers once the create is RUNNING: the run, +// not the result. The result is read from the run record (`CREATE_RUN`) once the +// page's poll sees it leave `running`. +const CREATE_RUN_ID = 'rc-create-run' +const CREATE_STARTED = { ok: true, run_id: CREATE_RUN_ID } +function CREATE_RUN(result: Record) { + return { status: 'done', exit_code: result.ok ? 0 : 1, output: [], result } +} + +function mockFleet( + data: Record, + posts?: Record, + gets?: Record, +) { + const seen: { url: string; body: unknown }[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method === 'POST') { + seen.push({ url: u, body: init.body ? JSON.parse(String(init.body)) : null }) + const key = Object.keys(posts || {}).find((k) => u.includes(k)) + const body = (key ? posts![key] : { ok: true }) as { ok?: boolean; run_id?: string } + // The backend answers a refusal with a 409 (an unexpected git failure with a + // 500) carrying `{ok: false, error}`, never a 200 with `ok: false`; the stub + // keeps that contract so the page's catch path is what the tests exercise. + // A started run is a 202: the mutation is admitted, not done. + const status = body.ok === false ? 409 : body.run_id ? 202 : 200 + return Promise.resolve(new Response(JSON.stringify(body), { status })) + } + const getKey = Object.keys(gets || {}).find((k) => u.includes(k)) + if (getKey) return Promise.resolve(new Response(JSON.stringify(gets![getKey]), { status: 200 })) + if (u.includes('/fleet')) return Promise.resolve(new Response(JSON.stringify(data), { status: 200 })) + if (u.includes('/disk')) return Promise.resolve(new Response(JSON.stringify({ total_mb: 51200 }), { status: 200 })) + return Promise.resolve(new Response('{}', { status: 200 })) + }) + return seen +} + +beforeEach(() => { + __resetDevFleetNoticesForTests() + vi.restoreAllMocks() +}) + +describe('DevFleetPage release-channel rows', () => { + it('badges an adopted channel row with the release it is sitting on', async () => { + mockFleet({ + base_branch: 'main', + worktrees: [MAIN, STABLE_WT, FEATURE], + release_channel: CHANNELS.stable, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + // The lane is already in the row name, so the badge carries the version. + const badge = screen.getByText('0.5.0') + expect(badge).toBeInTheDocument() + expect(badge).toHaveAttribute('title', expect.stringContaining('refs/tags/v0.5.0')) + }) + + it('lists a channel with no worktree as a placeholder row offering Create', async () => { + // Without the placeholder there is nowhere on the page the feature is + // discoverable — the design has no header control. + mockFleet({ + worktrees: [MAIN, FEATURE], + release_channel: CHANNELS.uncreated, + }) + renderPage() + await waitFor(() => expect(screen.getByTestId('release-channel-placeholder-stable')).toBeInTheDocument()) + const row = screen.getByTestId('release-channel-placeholder-stable') + expect(within(row).getByText('release-channel-stable')).toBeInTheDocument() + expect(within(row).getByText('0.5.0')).toBeInTheDocument() + // The status line is VISIBLE text that says what Create does, naming the + // version: a pill plus "no worktree yet" explained Create only to a reader + // who hovered the pill. + expect(within(row).getByText('No worktree yet — Create checks out 0.5.0')).toBeInTheDocument() + expect(within(row).queryByText('no worktree yet')).not.toBeInTheDocument() + expect(within(row).getByRole('button', { name: /create/i })).toBeEnabled() + }) + + it('keeps the enabled Create button OUT of any opacity-reduced (dimmed) subtree', async () => { + // The placeholder row reads muted, but CSS composites an element with + // opacity<1 and its whole subtree as ONE group — so any ancestor of Create + // carrying opacity would dim the button below full contrast (the bug: opacity + // sat on the grid container). The muting must live on sibling CELLS, never on + // an ancestor of the actions cell. Walk from the button up to and including + // the placeholder grid container and prove no ancestor carries a reduced + // inline opacity, naming the offending element so this cannot pass either way. + mockFleet({ worktrees: [MAIN, FEATURE], release_channel: CHANNELS.uncreated }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + const btn = within(row).getByRole('button', { name: /create/i }) + expect(btn).toBeEnabled() + const chain: string[] = [] + for (let el: HTMLElement | null = btn; el; el = el === row ? null : el.parentElement) { + const raw = el.style.opacity + const val = raw === '' ? 1 : Number(raw) + const id = el.getAttribute('data-testid') + chain.push(`${el.tagName.toLowerCase()}${id ? `[${id}]` : ''}=${val}`) + if (val < 1) { + throw new Error( + `Create button has an opacity-reduced ancestor: ${chain.join(' > ')} — ` + + 'an opacity<1 ancestor composites the button dim; move the muting onto sibling cells.', + ) + } + } + // The grid container itself must carry no opacity — that was the defect. + expect(row.style.opacity === '' || Number(row.style.opacity) === 1).toBe(true) + // ...and the row still reads muted: the status cell sits inside a dimmed subtree. + const status = within(row).getByText(/No worktree yet/) + expect(status.closest('[style*="opacity"]')).not.toBeNull() + }) + + it('renders a benign unpublished channel as information, Create enabled, no ErrorNotice', async () => { + // An empty channel is a documented state — this checkout has fetched no + // release tag — not an incident. It reads as ordinary information, keeps + // Create enabled (Create fetches first, which is what resolves it), and does + // NOT raise the shared error surface. The backend nulls `error` and sets + // `unpublished`. + const benign = { + ...CHANNELS.stable, worktree: null, ref: null, version: null, + tip_version: null, at_tip: null, unpublished: true, error: null, + } + mockFleet({ worktrees: [MAIN], release_channel: benign }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + // The status line names the MECHANISM, not just the absence: a reader who + // sees "nothing resolvable here" beside a live button has no reason to click + // it, and did not. The copy says that Create is what fetches. + const status = within(row).getByText(/No stable release fetched yet/) + expect(status.textContent).toMatch(/Create fetches/) + const create = within(row).getByRole('button', { name: /create/i }) + expect(create).toBeEnabled() + // ...and the enabled button says so itself, so the answer to "what happens + // if I click this" is on the control and not only in the row's prose. + expect(create).toHaveAttribute('title', expect.stringMatching(/fetches the stable release tags/)) + // Benign is not an error: no row-level notice. + expect(screen.queryByTestId('release-channel-error-stable')).not.toBeInTheDocument() + }) + + it('routes an ADOPTED channel row resolver error to the shared ErrorNotice, not a badge tooltip', async () => { + // A channel whose worktree exists and is detached is adopted even when + // resolution fails, so there is no placeholder row to carry the message. The + // error still must not live in a Badge title (not keyboard-reachable, no + // agent hand-off): `channelErrorFor` routes it to the same ErrorNotice the + // placeholder row uses, one surface for every channel error, rather than a + // page-level notice that fired on every mount for a checkout with no tags. + const adoptedButBroken = { + ...CHANNELS.stable, + ref: null, + version: null, + tip_version: null, + at_tip: null, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: adoptedButBroken }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.queryByTestId('release-channel-placeholder-stable')).not.toBeInTheDocument() + const notice = screen.getByTestId('release-channel-error-stable') + expect(notice.textContent).toContain('cannot list tags') + }) + + // BEHIND on a channel row: the n/a marker, in every channel state alike. The + // cell is not a second denominator (three wordings of one — `tip`, `newer`, + // tooltips — each left two `↓N` cells in one column meaning two things), so it + // says the column does not apply and points at the badge, which carries the + // row's currency in its own tooltip. Asserted per state so no state can quietly + // regain a count or a dash. + const BEHIND_NA_TIP = 'Release rows are measured by version — see the badge' + function behindMarker() { + // Two n/a cells sit on a channel row, PR then BEHIND, in grid order. + const cells = screen.getAllByText('n/a') + expect(cells).toHaveLength(2) + expect(cells[0]).toHaveAttribute('title', expect.stringContaining('pull request')) + return cells[1] + } + + it('shows the n/a marker in BEHIND, not a count or a dash, when the channel resolver failed', async () => { + const adoptedButBroken = { + ...CHANNELS.stable, + ref: null, version: null, tip_version: null, at_tip: null, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: adoptedButBroken }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(behindMarker()).toHaveAttribute('title', BEHIND_NA_TIP) + // No `?` cell anywhere in the table (the InfoTip BUTTON beside the heading is + // the one legitimate question mark on the page and is not a cell). + expect(screen.queryAllByText('?', { ignore: 'button' })).toEqual([]) + // The behind-main figure stays hidden on this row too. + expect(screen.queryByText('↓412')).not.toBeInTheDocument() + // Currency lives on the badge, which says the tip is unresolved. + expect(screen.getByText('not on a release').getAttribute('title')).toMatch(/could not be resolved/) + }) + + it('shows the n/a marker in BEHIND at the tip too — no dash that reads as "up to date with main"', async () => { + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: CHANNELS.stable }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const cell = behindMarker() + expect(cell).toHaveAttribute('title', BEHIND_NA_TIP) + expect(cell.textContent).not.toBe('—') + // The only dash in the Behind column belongs to main (behind: 0). + expect(screen.getAllByTitle('up to date with main')).toHaveLength(1) + // The badge is where at-tip is stated. + expect(screen.getByText('0.5.0')).toHaveAttribute('title', expect.stringContaining('Pinned to the stable release channel')) + }) + + it('never presents the tree\'s own tag as "the tip" in the badge tooltip when the resolver failed', async () => { + // The tree holds 0.5.0 (ref + version known) but the resolver failed, so + // tip_version is null. The tip is tip_version and nothing else: falling back + // to the tree's own ref would read "the stable channel tip is now + // refs/tags/v0.5.0" — the row's own version, presented as proof the channel + // is current, on the one row where nothing about the channel is known. + const heldButUnresolved = { + ...CHANNELS.stable, + ref: 'refs/tags/v0.5.0', version: '0.5.0', tip_version: null, at_tip: null, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: heldButUnresolved }) + renderPage() + await waitFor(() => expect(screen.getByText('0.5.0')).toBeInTheDocument()) + const badge = screen.getByText('0.5.0') + expect(badge.getAttribute('title')).toBe('On 0.5.0; the stable channel tip could not be resolved') + expect(badge.getAttribute('title')).not.toContain('refs/tags') + expect(badge.getAttribute('title')).not.toContain('is now') + }) + + it('renders no tip-denominated count in BEHIND on an adopted row that is behind the tip', async () => { + const behindTip = { ...CHANNELS.stable, at_tip: false, tip_version: '0.6.0' } + mockFleet({ + worktrees: [MAIN, STABLE_WT], + release_channel: behindTip, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + // Neither denominator appears in the cell: not a channel distance (the + // payload no longer carries one) and not `↓412` (behind main). The marker + // stands in. + expect(behindMarker()).toHaveAttribute('title', BEHIND_NA_TIP) + expect(screen.queryAllByText(/↓\d+\s*(newer|tip)?$/)).toEqual([]) + expect(screen.queryByText('↓412')).not.toBeInTheDocument() + // The badge is where "a newer release shipped" is said — in its text, with + // the tooltip spelling out the sentence. + expect(screen.getByText('0.5.0 · latest 0.6.0')).toHaveAttribute('title', 'On 0.5.0; the stable channel tip is now 0.6.0') + }) + + it('keeps the Behind header tooltip about main only, since no row measures anything else', async () => { + // The header is ONE element shared by every row class. It must not claim two + // denominators ("main for branches, channel tip for release rows"): the + // channel cell is the n/a marker, so a header naming a second denominator + // would describe a number no cell shows. + const behindTip = { ...CHANNELS.stable, at_tip: false, tip_version: '0.6.0' } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: behindTip }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const header = screen.getByText('Behind') + const title = (header.getAttribute('title') ?? '').toLowerCase() + expect(title).toMatch(/\bmain\b/) + expect(title).not.toContain('channel') + expect(title).not.toContain('tip') + }) + + it('badges a behind row with the release it HOLDS, not the newer tip', async () => { + // The badge shows "which release the tree is actually sitting on". Fed the + // RESOLVED version instead, the moment a newer release shipped the row would + // rename itself to a build it does not contain, with `↓N` the only hint + // anything was stale. + const behindTip = { + ...CHANNELS.stable, + at_tip: false, + version: '0.5.0', + tip_version: '0.6.0', + ref: 'refs/tags/v0.6.0', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: behindTip }) + renderPage() + // The held release LEADS the pill; the tip follows, labelled `latest`, so it + // cannot be read as the row's own version. No pill reads `0.6.0` alone. + const badge = await waitFor(() => screen.getByText('0.5.0 · latest 0.6.0')) + expect(screen.queryByText('0.6.0')).not.toBeInTheDocument() + expect(screen.queryByText('0.5.0')).not.toBeInTheDocument() + expect(badge).toHaveAttribute('title', expect.stringContaining('0.6.0')) + }) + + it('states behind-the-tip in the badge TEXT, not only in its colour', async () => { + // A warn-orange pill and an ok-green pill carrying the same "0.5.0" told a + // colourblind or keyboard reader nothing; the tip lived only in the hover + // tooltip. Behind the tip, the visible text names both versions. At the tip + // it is the bare version, and when the tip is unknown (resolver failed) the + // version stands alone — the tooltip already says the tip could not be + // resolved, and a "tip ?" would read as a rendering bug. + const behind = { ...CHANNELS.stable, at_tip: false, version: '0.5.0', tip_version: '0.5.3' } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: behind }) + const first = renderPage() + await waitFor(() => expect(screen.getByText('0.5.0 · latest 0.5.3')).toBeInTheDocument()) + expect(screen.queryByText('0.5.0')).not.toBeInTheDocument() + first.unmount() + + vi.restoreAllMocks() + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: { ...CHANNELS.stable, version: '0.5.3', tip_version: '0.5.3', at_tip: true } }) + const second = renderPage() + await waitFor(() => expect(screen.getByText('0.5.3')).toBeInTheDocument()) + expect(screen.queryByText(/· latest/)).not.toBeInTheDocument() + second.unmount() + + vi.restoreAllMocks() + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: { + ...CHANNELS.stable, at_tip: null, version: '0.5.0', tip_version: null, error: 'cannot list tags (git tag failed)', + } }) + renderPage() + await waitFor(() => expect(screen.getByText('0.5.0')).toBeInTheDocument()) + expect(screen.queryByText(/· latest/)).not.toBeInTheDocument() + expect(screen.queryAllByText(/\?/, { ignore: 'button' })).toEqual([]) + }) + + it('says so when a channel tree is on no release tag at all', async () => { + // Adoption is by SHAPE (detached), not by being at a release, so an operator + // who checked out an arbitrary commit in the channel is on no release. + // Falling back to the tip's version here would be the same lie the test above + // pins — and falling back to the LANE WORD is a different one: on a row + // already named `release-channel-stable`, a pill reading `stable` reads as a + // version. + const offTag = { + ...CHANNELS.stable, + at_tip: false, + version: null, + tip_version: '0.6.0', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: offTag }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.queryByText('0.6.0')).not.toBeInTheDocument() + expect(screen.queryByText('stable')).not.toBeInTheDocument() + // The label states the TREE's condition, so it cannot be read as the same + // worry as the resolver-error row (whose badge keeps its version). + const badge = screen.getByText('not on a release') + expect(screen.queryByText('no release')).not.toBeInTheDocument() + expect(badge).toHaveAttribute('title', expect.stringContaining('0.6.0')) + expect(badge.getAttribute('title')).not.toContain('?') + }) + + it('never renders a literal "?" as the tip when the tree is off-tag AND the tip is unknown', async () => { + // The sentence "the channel tip is ?" reads as a rendering bug. When neither + // tip_version nor ref resolved, the badge gets copy that interpolates no + // version at all. + const offTagNoTip = { + ...CHANNELS.stable, + at_tip: null, version: null, tip_version: null, ref: null, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: offTagNoTip }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const badge = screen.getByText('not on a release') + const title = badge.getAttribute('title') ?? '' + expect(title).toMatch(/could not be resolved/) + expect(title).not.toContain('?') + }) + + it('says "version unknown", never "not on a release", when the release probe itself failed', async () => { + // Two states hide behind `version: null` on an adopted row. `version_unknown` + // is set only when the probe that reads the tree's release FAILED (a timed-out + // or unreadable `--points-at` listing). "not on a release" is a positive claim + // about the tree, and this is a tree nobody read: the badge must stop + // asserting, and the tooltip must point at the notice that carries the error. + const probeFailed = { + ...CHANNELS.stable, + at_tip: false, version: null, version_unknown: true, tip_version: '0.6.0', + error: 'release-channel-stable: the release this tree sits on could not be read; refusing to name the release this tree holds', + } + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: probeFailed }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.queryByText('not on a release')).not.toBeInTheDocument() + const badge = screen.getByText('version unknown') + expect(badge.getAttribute('title')).toMatch(/could not be read/) + expect(badge.getAttribute('title')).toMatch(/notice below the row/) + // The error reaches the shared ErrorNotice; the row stays adopted (still a channel row). + const notice = screen.getByTestId('release-channel-error-stable') + expect(notice.textContent).toContain('could not be read') + expect(screen.queryByTestId('release-channel-placeholder-stable')).toBeNull() + // The flag is the discriminator: the same payload without it is the positive answer. + expect(screen.queryByText('0.6.0')).not.toBeInTheDocument() + }) + + it('renders no version pill on the placeholder when nothing resolved', async () => { + // The dashed pill's slot is a VERSION slot. With nothing resolved it must not + // fall back to `rc.lane` (the unpublished row would print `stable` twice) nor + // to a "not on a release" label — the same words the adopted off-tag badge + // uses for a different state (a real tree on no tag), beside a status line + // that already says nothing was fetched. Nothing to name means no pill: the + // status line is the whole statement. + const benign = { + ...CHANNELS.stable, worktree: null, ref: null, version: null, + tip_version: null, at_tip: null, unpublished: true, error: null, + } + mockFleet({ worktrees: [MAIN], release_channel: benign }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + expect(within(row).queryByText('stable')).not.toBeInTheDocument() + expect(within(row).queryByText('not on a release')).not.toBeInTheDocument() + expect(within(row).queryByText('0.5.0')).not.toBeInTheDocument() + // No dashed pill element at all, not merely an empty one. + expect(row.querySelector('[style*="dashed"]')).toBeNull() + // The status line carries the state instead. + expect(within(row).getByText(/No stable release fetched yet/)).toBeInTheDocument() + // The label survives only where it means something: the adopted off-tag badge. + expect(screen.queryByText('not on a release')).not.toBeInTheDocument() + }) + + it('keeps the version-bearing pill and tooltip when Create has something to check out', async () => { + mockFleet({ worktrees: [MAIN, FEATURE], release_channel: CHANNELS.uncreated }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + const pill = within(row).getByText('0.5.0') + expect(pill).toHaveAttribute('title', expect.stringContaining('0.5.0')) + expect(within(row).queryByText('not on a release')).not.toBeInTheDocument() + // No fetch tooltip on Create here: the pill already says what it checks out. + expect(within(row).getByRole('button', { name: /create/i })).not.toHaveAttribute('title') + }) + + it('marks PR inapplicable on a channel row instead of showing the no-PR dash', async () => { + // The em dash on every other row means "no PR yet", which invites waiting for + // one. A tag-detached tree can never have a PR at all. + mockFleet({ worktrees: [MAIN, STABLE_WT], release_channel: CHANNELS.stable }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const na = screen.getAllByText('n/a') + expect(na.length).toBeGreaterThan(0) + expect(na[0]).toHaveAttribute('title', expect.stringContaining('pull request')) + }) + + it('suppresses Rebase onto main on an adopted channel row but keeps it on a feature row', async () => { + // Rebasing a detached release checkout onto main would replay a shipped tag's + // history onto unreleased code, and the backend refuses it anyway (no branch + // to rebase). Moving the pin is Remove then Create. + mockFleet({ worktrees: [MAIN, STABLE_WT, FEATURE], release_channel: CHANNELS.stable, pods_available: true }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const menus = screen.getAllByRole('button', { name: /more actions/i }) + // Two non-main rows, two row menus: the channel row's is first (pinned under + // main), the feature row's second. + expect(menus).toHaveLength(2) + fireEvent.click(menus[0]) + await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument()) + expect(screen.queryByText('Rebase onto base branch')).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(menus[1]) + await waitFor(() => expect(screen.getByText('Rebase onto base branch')).toBeInTheDocument()) + }) + + it('does NOT adopt a branch checkout that merely shares the reserved name', async () => { + // The name guard, from the UI side: the backend reports worktree=null plus + // name_taken_by_branch, so the row keeps ordinary controls. + const taken = { ...CHANNELS.stable, worktree: null, at_tip: null, name_taken_by_branch: true } + mockFleet({ + worktrees: [MAIN, { ...STABLE_WT, behind: 5 }], + release_channel: taken, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + // No version badge: this row is not a channel pin. + expect(screen.queryByText('0.5.0')).not.toBeInTheDocument() + // Its behind count is the ordinary behind-main figure, not a channel distance. + expect(screen.getByText('↓5')).toBeInTheDocument() + }) + + it('explains the occupied name on the existing row, not as a second row', async () => { + // One directory is one row. Rendering a blocked placeholder alongside the + // real checkout printed `release-channel-stable` twice on the page, which is + // what this asserts against. + const taken = { ...CHANNELS.stable, worktree: null, at_tip: null, name_taken_by_branch: true } + mockFleet({ worktrees: [MAIN, { ...STABLE_WT, behind: 5 }], release_channel: taken }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.getAllByText('release-channel-stable')).toHaveLength(1) + expect(screen.queryByTestId('release-channel-placeholder-stable')).not.toBeInTheDocument() + // A SENTENCE, not a badge: three pill wordings each failed a cold read, + // because a pill has room for a verdict and not for the situation plus the + // way out. The row carries the same muted inline status line the placeholder + // row uses, in the same slot, saying in visible text that the checkout is on + // a branch, that this is why the stable release cannot use the name, and what + // frees it. + const sentence = screen.getByTestId('release-channel-name-taken-stable') + expect(sentence.textContent).toBe("On a branch, so the stable release can't use this name — rename or remove it to free it") + expect(sentence.tagName).toBe('SPAN') + // Not a warn Badge: nothing is broken on this row. The old pill texts are gone. + expect(screen.queryByText('Name reserved — on a branch')).toBeNull() + expect(screen.queryByText('Not a release worktree')).toBeNull() + // The tooltip carries the full explanation and names only controls that + // EXIST: Prune merged (a page button) and the git worktree commands. The row + // menu has no Remove or Rename item, so the sentence must not point at one. + const title = sentence.getAttribute('title') ?? '' + expect(title).toBe('release-channel-stable is reserved for the stable release worktree, but this checkout is on a branch. To free the name, rename or remove the checkout: Prune merged removes it once its PR has merged, or run `git worktree move` / `git worktree remove` in a shell') + expect(title).toContain('Prune merged') + expect(title).toContain('git worktree remove') + }) + + it('renders one row, not two, when the reserved name exists but its HEAD is unreadable', async () => { + // A third classification: `worktree_state` answers `detached: null` when HEAD + // cannot be read, so the payload is neither adopted (`worktree` set) nor + // name-taken (`name_taken_by_branch`). Guarding on those two classifications + // let the placeholder render beside the very directory it described. + const unreadable = { + ...CHANNELS.stable, + worktree: null, + name_taken_by_branch: false, + ref: null, + version: null, + error: 'cannot read HEAD for release-channel-stable', + } + mockFleet({ + worktrees: [MAIN, { ...STABLE_WT, branch: null }], + release_channel: unreadable, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.queryByTestId('release-channel-placeholder-stable')).toBeNull() + expect(screen.getAllByText('release-channel-stable')).toHaveLength(1) + // ...and because the row IS in the fleet, the placeholder path is suppressed, + // so the shared ErrorNotice below the row is the surface the backend error + // string reaches. `channelErrorFor` folds this unreadable case in with the + // adopted resolver-failure case, so a Badge title never carries the error. + const notice = screen.getByTestId('release-channel-error-stable') + expect(notice.textContent).toContain('cannot read HEAD for release-channel-stable') + }) + + it('does not conjure a Create placeholder when a filter hides the unreadable-HEAD row', async () => { + // The name check runs against the UNFILTERED fleet. Testing the FILTERED set + // meant a query that hid the channel row made the directory "absent" and the + // placeholder came back — offering to create a worktree that exists on disk, + // on a page where the only visible row with that name would have been the + // placeholder itself. + const unreadable = { + ...CHANNELS.stable, + worktree: null, + name_taken_by_branch: false, + ref: null, + version: null, + error: 'cannot read HEAD for release-channel-stable', + } + mockFleet({ + worktrees: [MAIN, { ...STABLE_WT, branch: null }, FEATURE], + release_channel: unreadable, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + // A query the release row does not match but the feature row does. + fireEvent.change(screen.getByLabelText('Filter worktrees'), { target: { value: 'kirocrew-wt' } }) + await waitFor(() => expect(screen.getByText('kirocrew-wt-update-freshness')).toBeInTheDocument()) + expect(screen.queryByText('release-channel-stable')).toBeNull() + expect(screen.queryByTestId('release-channel-placeholder-stable')).toBeNull() + }) + + it('gives a GENUINE resolver failure an ErrorNotice with the agent hand-off, not just a tooltip', async () => { + // A git failure — distinct from a benign empty channel — is the one error + // class here that reached the user only as tooltip and cell text, which is + // not keyboard-reachable and carries no hand-off. It goes through ErrorNotice + // with askAgent, the same surface every other error on this page uses. + const broken = { + ...CHANNELS.stable, + worktree: null, + ref: null, + version: null, + unpublished: false, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN], release_channel: broken }) + renderPage() + const notice = await waitFor(() => screen.getByTestId('release-channel-error-stable')) + expect(notice).toBeInTheDocument() + expect(notice.textContent).toContain('could not be resolved') + // The framed notice carries the full backend cause, with the hand-off. + expect(notice.textContent).toContain('cannot list tags') + expect(within(notice).getByRole('button', { name: /ask the agent/i })).toBeInTheDocument() + }) + + it('keeps the raw failure out of the placeholder cell and blocks Create', async () => { + const broken = { + ...CHANNELS.stable, + worktree: null, + ref: null, + version: null, + unpublished: false, + error: 'cannot list tags (git tag failed)', + } + mockFleet({ worktrees: [MAIN], release_channel: broken }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + // The cell carries a SHORT, non-truncating status; the raw git mechanism + // stays out of it and lives only in the ErrorNotice below, so the cell can + // ellipsise without ever cutting the sentence a user is reading. The lane + // word is framed as a channel: a bare `stable could not be resolved` reads as + // a name or a version that failed, not the channel. + expect(within(row).getByText('The stable release channel could not be resolved')).toBeInTheDocument() + expect(within(row).queryByText(/cannot list tags/)).toBeNull() + expect(within(row).getByRole('button', { name: /create/i })).toBeDisabled() + }) + + it('labels the table "2 worktrees · 1 to create" beside a placeholder, never a row total that contradicts the heading', async () => { + // "3 rows" beside "Worktrees (2)" and a WORKTREES card of 2 was a visible + // contradiction: three numbers for one table. The label now names both + // populations, and the heading and stat card stay on the worktree count — a + // placeholder is an offer to create a worktree, not one. + mockFleet({ worktrees: [MAIN, FEATURE], release_channel: CHANNELS.uncreated }) + renderPage() + await waitFor(() => expect(screen.getByTestId('release-channel-placeholder-stable')).toBeInTheDocument()) + expect(screen.getByText('2 worktrees · 1 to create')).toBeInTheDocument() + expect(screen.getByText('Worktrees (2)')).toBeInTheDocument() + expect(screen.queryByText('3 rows')).not.toBeInTheDocument() + expect(screen.queryByText('2 rows')).not.toBeInTheDocument() + }) + + it('selects the singular form for one worktree beside a placeholder', async () => { + mockFleet({ worktrees: [MAIN], release_channel: CHANNELS.uncreated }) + renderPage() + await waitFor(() => expect(screen.getByTestId('release-channel-placeholder-stable')).toBeInTheDocument()) + expect(screen.getByText('1 worktree · 1 to create')).toBeInTheDocument() + }) + + it('keeps the plain "N rows" label when no placeholder renders', async () => { + mockFleet({ worktrees: [MAIN, STABLE_WT, FEATURE], release_channel: CHANNELS.stable }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.getByText('3 rows')).toBeInTheDocument() + expect(screen.getByText('Worktrees (3)')).toBeInTheDocument() + expect(screen.queryByText(/to create/)).not.toBeInTheDocument() + }) + + it('counts the pinned channel row in the filter numerator', async () => { + // The channel row is pinned under main rather than sorted with the feature + // rows, so a numerator read off the sorted list alone showed "0 / 3" for a + // filter that matched the one visible channel row — the row was on screen and + // the count said nothing matched. + mockFleet({ worktrees: [MAIN, STABLE_WT, FEATURE], release_channel: CHANNELS.stable }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + fireEvent.change(screen.getByLabelText('Filter worktrees'), { target: { value: 'release-channel' } }) + expect(screen.getByText('release-channel-stable')).toBeInTheDocument() + expect(screen.getByText(/^1 \/ /)).toBeInTheDocument() + expect(screen.queryByText(/^0 \/ /)).not.toBeInTheDocument() + }) + + it('orders channel rows under main and above the feature worktrees', async () => { + // Fixed position, not part of the sort: every sort key on offer describes + // feature-branch progress, and a release worktree scores badly on all of them + // by design. + mockFleet({ + worktrees: [MAIN, FEATURE, STABLE_WT], + release_channel: CHANNELS.stable, + }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + const names = screen + .getAllByText(/^(main|release-channel-\w+|kirocrew-wt-[\w-]+)$/) + .map((n) => n.textContent) + expect(names.indexOf('release-channel-stable')).toBeLessThan( + names.indexOf('kirocrew-wt-update-freshness'), + ) + expect(names.indexOf('main')).toBeLessThan(names.indexOf('release-channel-stable')) + }) + + it('places the placeholder with the channel group, above the feature worktrees', async () => { + mockFleet({ worktrees: [MAIN, FEATURE], release_channel: CHANNELS.uncreated }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + const feature = screen.getByText('kirocrew-wt-update-freshness') + // DOCUMENT_POSITION_FOLLOWING: the feature row comes after the placeholder. + expect(row.compareDocumentPosition(feature) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('posts an empty body to /release-channel/create with no confirm step', async () => { + const seen = mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': CREATE_STARTED }, + { ['/run?id=' + CREATE_RUN_ID]: CREATE_RUN({ ok: true, lane: 'stable', version: '0.5.0' }) }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + // One click. Create is reversible by Remove, and the dialog it used to open + // just repeated the button's own tooltip back at the operator. + await waitFor(() => expect(seen.some((s) => s.url.includes('/release-channel/create'))).toBe(true)) + const post = seen.find((s) => s.url.includes('/release-channel/create'))! + // Served by the SPAWNED backend, through devFleetApi's own base. + expect(post.url).toBe('/apps/dev-fleet/api/release-channel/create') + // An EMPTY body: the server derives the name, the ref and the path, so a + // caller can never name one. + expect(post.body).toEqual({}) + }) + + // The create outlives the app proxy's 30 s request deadline, so the POST + // answers with a run id and the result travels on the run record. Polling uses + // the page's real 2 s cadence, hence the wider windows below. + function runGetCount() { + return vi.mocked(globalThis.fetch).mock.calls.filter(([url, init]) => { + const u = typeof url === 'string' ? url : (url as Request).url + return u.includes('/run?id=' + CREATE_RUN_ID) && init?.method !== 'POST' + }).length + } + + it('reads the Create outcome from the run record and names the next step on success', async () => { + mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': CREATE_STARTED }, + { ['/run?id=' + CREATE_RUN_ID]: CREATE_RUN({ ok: true, lane: 'stable', version: '0.5.0', ref: 'refs/tags/v0.5.0' }) }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + const button = within(row).getByRole('button', { name: /create/i }) + button.click() + // The button stays busy while the run is polled: the POST answering is not + // the create finishing. + await waitFor(() => expect(button).toBeDisabled()) + await waitFor(() => expect(runGetCount()).toBeGreaterThan(0), { timeout: 5000 }) + // The toast reads the result off the run, with the button's own label as the + // next step. + await waitFor(() => expect(screen.getByText(/Created stable worktree at 0\.5\.0 — next: Provision/)).toBeInTheDocument(), { timeout: 5000 }) + expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull() + }, 15000) + + it('keeps polling while the run is running and frames a failure the run reports', async () => { + // First poll: still running. Second: done, with the refusal the create ended + // in. The notice is the same one a synchronous refusal gets. + let polls = 0 + mockFleet({ worktrees: [MAIN], release_channel: CHANNELS.uncreated }, { '/release-channel/create': CREATE_STARTED }) + const passthrough = vi.mocked(globalThis.fetch).getMockImplementation()! + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method !== 'POST' && u.includes('/run?id=' + CREATE_RUN_ID)) { + polls += 1 + const body = polls === 1 + ? { status: 'running', exit_code: null, output: [] } + : CREATE_RUN({ ok: false, code: 'provenance_mismatch', error: 'refusing: v0.5.0 in this checkout does not match what the remote publishes' }) + return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })) + } + return passthrough(url, init) + }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + const notice = await waitFor(() => screen.getByTestId('release-channel-create-error-stable'), { timeout: 7000 }) + expect(polls).toBeGreaterThanOrEqual(2) + expect(notice.textContent).toContain('Could not create the release-channel worktree') + expect(notice.textContent).toContain('does not match what the remote publishes') + // The row is back to offering Create once the run has ended. + await waitFor(() => expect(within(screen.getByTestId('release-channel-placeholder-stable')).getByRole('button', { name: /create/i })).toBeEnabled()) + }, 15000) + + it('attaches to the run a busy 409 names instead of reporting a collision', async () => { + // A reload mid-create loses the page's run id; the next click is refused as + // busy WITH the id, and the page polls that run rather than showing an error. + mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': { ok: false, code: 'busy', error: 'a release-channel-stable create is already running', run_id: CREATE_RUN_ID } }, + { ['/run?id=' + CREATE_RUN_ID]: CREATE_RUN({ ok: true, lane: 'stable', version: '0.5.0' }) }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + await waitFor(() => expect(runGetCount()).toBeGreaterThan(0), { timeout: 5000 }) + await waitFor(() => expect(screen.getByText(/Created stable worktree at 0\.5\.0/)).toBeInTheDocument(), { timeout: 5000 }) + expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull() + }, 15000) + + // The run registry is process-local: a gateway restart mid-create answers the + // next poll with 404 whether or not the create landed. The FLEET is the record + // that survives, so the page reads it fresh and lets it decide. + it('reports a run the gateway no longer knows as lost when the fleet still has no tree', async () => { + mockFleet({ worktrees: [MAIN], release_channel: CHANNELS.uncreated }, { '/release-channel/create': CREATE_STARTED }) + const passthrough = vi.mocked(globalThis.fetch).getMockImplementation()! + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method !== 'POST' && u.includes('/run?id=' + CREATE_RUN_ID)) { + return Promise.resolve(new Response(JSON.stringify({ error: 'unknown run id' }), { status: 404 })) + } + return passthrough(url, init) + }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + const notice = await waitFor(() => screen.getByTestId('release-channel-create-error-stable'), { timeout: 5000 }) + expect(notice.textContent).toContain('Lost track of the create run') + }, 15000) + + it('reads a lost run receipt as success when the fresh fleet carries the adopted tree', async () => { + // The create's `worktree move` landed, then the gateway restarted before the + // page polled the terminal run. The rebuilt fleet shows the adopted row; the + // page must not put a "could not create" notice under it. + mockFleet({ worktrees: [MAIN], release_channel: CHANNELS.uncreated }, { '/release-channel/create': CREATE_STARTED }) + const passthrough = vi.mocked(globalThis.fetch).getMockImplementation()! + let created = false + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method === 'POST' && u.includes('/release-channel/create')) created = true + if (init?.method !== 'POST' && u.includes('/run?id=' + CREATE_RUN_ID)) { + return Promise.resolve(new Response(JSON.stringify({ error: 'unknown run id' }), { status: 404 })) + } + if (init?.method !== 'POST' && u.includes('/fleet') && created) { + return Promise.resolve(new Response(JSON.stringify({ worktrees: [MAIN, STABLE_WT], release_channel: CHANNELS.stable }), { status: 200 })) + } + return passthrough(url, init) + }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + await waitFor(() => expect(screen.getByText(/Created stable worktree at 0\.5\.0/)).toBeInTheDocument(), { timeout: 5000 }) + expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull() + }, 15000) + + // A failed Create is the case the whole placeholder exists for: the backend + // refuses (name held by a branch, unresolvable tip, provenance mismatch) or the + // request never lands, and the fleet refetch that follows re-renders the row + // exactly as before — worktree still missing, nothing on the page saying why. A + // toast was the only record and it self-dismissed. The failure goes through the + // shared ErrorNotice on the row, with the hand-off, and stays until the + // operator retries or dismisses it. + function fleetGetCount() { + return vi.mocked(globalThis.fetch).mock.calls.filter(([url, init]) => { + const u = typeof url === 'string' ? url : (url as Request).url + return u.includes('/fleet') && init?.method !== 'POST' + }).length + } + + it('keeps a refused Create on the row as an ErrorNotice with the hand-off, through the fleet refetch', async () => { + mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': { ok: false, error: 'refusing: release-channel-stable is held by a branch checkout' } }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + const before = fleetGetCount() + within(row).getByRole('button', { name: /create/i }).click() + const notice = await waitFor(() => screen.getByTestId('release-channel-create-error-stable')) + // Framed as an outcome with its cause, not the bare backend string. + expect(notice.textContent).toContain('Could not create the release-channel worktree') + expect(notice.textContent).toContain('refusing: release-channel-stable is held by a branch checkout') + expect(within(notice).getByRole('button', { name: /ask the agent/i })).toBeInTheDocument() + // The failure triggers a fleet refetch that returns the SAME placeholder + // payload. The notice is component state, not fleet state, so it survives. + await waitFor(() => expect(fleetGetCount()).toBeGreaterThan(before)) + await waitFor(() => expect(screen.getByTestId('release-channel-placeholder-stable')).toBeInTheDocument()) + expect(screen.getByTestId('release-channel-create-error-stable')).toBeInTheDocument() + // A refused Create is not a resolver failure: that notice stays absent. + expect(screen.queryByTestId('release-channel-error-stable')).toBeNull() + }) + + it('keeps the refusal notice when the refetch replaces the placeholder with the created row', async () => { + // A stale tab: another client created the worktree, this tab still shows the + // placeholder and clicks Create. The backend answers `409 exists`, and the + // refetch that follows now carries the reserved worktree -- so the + // placeholder UNMOUNTS. The notice must not go with it: the operator has a + // refusal, its recovery steps and the hand-off to read, undismissed. + let fleetCalls = 0 + const uncreated = { worktrees: [MAIN], release_channel: CHANNELS.uncreated } + const created = { worktrees: [MAIN, STABLE_WT], release_channel: CHANNELS.stable } + vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method === 'POST' && u.includes('/release-channel/create')) { + return Promise.resolve(new Response(JSON.stringify({ + ok: false, code: 'exists', + error: 'release-channel-stable already exists -- remove it first, then create it again', + }), { status: 409 })) + } + if (u.includes('/fleet')) { + fleetCalls += 1 + // First load: placeholder. Every refetch after the click: the created row. + return Promise.resolve(new Response(JSON.stringify(fleetCalls === 1 ? uncreated : created), { status: 200 })) + } + if (u.includes('/disk')) return Promise.resolve(new Response(JSON.stringify({ total_mb: 51200 }), { status: 200 })) + return Promise.resolve(new Response('{}', { status: 200 })) + }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + // The refetch lands the adopted row and the placeholder is gone... + await waitFor(() => expect(screen.queryByTestId('release-channel-placeholder-stable')).toBeNull()) + await waitFor(() => expect(screen.getByText('0.5.0')).toBeInTheDocument()) + // ...and the refusal is still on the page, with its cause and the hand-off. + const notice = screen.getByTestId('release-channel-create-error-stable') + expect(notice.textContent).toContain('Could not create the release-channel worktree') + expect(notice.textContent).toContain('already exists') + expect(within(notice).getByRole('button', { name: /ask the agent/i })).toBeInTheDocument() + // Dismiss still works from the new row shape. + within(notice).getByRole('button', { name: /dismiss/i }).click() + await waitFor(() => expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull()) + }) + + it('renders a transport failure on Create through the same notice, with its message', async () => { + mockFleet({ worktrees: [MAIN], release_channel: CHANNELS.uncreated }) + const passthrough = vi.mocked(globalThis.fetch).getMockImplementation()! + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method === 'POST' && u.includes('/release-channel/create')) { + return Promise.reject(new Error('Failed to fetch')) + } + return passthrough(url, init) + }) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + const notice = await waitFor(() => screen.getByTestId('release-channel-create-error-stable')) + expect(notice.textContent).toContain('Failed to fetch') + expect(within(notice).getByRole('button', { name: /ask the agent/i })).toBeInTheDocument() + }) + + it('clears the previous Create failure when Create is clicked again', async () => { + mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': { ok: false, error: 'refusing: first attempt' } }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + await waitFor(() => expect(screen.getByTestId('release-channel-create-error-stable').textContent).toContain('first attempt')) + // The retry succeeds: the notice described an attempt this click supersedes, + // so it goes at the click, and nothing replaces it on success. + const passthrough = vi.mocked(globalThis.fetch).getMockImplementation()! + vi.mocked(globalThis.fetch).mockImplementation((url, init) => { + const u = typeof url === 'string' ? url : (url as Request).url + if (init?.method === 'POST' && u.includes('/release-channel/create')) { + return Promise.resolve(new Response(JSON.stringify(CREATE_STARTED), { status: 202 })) + } + if (init?.method !== 'POST' && u.includes('/run?id=' + CREATE_RUN_ID)) { + return Promise.resolve(new Response(JSON.stringify(CREATE_RUN({ ok: true, lane: 'stable', version: '0.5.0' })), { status: 200 })) + } + return passthrough(url, init) + }) + await waitFor(() => expect(within(row).getByRole('button', { name: /create/i })).toBeEnabled()) + within(row).getByRole('button', { name: /create/i }).click() + await waitFor(() => expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull()) + // ...and stays gone once the run reports success. + await waitFor(() => expect(screen.getByText(/Created stable worktree at 0\.5\.0/)).toBeInTheDocument(), { timeout: 5000 }) + expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull() + }, 15000) + + it('removes the Create failure notice on dismiss', async () => { + mockFleet( + { worktrees: [MAIN], release_channel: CHANNELS.uncreated }, + { '/release-channel/create': { ok: false, error: 'refusing: working tree is redirected' } }, + ) + renderPage() + const row = await waitFor(() => screen.getByTestId('release-channel-placeholder-stable')) + within(row).getByRole('button', { name: /create/i }).click() + const notice = await waitFor(() => screen.getByTestId('release-channel-create-error-stable')) + within(notice).getByRole('button', { name: /dismiss/i }).click() + await waitFor(() => expect(screen.queryByTestId('release-channel-create-error-stable')).toBeNull()) + // Dismissing the notice does not touch the row: the placeholder still offers Create. + expect(within(screen.getByTestId('release-channel-placeholder-stable')).getByRole('button', { name: /create/i })).toBeInTheDocument() + }) + + it('renders nothing channel-specific when the payload carries no release_channel', async () => { + // `null` for the whole key means nothing can be said (no repo, or the + // resolver blew up before it could classify). No placeholder, no badge, no + // error surface — and the plain row total stands. + mockFleet({ worktrees: [MAIN, STABLE_WT, FEATURE], release_channel: null }) + renderPage() + await waitFor(() => expect(screen.getByText('release-channel-stable')).toBeInTheDocument()) + expect(screen.queryByTestId('release-channel-placeholder-stable')).toBeNull() + expect(screen.queryByTestId('release-channel-error-stable')).toBeNull() + expect(screen.queryByText('n/a')).toBeNull() + // The reserved name alone confers nothing: the row keeps its behind-main count. + expect(screen.getByText('↓412')).toBeInTheDocument() + expect(screen.getByText('3 rows')).toBeInTheDocument() + }) +})