feat(protocol): give every build one typed identity #83

Merged
natsukium merged 2 commits from feat/build-identity-17 into main 2026-09-04 20:30:28 +09:00
Owner

Fixes #17.

Gives every felis build one typed identity and splits the self-report
from the cross-process comparison.

  • BuildIdentity { version, revision, dirty } lands in felis-protocol
    (it crosses the wire, and its canonical <version> (<revision>[-dirty])
    rendering is the one form every process writes and reads), riding
    Welcome.identity on wire minor 9 beside the build_id string it
    supersedes, so a daemon below that minor still answers something.
  • Build stamping moves to git rev-parse HEAD plus a porcelain check
    (crates/build-common.rs), re-stamped when .git/index moves; the
    revision is now full 40 hex with a fixed 12-char abbreviation, so two
    machines no longer render one build two ways. The flake passes
    self.rev / self.dirtyRev in full.
  • felis --version is now this binary's own build alone, rendered from a
    const — no subprocess, no tokio runtime, no dial, so it answers offline;
    a verb or a post--- argv beside it is a usage error (exit 2).
  • The comparison keeps its audience as felis version, which gains
    --format json and reads the client's identity by parsing its canonical
    --version line through BuildIdentity's FromStr.

Doc cascade: docs/reference/ipc.md (minor ledger row 9, ConnWelcome),
docs/explanation/architecture/ipc.md, docs/reference/cli.md,
docs/reference/spec.md (REQ-104), docs/reference/workspace.md,
docs/reference/testing.md, docs/explanation/terminal-identity.md,
CHANGELOG.md, crates/felis-protocol/proto/felis.proto and its
generated mirror, skills/felis/SKILL.md, flake.nix, nix/package.nix,
scripts/bench/envinfo.py.

Rebased onto the current main, which had already taken minor 8 for the
atomic-create / operation-id additions, so build identity is minor 9.

Verified: just check green (fmt, clippy, nextest, deny); reviewed by
plan/correctness/docs lenses and pi sol.

Deferred

  • BuildIdentity identity on OpsStatusReply, and typed rendering in
    cli_daemon.rs (felis daemon status human + --format json) — no
    acceptance criterion needs it; criterion 3 is satisfied by
    Welcome.identity, which both felis version and doctor read. The
    extra proto field, convert arm, fixtures and CLI rendering did not fit
    the size budget. daemon status still reports its untyped
    <semver> (<revision>[-dirty]) string, which is now the canonical form
    anyway.
  • Fixing the two (unknown) Nix stamping paths — the overlay
    (flake.nix overlays.default) and nix/hm-module.nix's default
    package, which call nix/package.nix with no gitHash. Not required by
    any acceptance criterion, and verifying it needs a nix eval /
    nix build round the size budget did not leave room for. It is the
    plan's own named risk (3), so it should become a follow-up before the
    release that introduces the concept.
  • Rejecting dirty release builds in CI — the plan assigns this to #18's
    tag workflow; #17 only has to make dirty observable, which it now
    does. Recorded in docs/reference/workspace.md.
  • scripts/bench/crossterm_test.py fixture update to the new --version
    line shape — its mocked cli (b27da75) output still parses through
    the widened paren_hash regex, so the test passes unchanged; rewriting
    the fixture is cosmetic.
  • docs/explanation/architecture/control-surfaces.md wording touch named
    in the plan's cascade — its line about version surfaces is still
    accurate after the split (--version remains a local, non-dialing
    surface); the grep sweep found nothing that contradicts the change.
  • Two Nix install paths still stamp no revision, so they report
    unknown — the same overlay + hm-module gitHash threading as above;
    no acceptance criterion requires the Nix install paths specifically.
  • Daemon status was not migrated to the typed identity, and the bench
    harness still scrapes text — the OpsStatusReply half is the first
    deferred item; the crossterm_test.py half is cosmetic. AC3 is
    satisfied for the required surfaces via Welcome.identity, and the
    envinfo.py revision_drift wrinkle is bench-harness behavior no
    acceptance criterion covers.
  • Build-script rerun paths are wrong in a linked worktree, forcing a
    rebuild every invocation — a build-performance annoyance in worktree
    builds, not a correctness bug or a failure of any numbered criterion,
    and it does not fail CI.
  • FromStr accepts noncanonical lines it documents as refused —
    non-blocking; the only current producer is felis's own clap line, so no
    real consumer is affected. Tightening validation is hardening.
  • A client binary that runs and fails is reported as unavailable, not
    unrecognized — a diagnostic-granularity edge case (spawn failure vs.
    nonzero exit); no criterion requires distinguishing the two.
  • The minor-8 old-peer fallback (daemon_status "untyped") is never
    exercised — AC4's deterministic-tests requirement is about clean/dirty
    build identities, not this compatibility path, so it is extra hardening.
  • Bench provenance drops the binary's dirty flag, so a dirty binary
    measured against a clean checkout reports no drift — bench-harness
    behavior outside #17's criteria; worth a follow-up to correct the
    testing.md wording and the drift derivation.
  • Stale total_bytes reference in the MAX_IMAGE_BYTES doc comment —
    belongs to the unrelated image-transfer rework this branch was stacked
    on, not to #17's criteria.
