[v0.1/P0] Define immutable build identity and offline version output #17
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#17
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent: #12 (P0.3). Supersedes the release-identity part of #9.
Why
The current
--versionreports hashes without semver and launches the client and contacts a daemon. There is no typed identity shared by the three processes, and dirty builds are not distinguishable.Scope
BuildIdentity { version, revision, dirty }.Welcome, daemon status, client diagnostics, and machine output.semver (revision[-dirty])for humans.felis --versionreport only the front-door build with no subprocess or network I/O.felis version --format human|json.Acceptance criteria
felis --versionsucceeds offline and cannot autospawn or dial.CLI args focused review (#55) で追加確認。
現状は
felis --version sessions listとfelis --version -- htopが parse に成功し、後続 command/argv を無視して exit 0 の version report を返す。offline/self-only 化に加え、標準--versionと同様に exclusive であることを acceptance に含めたい。既知 subcommand を silent ignore する形は typo を隠す。Triage plan (2026-09-03)
Source-grounded triage against
mainat69076d42, reviewed through seven rounds of an independent reviewer (pisol/luna) until it passed with no findings. The dependency order that supersedes the tracker's is posted on #12. Where a "Review amendments" section below conflicts with an earlier section, the amendment is the decision.Claim check
Every claim in the issue body and in the #55 comment holds at HEAD (
69076d42).--versionreports hashes without semver.crates/felis-cli/src/cli_version.rs:18-22prints three lines of the formcli (hash); the only place a semver appears is the client's and daemon's own clap--version(crates/felis-client/src/main.rs:241,crates/felis-daemon/src/lib.rs:46-48), which the front door never shows. Live run oftarget/debug/felis --versiongivescli (69076d42)/client (5077d74b)/daemon (b2a2ddf)— note the hash width already differs between processes (8 vs 7 chars) becausebuild-common.rs:27-28usesgit rev-parse --short, whose width depends on the local object store. Nothing sourced from the identity is stable enough to compare.It launches the client and contacts a daemon.
cli_version.rs:26-36runsfelis-client --versionas a subprocess and scrapes the parenthesised hash (paren_hash,:38-42);cli_version.rs:46-78builds a tokio runtime and dials the daemon throughcrate::conn::dial(crates/felis-cli/src/conn.rs:103-105). The local carrier never autospawns from that path (RemoteSpawnonly governs the SSH relay;crates/felis-client-core/src/connector.rs:436-446,455-466), so the issue's "cannot autospawn" criterion is already met locally — verified:felis --version --socket /nonexistent/x.sockprintsdaemon (not running), exit 0. But with--hostthe dial spawnsssh <host> felis-daemon relay --no-spawn(connector.rs:237-245,462-467), which is network I/O.docs/reference/cli.md:767-768documents--host/--socketas selecting which daemon--versionasks, so the docs agree with the code and both must change.No typed identity shared by the three processes. The wire carries two untyped strings:
ConnWelcome.build_id(crates/felis-protocol/proto/felis.proto:655-659, populated fromfelis_daemon::build_hash()atcrates/felis-daemon/src/serve.rs:1930) andOpsStatusReply.versionas"<semver> (<hash>)"(felis.proto:1369-1371). Consumers:cli_doctor.rs:128-141(printsconn.daemon_build_idinside a sentence),cli_daemon.rs:73-75,125(echoes the string), and the bench harnessscripts/bench/envinfo.py:195-206which regex-scrapesfelis --version(\(([0-9a-f]{7,40})\)) — a third scraper the issue does not mention but which the change breaks.Dirty builds are not distinguishable — partially wrong. The Cargo path (
crates/build-common.rs:5-33) has no dirty detection at all. The Nix flake path already stamps-dirty:flake.nix:251passesinputs.self.shortRev or inputs.self.dirtyShortRev, andnix/package.nix:32,78forwards it asFELIS_GIT_HASH. Sonix buildof a dirty tree yields(abc1234-dirty);cargo buildof the same tree yields(abc1234). Two further stamping gaps: the overlay (flake.nix:530) and the Home Manager module default package (nix/hm-module.nix:95) callpackage.nixwith nogitHash, so those install paths report(unknown).#55 comment:
--versionis not exclusive. Verified live:felis --version sessions listandfelis --version -- htopboth print the report and exit 0. Cause:versionis a plainboolarg (crates/felis-cli/src/main.rs:294-295) checked before the subcommand match (main.rs:347-351); noconflicts_withoncmd/command.Nothing here was changed by the three post-snapshot commits (they touched
font.size_pxand the spawn separator only).Verdict
accept-with-changes. The direction is right; three scope adjustments:
felis --versionbecomes the clap-standard self-report of the front-door binary only (offline, exclusive). The cross-process comparison moves to a newfelis versionverb (--format human|json), which is the only place that may execfelis-clientor dial. Today's aggregate--versiondisappears; do not keep a "report if reachable" middle ground.BuildIdentitytoWelcome/StatusReplyas replacements forbuild_id/versionon the 2.0 schema rather than as ledger row 6 beside them. If #17 lands before #30, add them additively (row 6) and let #30 collapse the pair. Either way the recommended order in #12 (#19 and #45 before #17) still holds because #19's compatibility gate is what makes the proto edit reviewable.BuildIdentityis pure data (String,String,bool) and belongs infelis-protocol(the everyone-depends-on-it crate; no tokio/OS use, so the purity rule atCLAUDE.md"Hard rule" is respected). ItsDisplay/FromStrcanonical form is the one thing every process shares, and that is the "typed shape rather than scraped text" the acceptance criterion asks for — the front door parsesfelis-client --version's canonical line with the sameFromStr, round-trip tested, instead ofparen_hash.Principle check: no capability is added (principle 1 test "no real consumer needs it yet" — the consumers are
doctor, the bench harness, and #18's release gate); explicitness improves (principle 4). Pass.Approach
felis-protocol (
crates/felis-protocol)src/build_identity.rs:pub struct BuildIdentity { pub version: String, pub revision: String, pub dirty: bool }withDisplayrendering<version> (<revision>[-dirty])andFromStraccepting exactly that (revision =unknownor 7..40 lowercase hex; reject anything else). Keep the hash full (40 hex) in the type;Displayabbreviates to a fixed 12 so the width no longer floats with the object store. Proptest round-tripDisplay → FromStr(independent oracle: build the string by hand, per the extend-ipc skill's "never production against itself").proto/felis.proto:message BuildIdentity { string version = 1; string revision = 2; bool dirty = 3; }; addBuildIdentity identitytoConnWelcomeandOpsStatusReply(field numbers per #30's outcome, see Verdict 2).just proto, commitsrc/generated/,convert/conn.rs:75,118and the ops convert arm, plus the fixtures atmessages/conn.rs:112-115,codec.rs:252,felis-grid/src/transcode.rs:183,benches/ipc_throughput.rs:43.Build stamping (
crates/build-common.rs, the fourbuild.rsincludes)FELIS_BUILD_VERSION(=CARGO_PKG_VERSION, kept for symmetry),FELIS_BUILD_REVISION(fullgit rev-parse HEAD),FELIS_BUILD_DIRTY(git status --porcelain --untracked-files=nonon-empty). Keep accepting the singleFELIS_GIT_HASH=<hex>[-dirty]env as the Nix injection form sonix/package.nix:78andflake.nix:251need only switch fromshortRev/dirtyShortRevtorev/dirtyRev(full hash). Addrerun-if-changed=../../.git/indexso a staged change re-stamps; note in the comment that an unstaged edit will not (the exact-identity guarantee is the Nix build's, not cargo's).(unknown)paths:flake.nix:530overlay andnix/hm-module.nix:95should take the samegitHash(threadselfthrough, e.g.packages.felisreused viainputs.self.packages.${system}.felisfor the HM default, and the overlay documented as "unknown unless you passgitHash").pub const fn build_identity() -> BuildIdentity-equivalent (aBuildIdentity::from_env_consts(env!(...))helper);felis_daemon::version()(lib.rs:46-48) andfelis-client'sFELIS_VERSION(main.rs:241) render throughDisplayso the three binaries'--versionare byte-identical in shape.felis-cli (
crates/felis-cli)main.rs:294-295: replace the bool with clap's built-in#[command(version = <const rendered identity>)]so-V/--versionis exclusive by clap's own rules and printsfelis <semver> (<rev12>[-dirty]), no subprocess, no runtime, no dial. Add a test thatfelis --version sessions listandfelis --version -- htopexit 2 with a usage error (exit-code contract atdocs/reference/cli.md:44-50). If clap'sVersionaction turns out to short-circuit before seeing the trailing tokens, keep the bool and emitCli::command().error(ErrorKind::ArgumentConflict, ..)whencmd.is_some() || !command.is_empty(); either way the test pins the contract.Cmd::Version { #[command(flatten)] format: PointFormat }incli_version.rs: human output is three aligned rows (cli,client,daemon) each rendering aBuildIdentityor one of the status words already defined (not running,unavailable,incompatible: …atcli_version.rs:44-78); JSON is the standard{"v":1, ...}envelope with{"cli": {version,revision,dirty}, "client": {…}|null, "daemon": {…}|null, "daemon_status": "…"}. Client identity comes fromfelis-client --versionparsed withBuildIdentity::from_str; daemon identity fromWelcome.identity. This verb is the only one allowed the subprocess and the dial; keep theRefusesemantics and cold-socket tests (cli_version.rs:108-215) as they are, retargeted at the verb.cli_doctor.rs:128-141andcli_daemon.rs:73-75,125: render the typed identity;felis daemon status --format jsongainsidentitybeside the existingversionstring (or replaces it under #30).nix/package.nix:123-134).Release-build rejection of dirty trees
Belongs to #18's workflow, not to the build script: the tag job evaluates
nix eval .#felis.gitHash/self.dirtyRevand fails on-dirty, then asserts the built binary'sfelis --versionreportsdirty=falseand the tag's revision. This issue only has to make the fact observable; document the split in both issues.Other consumers
scripts/bench/envinfo.py:195-230(paren_hash,revision_drift) andcrossterm_test.py:615-635: parse the new canonical line (felis <semver> (<rev12>[-dirty]));revision_driftcan then use thedirtyflag instead of a suffix heuristic.Docs cascade (doc-cascade skill)
docs/reference/cli.md:744-787"Version reporting" (rewrite:--versionself-report +felis versionverb + JSON shape),docs/reference/ipc.md:516-522(Welcome),:1738-1752(ledger row or 2.0 baseline),docs/reference/workspace.md:157-182"Versioning" (add the build-identity axis: semver + revision + dirty; how each build path stamps it),docs/reference/testing.md:867(bench meta wording).docs/explanation/architecture/ipc.md:908-912(Welcome.build_id→ identity; still no gate on it),docs/explanation/terminal-identity.md:171-189(why--versioncarries the hash andTERM_PROGRAM_VERSIONdoes not — unchanged argument, new wording),docs/explanation/architecture/control-surfaces.md:21.CHANGELOG.mdUnreleased: removed aggregate--version, addedfelis version, changed Welcome/status shapes.skills/felis/SKILL.md: addfelis version --format jsonunder the health section (:213-261), since it is the verb an agent should use to detect a stale daemon.Tests that prove it
identity.crates/felis-cli/tests/):--versionoffline on a cold--socketpath exits 0 within the process (no socket created, nofelis-clienton PATH needed — run with an emptyPATH), exclusivity → exit 2,felis version --format jsonagainst the fake-daemon harness (clean and dirty identities,not running, major-refusal).FELIS_GIT_HASH=abc…-dirtywhen building a fixture? Build-script envs are not settable per-test; instead testBuildIdentity::from_env_consts("0.1.0", "abc…", "-dirty" form)with both shapes.Dependencies
build_id/versionstrings are dropped or kept.felis versionoutput.scripts/bench) must be updated in the same change orjust bench*breaks.Risk/effort
M. Touches four crates, the proto schema, build scripts, three Nix entry points, and the bench harness. Main risks: (1) clap's
--versionexclusivity semantics (covered by the test); (2) stale-stamp on unstaged edits under cargo (documented, not solved — the release path is Nix); (3) forgetting one of the(unknown)Nix paths, which would make a Home Manager install report an unknown identity right after the release that introduces the concept.Labels
Keep
priority/P0,release/v0.1.0. The exclusivity acceptance from #55's comment should be added to the issue body's checklist. Add a note that "reject dirty release builds" is delivered by #18's workflow, with #17 only makingdirtyobservable, so the two issues do not double-implement it.Review amendments (round 1)
<binary> <semver> (<rev40>[-dirty])with the full 40-hex revision (orunknown).FromStraccepts only that canonical form.felis-client --versionprints the canonical line;felis version --format jsoncarries the full revision; only human renderings (felis --version,felis versionhuman table) abbreviate to 12 hex. #31's comparison againstgit rev-parse HEADreads the JSON (full) form. There is no claim that the abbreviated rendering round-trips.const fnproducing owned strings.BuildIdentityholdsCow<'static, str>fields (orStringwith a plain runtime constructorBuildIdentity::from_build_env(version: &'static str, revision: &'static str, dirty: bool)); each binary builds it once at startup from theenv!constants.