Add facet update with interactive picker and tagged InstallOperation - #557
Conversation
…view, and `upgrade` alias
…vigation, discovery progress indicator, and latest-only candidate fix
…, rejected lookups, and column misalignment
…nd show dry-run plan on no-op
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
🦋 Changeset detectedLatest commit: 1c0c5e7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Too many files changed for review (117 files, 100 file limit). Bypass the limit by tagging |
prepareFacetUpdate, batch metadata limits, and response identity checksfacet update with interactive picker and tagged InstallOperation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Important Review skippedToo many files! This PR contains 117 files, which is 17 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (117)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Merge activity
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1c0c5e71dd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // network error produces. | ||
| let settled: ReadonlyArray<RegistryResult<ReadonlyArray<RegistryMetadata>>> | ||
| try { | ||
| settled = await Promise.all(groups.map((group) => resolve(group))) |
There was a problem hiding this comment.
Bound discovery concurrency across batches
When a project produces more than 100 metadata specifiers, this Promise.all starts every group concurrently, while resolveRegistryMetadataBatch itself starts up to 100 concurrent fetchOne calls (packages/engine/src/registry/resolve-metadata.ts:89). Consequently a 10,000-facet manifest can still open roughly 20,000 requests at once, defeating the new cap's stated purpose and making large updates likely to hit registry rate limits or local socket limits; process groups sequentially or through a bounded worker pool.
Useful? React with 👍 / 👎.
| const targetAt = specs.push({ name: facet.name, version: facet.authored.spec }) - 1 | ||
| const latestAt = specs.push({ name: facet.name, version: { kind: 'latest' } }) - 1 |
There was a problem hiding this comment.
Reuse the Latest lookup for floating specifiers
For facets authored as * or latest, these two entries ask the registry the same semantic question in separate requests. If a release is published or different registry replicas answer between those requests, Target and Latest can disagree even though both specifiers mean the newest release, so plain update and --latest can select different versions for an already-floating facet; use the single Latest response for both columns when the authored specifier resolves to latest.
Useful? React with 👍 / 👎.
This PR was auto-generated by the release workflow. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## agent-facets@0.33.0 ### Minor Changes - [#557](#557) [`9a061c4`](9a061c4) Thanks [@eXamadeus](https://github.com/eXamadeus)! - **New command: `facet update` (aliased `facet upgrade`)** — moves the registry-backed facets a project declares to newer releases. It reads `facets.json` and `facets.lock`, asks the registry for each facet's range-respecting target and its latest release, and shows both alongside what is installed, so "why is this one not moving?" is answerable from the plan itself. Plain `facet update` takes every target the declared specifier already permits. `--latest` (`-L`) crosses those specifiers and rewrites them by the smallest edit that admits the new version, preserving how the intent was written: a pin stays a pin, `1.*` becomes `2.*`, `1.2.*` becomes `2.4.*`, and `*` and `latest` are left exactly as authored. **Previews and per-facet selection.** `--dry-run` prints the plan and writes nothing — no manifest, no lockfile, no receipt, no assets, no cache, and no adapter installation, which makes it safe on a machine with no adapter connected. `--interactive` (`-i`) opens a picker for choosing which facets move and which version each takes. Every row starts on its **latest** release with nothing selected, so walking the list and pressing `Space` takes the newest version of each facet you pick — `Space` always means "yes, this one", and a facet you never touch is left alone. `◀ ▶` (or `l`) moves a row to its range target first when that is what you want instead. `--latest` is accepted alongside `--interactive` but changes nothing there; it is how the non-interactive run asks for what this screen already offers. The picker requires a real terminal and fails immediately without one, before any registry lookup. Git and local facets are named as unsupported rather than counted as current — reporting them as up to date would claim something nothing verified. **Applying an update is an install.** Discovery runs read-only and takes no project lock, so reading a plan never blocks another facet operation. Application re-checks under the lock that the project has not moved since the plan was reviewed, then runs the ordinary install pipeline: the same verification, collision handling, MCP approval, rollback, and atomic manifest/lockfile/receipt write. The version you reviewed is the version installed — a release published in between does not silently change it. Recorded materialization choices survive a version change. There is no `--frozen-lockfile`: reproducing what the lockfile already records is the opposite of what this command does. **Breaking: `facet upgrade` is no longer a placeholder.** It previously printed a not-yet-implemented notice and exited `0` without touching a single file. It is now an alias of `facet update` — one command, one help page, one behavior — so the same invocation contacts the registry, may install adapters, takes the project lock, and rewrites `facets.json`, `facets.lock`, the install receipt, and your materialized assets. `update` is the canonical spelling, and `facet upgrade --help` prints `Usage: facet update`. If something in your automation called `facet upgrade` expecting a no-op, drop the call or make it explicit with `facet update --dry-run`. Neither name touches the CLI binary. That remains `facet self-update`. **Protocol: version components are bounded by exact integer representation.** The published version grammar now rejects a specifier or locked version whose numeric component exceeds `2^53 - 1`, with an error that names the magnitude rather than the form. Above that bound two distinct releases are the same double — `9007199254740992` and `9007199254740993` compare equal — so a comparison that decides which release is newer, or whether a locked version still satisfies a manifest range, could answer for a version that was never published. `facet update` is the first command whose whole job is that comparison, which is why the bound lands now. No real version is anywhere near it. ### Patch Changes - [#554](#554) [`d703ff1`](d703ff1) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Remove empty directories left behind when an install rolls back. Cleanup previously only reclaimed directories the run could prove it had created, identified by inode. That test was both too strict and unsound: a directory that existed before the run was never a candidate no matter how empty the rollback left it, and inode identity proves nothing on Linux, which recycles an inode the moment it is freed. Rollback now asks the only question that matters — is anything left inside? — and hands the answer to `rmdir`, which is non-recursive and so refuses to remove a directory holding anything at all, yours or ours, in a single step with no check-then-delete window. The walk climbs from each restored path and stops at the tool's configuration directory (`.claude`, `.opencode`), which is never removed and never climbed past, so cleanup can only reclaim the tree the install materialized. ## @agent-facets/protocol@0.31.1 ### Patch Changes - [#557](#557) [`9a061c4`](9a061c4) Thanks [@eXamadeus](https://github.com/eXamadeus)! - **New command: `facet update` (aliased `facet upgrade`)** — moves the registry-backed facets a project declares to newer releases. It reads `facets.json` and `facets.lock`, asks the registry for each facet's range-respecting target and its latest release, and shows both alongside what is installed, so "why is this one not moving?" is answerable from the plan itself. Plain `facet update` takes every target the declared specifier already permits. `--latest` (`-L`) crosses those specifiers and rewrites them by the smallest edit that admits the new version, preserving how the intent was written: a pin stays a pin, `1.*` becomes `2.*`, `1.2.*` becomes `2.4.*`, and `*` and `latest` are left exactly as authored. **Previews and per-facet selection.** `--dry-run` prints the plan and writes nothing — no manifest, no lockfile, no receipt, no assets, no cache, and no adapter installation, which makes it safe on a machine with no adapter connected. `--interactive` (`-i`) opens a picker for choosing which facets move and which version each takes. Every row starts on its **latest** release with nothing selected, so walking the list and pressing `Space` takes the newest version of each facet you pick — `Space` always means "yes, this one", and a facet you never touch is left alone. `◀ ▶` (or `l`) moves a row to its range target first when that is what you want instead. `--latest` is accepted alongside `--interactive` but changes nothing there; it is how the non-interactive run asks for what this screen already offers. The picker requires a real terminal and fails immediately without one, before any registry lookup. Git and local facets are named as unsupported rather than counted as current — reporting them as up to date would claim something nothing verified. **Applying an update is an install.** Discovery runs read-only and takes no project lock, so reading a plan never blocks another facet operation. Application re-checks under the lock that the project has not moved since the plan was reviewed, then runs the ordinary install pipeline: the same verification, collision handling, MCP approval, rollback, and atomic manifest/lockfile/receipt write. The version you reviewed is the version installed — a release published in between does not silently change it. Recorded materialization choices survive a version change. There is no `--frozen-lockfile`: reproducing what the lockfile already records is the opposite of what this command does. **Breaking: `facet upgrade` is no longer a placeholder.** It previously printed a not-yet-implemented notice and exited `0` without touching a single file. It is now an alias of `facet update` — one command, one help page, one behavior — so the same invocation contacts the registry, may install adapters, takes the project lock, and rewrites `facets.json`, `facets.lock`, the install receipt, and your materialized assets. `update` is the canonical spelling, and `facet upgrade --help` prints `Usage: facet update`. If something in your automation called `facet upgrade` expecting a no-op, drop the call or make it explicit with `facet update --dry-run`. Neither name touches the CLI binary. That remains `facet self-update`. **Protocol: version components are bounded by exact integer representation.** The published version grammar now rejects a specifier or locked version whose numeric component exceeds `2^53 - 1`, with an error that names the magnitude rather than the form. Above that bound two distinct releases are the same double — `9007199254740992` and `9007199254740993` compare equal — so a comparison that decides which release is newer, or whether a locked version still satisfies a manifest range, could answer for a version that was never published. `facet update` is the first command whose whole job is that comparison, which is why the bound lands now. No real version is anywhere near it.
This PR was auto-generated by the release workflow. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## agent-facets@0.33.0 ### Minor Changes - [#557](#557) [`9a061c4`](9a061c4) Thanks [@eXamadeus](https://github.com/eXamadeus)! - **New command: `facet update` (aliased `facet upgrade`)** — moves the registry-backed facets a project declares to newer releases. It reads `facets.json` and `facets.lock`, asks the registry for each facet's range-respecting target and its latest release, and shows both alongside what is installed, so "why is this one not moving?" is answerable from the plan itself. Plain `facet update` takes every target the declared specifier already permits. `--latest` (`-L`) crosses those specifiers and rewrites them by the smallest edit that admits the new version, preserving how the intent was written: a pin stays a pin, `1.*` becomes `2.*`, `1.2.*` becomes `2.4.*`, and `*` and `latest` are left exactly as authored. **Previews and per-facet selection.** `--dry-run` prints the plan and writes nothing — no manifest, no lockfile, no receipt, no assets, no cache, and no adapter installation, which makes it safe on a machine with no adapter connected. `--interactive` (`-i`) opens a picker for choosing which facets move and which version each takes. Every row starts on its **latest** release with nothing selected, so walking the list and pressing `Space` takes the newest version of each facet you pick — `Space` always means "yes, this one", and a facet you never touch is left alone. `◀ ▶` (or `l`) moves a row to its range target first when that is what you want instead. `--latest` is accepted alongside `--interactive` but changes nothing there; it is how the non-interactive run asks for what this screen already offers. The picker requires a real terminal and fails immediately without one, before any registry lookup. Git and local facets are named as unsupported rather than counted as current — reporting them as up to date would claim something nothing verified. **Applying an update is an install.** Discovery runs read-only and takes no project lock, so reading a plan never blocks another facet operation. Application re-checks under the lock that the project has not moved since the plan was reviewed, then runs the ordinary install pipeline: the same verification, collision handling, MCP approval, rollback, and atomic manifest/lockfile/receipt write. The version you reviewed is the version installed — a release published in between does not silently change it. Recorded materialization choices survive a version change. There is no `--frozen-lockfile`: reproducing what the lockfile already records is the opposite of what this command does. **Breaking: `facet upgrade` is no longer a placeholder.** It previously printed a not-yet-implemented notice and exited `0` without touching a single file. It is now an alias of `facet update` — one command, one help page, one behavior — so the same invocation contacts the registry, may install adapters, takes the project lock, and rewrites `facets.json`, `facets.lock`, the install receipt, and your materialized assets. `update` is the canonical spelling, and `facet upgrade --help` prints `Usage: facet update`. If something in your automation called `facet upgrade` expecting a no-op, drop the call or make it explicit with `facet update --dry-run`. Neither name touches the CLI binary. That remains `facet self-update`. **Protocol: version components are bounded by exact integer representation.** The published version grammar now rejects a specifier or locked version whose numeric component exceeds `2^53 - 1`, with an error that names the magnitude rather than the form. Above that bound two distinct releases are the same double — `9007199254740992` and `9007199254740993` compare equal — so a comparison that decides which release is newer, or whether a locked version still satisfies a manifest range, could answer for a version that was never published. `facet update` is the first command whose whole job is that comparison, which is why the bound lands now. No real version is anywhere near it. ### Patch Changes - [#554](#554) [`d703ff1`](d703ff1) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Remove empty directories left behind when an install rolls back. Cleanup previously only reclaimed directories the run could prove it had created, identified by inode. That test was both too strict and unsound: a directory that existed before the run was never a candidate no matter how empty the rollback left it, and inode identity proves nothing on Linux, which recycles an inode the moment it is freed. Rollback now asks the only question that matters — is anything left inside? — and hands the answer to `rmdir`, which is non-recursive and so refuses to remove a directory holding anything at all, yours or ours, in a single step with no check-then-delete window. The walk climbs from each restored path and stops at the tool's configuration directory (`.claude`, `.opencode`), which is never removed and never climbed past, so cleanup can only reclaim the tree the install materialized. ## @agent-facets/protocol@0.31.1 ### Patch Changes - [#557](#557) [`9a061c4`](9a061c4) Thanks [@eXamadeus](https://github.com/eXamadeus)! - **New command: `facet update` (aliased `facet upgrade`)** — moves the registry-backed facets a project declares to newer releases. It reads `facets.json` and `facets.lock`, asks the registry for each facet's range-respecting target and its latest release, and shows both alongside what is installed, so "why is this one not moving?" is answerable from the plan itself. Plain `facet update` takes every target the declared specifier already permits. `--latest` (`-L`) crosses those specifiers and rewrites them by the smallest edit that admits the new version, preserving how the intent was written: a pin stays a pin, `1.*` becomes `2.*`, `1.2.*` becomes `2.4.*`, and `*` and `latest` are left exactly as authored. **Previews and per-facet selection.** `--dry-run` prints the plan and writes nothing — no manifest, no lockfile, no receipt, no assets, no cache, and no adapter installation, which makes it safe on a machine with no adapter connected. `--interactive` (`-i`) opens a picker for choosing which facets move and which version each takes. Every row starts on its **latest** release with nothing selected, so walking the list and pressing `Space` takes the newest version of each facet you pick — `Space` always means "yes, this one", and a facet you never touch is left alone. `◀ ▶` (or `l`) moves a row to its range target first when that is what you want instead. `--latest` is accepted alongside `--interactive` but changes nothing there; it is how the non-interactive run asks for what this screen already offers. The picker requires a real terminal and fails immediately without one, before any registry lookup. Git and local facets are named as unsupported rather than counted as current — reporting them as up to date would claim something nothing verified. **Applying an update is an install.** Discovery runs read-only and takes no project lock, so reading a plan never blocks another facet operation. Application re-checks under the lock that the project has not moved since the plan was reviewed, then runs the ordinary install pipeline: the same verification, collision handling, MCP approval, rollback, and atomic manifest/lockfile/receipt write. The version you reviewed is the version installed — a release published in between does not silently change it. Recorded materialization choices survive a version change. There is no `--frozen-lockfile`: reproducing what the lockfile already records is the opposite of what this command does. **Breaking: `facet upgrade` is no longer a placeholder.** It previously printed a not-yet-implemented notice and exited `0` without touching a single file. It is now an alias of `facet update` — one command, one help page, one behavior — so the same invocation contacts the registry, may install adapters, takes the project lock, and rewrites `facets.json`, `facets.lock`, the install receipt, and your materialized assets. `update` is the canonical spelling, and `facet upgrade --help` prints `Usage: facet update`. If something in your automation called `facet upgrade` expecting a no-op, drop the call or make it explicit with `facet update --dry-run`. Neither name touches the CLI binary. That remains `facet self-update`. **Protocol: version components are bounded by exact integer representation.** The published version grammar now rejects a specifier or locked version whose numeric component exceeds `2^53 - 1`, with an error that names the magnitude rather than the form. Above that bound two distinct releases are the same double — `9007199254740992` and `9007199254740993` compare equal — so a comparison that decides which release is newer, or whether a locked version still satisfies a manifest range, could answer for a version that was never published. `facet update` is the first command whose whole job is that comparison, which is why the bound lands now. No real version is anywhere near it. Co-authored-by: the-faceter[bot] <272408671+the-faceter[bot]@users.noreply.github.com>

Why
A project could declare facets and reproduce them, but had no way to move them to newer releases.
facet installreproduces what the lockfile already records — the opposite operation.#542 landed the read-only half of this:
prepareFacetUpdate, batched registry metadata with theTOO_MANY_SPECIFIERSguard, and response identity checks. This PR adds everything that turns that plan into a shipped command — the application half and thefacet updateCLI surface, aliasedfacet upgrade. It supersedes the stack that spanned #543 through #552, which is closed in favor of reviewing this as one unit.Details
Applying a reviewed plan
Planning runs without the install lock so a user can read an interactive picker or a dry-run preview without blocking the machine. Application then runs under the lock as an ordinary install transaction.
The plan carries the exact
FileStateof bothfacets.jsonandfacets.lockfrom the moment it was built. Before touching anything, application compares those bytes against disk; if the project moved while the plan was being reviewed, the plan is withdrawn (UPDATE_PLAN_STALE) rather than merged into state the user never saw. The comparison is byte-level rather than semantic, matching the write preconditions used later in the same run.No registry question is asked twice. Discovery resolved the metadata, the user approved that answer, and application installs precisely that release — re-resolving a range at apply time could pick up something published after the user looked.
InstallOperationThe install entry point previously took an optional additions/removals delta plus a separate
frozenLockfileboolean. That pair could express states no caller should be able to say: the same facet added and removed, an add and a remove in one run, a frozen run that mutates the locked set, and two spellings of "just reproduce the lockfile". It is now one tagged union —reproduce/add/remove/update— where those combinations are unconstructible, so theFROZEN_WITH_DELTAandDELTA_CONFLICTruntime guards are deleted rather than reimplemented. Interaction capabilities (collision resolution, MCP consent, asset takeover) hang off the arms that can legitimately use them, so frozen reproduction can no longer be handed a resolver it would silently ignore.Current, Target, Latest
Every checkable facet has three versions, and the plan shows all three — including for facets that are not moving, since that is what answers "why is this one staying put?". Plain
facet updatetakes every Target that advances, staying inside the declared range and never rewritingfacets.json.--latesttakes every Latest that advances, and because that may cross the declared range, it rewrites the specifier by the smallest edit that admits the new version while preserving how the intent was expressed: a pin stays a pin, a major wildcard stays a major wildcard, an already-floating specifier is left verbatim.Interactive selection
--interactiveopens a three-column picker with arrow-key column navigation and a discovery progress indicator. Every row starts on its latest release with nothing selected, so a single keypress takes the newest version. Selection logic lives in the engine rather than the view, so a dry run and a real application cannot disagree about what a choice commits. Without a usable terminal the command fails before contacting the registry rather than after.Version components are bounded at 2^53−1 so ordering comparisons stay exact.
Verification
bun checkis green: lint, type checks, unit tests, e2e, docs validation, and OpenSpec validation. Coverage includes version ordering, specifier rewriting, discovery classification, picker interaction and column layout, the stale-plan withdrawal path, and the apply lifecycle against a real temporary project tree.The OpenSpec change
add-facet-update-commandis archived into the canonical specs here.