Fixes #17. Gives every felis build one typed identity and splits the self-report from the cross-process comparison. - `BuildIdentity { version, revision, dirty }` lands in `felis-protocol` (it crosses the wire, and its canonical `<version> (<revision>[-dirty])` rendering is the one form every process writes and reads), riding `Welcome.identity` on **wire minor 9** beside the `build_id` string it supersedes, so a daemon below that minor still answers something. - Build stamping moves to `git rev-parse HEAD` plus a porcelain check (`crates/build-common.rs`), re-stamped when `.git/index` moves; the revision is now full 40 hex with a fixed 12-char abbreviation, so two machines no longer render one build two ways. The flake passes `self.rev` / `self.dirtyRev` in full. - `felis --version` is now this binary's own build alone, rendered from a const — no subprocess, no tokio runtime, no dial, so it answers offline; a verb or a post-`--` argv beside it is a usage error (exit 2). - The comparison keeps its audience as `felis version`, which gains `--format json` and reads the client's identity by parsing its canonical `--version` line through `BuildIdentity`'s `FromStr`. Doc cascade: `docs/reference/ipc.md` (minor ledger row 9, `ConnWelcome`), `docs/explanation/architecture/ipc.md`, `docs/reference/cli.md`, `docs/reference/spec.md` (REQ-104), `docs/reference/workspace.md`, `docs/reference/testing.md`, `docs/explanation/terminal-identity.md`, `CHANGELOG.md`, `crates/felis-protocol/proto/felis.proto` and its generated mirror, `skills/felis/SKILL.md`, `flake.nix`, `nix/package.nix`, `scripts/bench/envinfo.py`. Rebased onto the current `main`, which had already taken minor 8 for the atomic-create / operation-id additions, so build identity is minor 9. Verified: just check green (fmt, clippy, nextest, deny); reviewed by plan/correctness/docs lenses and pi sol. ## Deferred - `BuildIdentity identity` on `OpsStatusReply`, and typed rendering in `cli_daemon.rs` (`felis daemon status` human + `--format json`) — no acceptance criterion needs it; criterion 3 is satisfied by `Welcome.identity`, which both `felis version` and `doctor` read. The extra proto field, convert arm, fixtures and CLI rendering did not fit the size budget. `daemon status` still reports its untyped `<semver> (<revision>[-dirty])` string, which is now the canonical form anyway. - Fixing the two `(unknown)` Nix stamping paths — the overlay (`flake.nix` `overlays.default`) and `nix/hm-module.nix`'s default package, which call `nix/package.nix` with no `gitHash`. Not required by any acceptance criterion, and verifying it needs a `nix eval` / `nix build` round the size budget did not leave room for. It is the plan's own named risk (3), so it should become a follow-up before the release that introduces the concept. - Rejecting dirty release builds in CI — the plan assigns this to #18's tag workflow; #17 only has to make `dirty` observable, which it now does. Recorded in `docs/reference/workspace.md`. - `scripts/bench/crossterm_test.py` fixture update to the new `--version` line shape — its mocked `cli (b27da75)` output still parses through the widened `paren_hash` regex, so the test passes unchanged; rewriting the fixture is cosmetic. - `docs/explanation/architecture/control-surfaces.md` wording touch named in the plan's cascade — its line about version surfaces is still accurate after the split (`--version` remains a local, non-dialing surface); the grep sweep found nothing that contradicts the change. - Two Nix install paths still stamp no revision, so they report `unknown` — the same overlay + hm-module `gitHash` threading as above; no acceptance criterion requires the Nix install paths specifically. - Daemon status was not migrated to the typed identity, and the bench harness still scrapes text — the `OpsStatusReply` half is the first deferred item; the `crossterm_test.py` half is cosmetic. AC3 is satisfied for the required surfaces via `Welcome.identity`, and the `envinfo.py` `revision_drift` wrinkle is bench-harness behavior no acceptance criterion covers. - Build-script rerun paths are wrong in a linked worktree, forcing a rebuild every invocation — a build-performance annoyance in worktree builds, not a correctness bug or a failure of any numbered criterion, and it does not fail CI. - `FromStr` accepts noncanonical lines it documents as refused — non-blocking; the only current producer is felis's own clap line, so no real consumer is affected. Tightening validation is hardening. - A client binary that runs and fails is reported as `unavailable`, not `unrecognized` — a diagnostic-granularity edge case (spawn failure vs. nonzero exit); no criterion requires distinguishing the two. - The minor-8 old-peer fallback (`daemon_status` "untyped") is never exercised — AC4's deterministic-tests requirement is about clean/dirty build identities, not this compatibility path, so it is extra hardening. - Bench provenance drops the binary's dirty flag, so a dirty binary measured against a clean checkout reports no drift — bench-harness behavior outside #17's criteria; worth a follow-up to correct the `testing.md` wording and the drift derivation. - Stale `total_bytes` reference in the `MAX_IMAGE_BYTES` doc comment — belongs to the unrelated image-transfer rework this branch was stacked on, not to #17's criteria.
Three processes compared builds through two untyped strings: the short
hash in `Welcome.build_id` and the daemon's `<semver> (<hash>)`, each
reader scraping its own shape out of the other's prose. The hashes were
not even comparable, because `git rev-parse --short` picks its width
from the local object store, so two machines rendered one build two
ways; and the cargo path had no dirty detection at all while the Nix
path already stamped `-dirty`.

`BuildIdentity` (semver, full revision, dirty) is that fact typed, in
felis-protocol because it crosses the wire and because the canonical
rendering `<version> (<revision>[-dirty])` is the one form every
process must both write and read. It rides `Welcome.identity` on wire
minor 9, beside the `build_id` string it supersedes so a daemon below
that minor still answers something.

Build stamping moves with it: `git rev-parse HEAD` plus a porcelain
check, re-run when `.git/index` moves so a staged change re-stamps. An
unstaged edit still does not, which is why the flake now passes
`self.rev` / `self.dirtyRev` in full and why a release is cut from the
Nix path rather than from cargo.

Refs #17
feat(cli): split the self-report from the build comparison
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 48s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m24s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m20s
windows / cargo nextest (Windows) (pull_request) Failing after 2m2s
windows / frontend smoke (Windows) (pull_request) Has been skipped
pr / nix flake check (pull_request) Failing after 20s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m55s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 48s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 12s
9f6f7473d4
`felis --version` aggregated three builds: it ran `felis-client
--version` as a subprocess and built a tokio runtime to dial the
daemon. That is the wrong contract for the flag every tool expects to
answer instantly and in isolation; with `--host` it even opened an SSH
connection, so the one command a user runs to ask "which build is
this?" could hang on a network. It was not exclusive either: `felis
--version sessions lst` printed the report and exited 0, hiding the
typo.

The flag is now this binary's own build alone, rendered from a const so
no runtime, subprocess, or dial can creep back in, and a verb or a
post-`--` argv beside it is a usage error. The comparison keeps its
audience as `felis version`, which gains `--format json`, the shape an
agent needs to notice it is driving a stale daemon, and reads the
client's identity by parsing its canonical `--version` line through
`BuildIdentity`'s `FromStr` instead of lifting whatever sat between the
last parentheses.

Refs #17
natsukium force-pushed feat/build-identity-17 from 9f6f7473d4
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 48s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m24s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m20s
windows / cargo nextest (Windows) (pull_request) Failing after 2m2s
windows / frontend smoke (Windows) (pull_request) Has been skipped
pr / nix flake check (pull_request) Failing after 20s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m55s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 48s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 12s
to 8ee45f7605
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 47s
windows / cargo nextest (Windows) (pull_request) Failing after 1m51s
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m6s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m13s
pr / nix flake check (pull_request) Successful in 26s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m30s
pr / wire schema is compatible with the base (pull_request) Successful in 8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m1s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 28s
fuzz / cargo fuzz nightly long-run (push) Has been skipped
windows / frontend smoke (Windows) (push) Has been skipped
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m0s
pr / nix flake check (push) Successful in 5s
darwin / build felis (aarch64-darwin) (push) Successful in 13s
windows / cargo nextest (Windows) (push) Failing after 1m43s
windows / package felis (x86_64-pc-windows-msvc) (push) Has been skipped
pr / cargo build / clippy / test / deny (push) Successful in 1m37s
pr / wire schema is compatible with the base (push) Successful in 9s
pr / frontend smoke (x86_64-linux) (push) Successful in 8s
windows / cargo clippy (Windows cross) (push) Failing after 13s
pr / publish felis (x86_64-linux) (push) Successful in 10s
2026-09-04 20:21:39 +09:00
Compare
natsukium deleted branch feat/build-identity-17 2026-09-04 20:30:28 +09:00
Sign in to join this conversation.
No description provided.