Skip to content

fleet: self-reported device identity (friendly name + device type) in the roster #1368

Closed
2 of 2 issues completed

Description

@jeonghun-jj-lee

Important

Problem — The fleet roster carries a machine's name and optional device_type, and the sidebar renders them, but the enroll verb never produces meaningful values: it writes name = hostname() (the raw FQDN) and no device_type. So every machine labels itself nicely (the extension's local self-row reads the OS's friendly name + form factor) while its peers see raw hostnames and no type. The disagreement is symmetric: the server sees the laptop as JVs-MacBook-Pro.local / client, and the laptop sees the server as Mac.mynetworksettings.com / server.
Approach — Make a machine's friendly name + device type part of its self-report, derived by logic shared between the enroll producer (amico-run) and the extension self-row so a machine and its peers cannot disagree by construction. Cross-platform (macOS + Linux; WSL abstains on type), with an explicit settings override on one namespace read by both producers. Land it in two vertical slices: the client-row producer first, then the server's own self-registration.
Approaches considered — (1) Duplicate the classifier in amico-run — rejected, the two producers drift (the bug itself). (2) Put pure + impure detection in @amicode/schema — rejected, schema stays side-effect/child_process-free for hygiene (it already imports node:os/node:path, so it is not browser-safe regardless; the browser sidebar module re-declares the vocabulary rather than importing schema). (3) Enrich the join token with the server's name/type instead of a server row — rejected, stretches the secret token contract and leaves the server a roster non-citizen (against ADR 0026). (4) De-dupe the synthesized server node by role instead of id-alignment — rejected, adds client-side edge cases to avoid a one-line producer rule.
Scope — in: the shared pure derivation in @amicode/schema; a thin per-package impure detector behind a single injectable command-runner seam (macOS + Linux + WSL); the client enroll row carrying friendly name + device type; the server self-registering its own row keyed by its canonical host; the server's own self-row identity reconciled to the same key; the amicode.device.name / amicode.device.type overrides read by BOTH producers; rehoming the extension's existing classifier to schema. · out: the roster JSON schema (the name/device_type fields already exist); Server mode, fleet.json canonical immutability changes beyond a documented invariant; amicissimo; a periodic self-report heartbeat; a Linux chassis auto-guess under WSL; a stale-row prune path.
Assumptions — A client references the server by its fleet.json canonical.host, so a server row keyed by that same string collapses against the client's synthesized node. Detection is best-effort; an undefined type honestly falls back to server_mode. The hub's loopback reachability at server self-register time is verified, not assumed (see AC).

Acceptance Criteria

Shared derivation (@amicode/schema, lands with Slice 1)

  • classifyMacModel(modelName) maps a system_profiler "Model Name" string to laptop/desktop/undefined (rehomed from the extension's classifyDeviceType; existing behavior preserved).
  • classifyLinuxChassis(chassis) maps laptop|notebook|portable → laptop, desktop|tower → desktop, server|rack → server, else undefined.
  • normalizeDeviceName(raw) is defined by an explicit rule and proven by a table: Mac.mynetworksettings.com → Mac, host.local → host, JJ's Mac Studio → JJ's Mac Studio (unchanged), "" → "". Rule: strip from the first . onward only when the remainder is a DNS-suffix-shaped label sequence; leave dot-free and space-bearing names intact.
  • isWslKernel(procVersion) returns true for a /proc/version containing a Microsoft/WSL marker, false otherwise — extracted as a pure function from the currently-inline regex in the extension's detectWSLVersion (rebuild/host_matrix.ts).
  • classifyDeviceType is exported from @amicode/schema and sidebar_view.ts imports it (behavioral assertion); a lint/grep gate confirms no private classifier body remains in the extension (structural assertion). (Replaces the untestable "no duplicate logic remains".)

Impure detection (per node package, testable)

  • Each node package (extension host + amico-run) exposes a single injectable command-runner seam; a test per package feeds canned command output through the real caller into the shared pure classifier — so "on macOS/Linux/WSL the row reflects X" is a failing-test-able assertion, not a claim that requires shelling out.
  • Linux type source order is specified and tested: hostnamectl chassis first, /sys/class/dmi/id/chassis_type second, then abstain. hostnamectl absent (minimal container / no systemd) ⇒ prettified hostname + device_type omitted.

Slice 1 — client row producer (amico-run enroll)

  • A client enroll POSTs a roster row whose name is the resolved friendly name and whose device_type is the resolved type.
  • Resolution precedence, per field, is exercised by injected seams (detectDeviceType?, a setting reader): explicit setting (amicode.device.name / amicode.device.type) → OS detection → prettified hostname (name) / undefined (type). Tests cover override-wins and honest-fallback independently — not just an opaque machineName stub.
  • The friendly-name seam is decoupled from the canonical-host derivation: wiring a friendly name MUST NOT change canonical.host. (Today enrollAsServer derives host from machineName; a friendly name must not leak into canonical/the join token.)
  • On macOS the row reflects ComputerName + model type; on Linux PRETTY_HOSTNAME + chassis type; on WSL a name with device_type omitted.

Slice 2 — server self-registration + both views single

  • enroll --as-server POSTs its own roster row to the loopback /amicode/roster only after verifying the hub is reachable (retry-or-honest-fail like the client verify-attach path); it does not treat the dev-fallback pin probe as proof of reachability.
  • The server row is keyed by machine_id = canonical.host, carries the resolved friendly name, and device_type = server (or the detected/overridden type). It uses the validated parse + single-writer upsert path (no new writer).
  • A client attached to that server renders exactly one server row (synthesized node collapses against the real row via the existing de-dupe — zero client-side change).
  • The server, viewing its own sidebar, renders exactly one row for itself — including when enrolled with a non-default --host (so canonical.host ≠ os.hostname()). Satisfied by resolving the server's self-row identity (readLocalDevice().machineId) to canonical.host when this machine is the server, OR de-duping the self-row against the roster on canonical.host. (Guards the live-fleet case canonical.host = Mac.mynetworksettings.com.)

Testing Decisions

  • Extend packages/schema's roster tests with a table-driven suite for the pure functions (Mac model strings, Linux chassis values, /proc/version WSL strings, the normalizeDeviceName table).
  • Extend packages/amico-run's enroll tests (injected seams): the POSTed client row's name/device_type, override-wins, honest-fallback, the friendly-name-does-not-touch-canonical case, and the server self-registration (machine_id === canonical.host, hub-reachability-verified-before-POST).
  • Add a per-package impure-detector test feeding canned command output through the real command-runner seam.
  • Extend the extension's fleet sidebar/provider tests with two cases: a client renders exactly one server row; a server renders exactly one self-row when canonical.host ≠ hostname().
  • Reuse-first: extend the existing suite covering readLocalDevice/classifyDeviceType; no new file for the self-row change.

Key Decisions

  • One derivation, two node consumers. Pure classification lives in @amicode/schema beside KNOWN_DEVICE_TYPES; the producer (amico-run) and the extension host self-row both call it. (The browser sidebar module is a third, re-declaring site and cannot import schema — schema pulls node builtins.)
  • Impure shell behind one command-runner seam per package. The execSync/file-read platform branch is duplicated per package by necessity, but each hides behind a single injectable runner so both copies are test-covered against canned output — closing the drift hole below the pure seam.
  • Server keyed by canonical host, and its own self-row too. machine_id = canonical.host for the server's row collapses the client's synthesized node; the server's self-row identity is reconciled to the same key so the server does not double-render itself under --host / FQDN drift.
  • One override namespace, read by both. Canonical keys are amicode.device.name (already read by the self-row at sidebar_view.ts) + a new amicode.device.type. The amicode.device.name setting description is updated to reflect it also names the fleet device (today it documents only the dormant Device Inspector). An AC asserts the self-row and the POSTed row resolve the same override string.
  • WSL abstains on type. Honest undefined rather than an unreliable in-VM chassis guess; the override covers intentional labelling.
  • canonical.host is invariant after first server enroll. A re-enroll that changes --host would orphan clients' synthesized node (stale pointer, ghost row); we document canonical as immutable post-enroll and note that changing it requires re-enrolling clients, not just the server.
  • Re-enroll is the refresh. Existing machines re-enroll once (idempotent upsert) to pick up identity; a periodic heartbeat is a deferred follow-up.

Data Contracts

  • No change to the roster JSON schema: RosterRow.name and RosterRow.device_type? already exist and validate. This work only changes which values the producer writes.
  • Settings: amicode.device.name (existing; description updated to cover the fleet device name) and a new amicode.device.type (string; suggested server|desktop|laptop, open). Both read by the extension self-row AND the enroll verb (which already reads settings.json for amicode.fleetTransport).
  • New @amicode/schema exports: classifyMacModel, classifyLinuxChassis, normalizeDeviceName, isWslKernel — all pure.
  • New amico-run/extension seams: a deviceType detector seam and a single command-runner seam (injectable); the friendly-name seam kept separate from the canonical.host derivation.

Constraints & Invariants

  • fleet.json untouched by the roster work (one-parser invariant, ADR 0023); canonical treated as immutable post-enroll.
  • Single-writer self-report (ADR 0026): a machine writes only its own row; the server's self-registration is its own row keyed by its canonical host.
  • @amicode/schema stays side-effect-free; only pure functions are added.
  • No silent fallback (ADR 0024/0025): undetected type → server_mode fallback; no friendly-name source → prettified hostname; hub-not-reachable at server self-register → honest fail, never a false success. Nothing fabricated.
  • Never-fork (ADR 0005): no engine-spawn path is added.
  • The friendly name never feeds canonical.host or the minted join token.

Prior Art

  • The roster contract + vocabulary (RosterRow, KNOWN_DEVICE_TYPES, parseRosterRow, upsertRosterRow) in the schema package.
  • The enroll producer (client + server paths, the injectable seams, the loopback pin probe and its dev fallback) in amico-run.
  • The existing name/type derivation (scutil ComputerName, system_profiler classifier, readLocalDevice reading amicode.device.name) and the roster consumer (typeLabel = device_type ?? server_mode, the canonical-server synthesis + de-dupe, the self-row synthesis guard) in the extension.
  • The proven WSL detector (/proc/version regex) in the extension's rebuild host-matrix code.

Source

Notes

  • One-time operator action after merge: re-enroll the existing fleet machines once so their rows pick up the friendly name + device type.
  • --host <ip> makes machine_id an IP; DHCP churn would mint a new row and orphan the old one. Prefer a stable --host string; stale-row pruning is a deferred follow-up.

Activity

  1. jeonghun-jj-lee commented on Sep 21, 2026

    @jeonghun-jj-lee
    ContributorAuthor

    Reviewed by two independent /deliberate critics (code-grounding + architecture/testability), fresh context, artifact + code only. Two ship-blocking defects were caught and folded into the ACs before any implementation:

    1. Server self-double-render. machine_id = canonical.host fixes the client view, but the server's own self-row is keyed by os.hostname() (sidebar_view.ts:559) — which differs from canonical.host under a non-default --host or macOS FQDN drift (the live fleet's Mac.mynetworksettings.com). The server would render itself twice. Slice 2 now reconciles the self-row identity too, with an explicit AC.
    2. Override-key split. The draft named amicode.deviceName, but the extension self-row already reads amicode.device.name (sidebar_view.ts:556). Two different keys = split identity on the override path — the exact bug this closes. Unified on amicode.device.name + new amicode.device.type, read by both producers, with an AC.

    Plus sharpening: single injectable command-runner seam per package so the duplicated impure shell is test-covered (not left to drift); a precise normalizeDeviceName rule + table (Mac.mynetworksettings.com → Mac); Linux type-source order + hostnamectl-absent AC; honest hub-reachability check before the server self-registers (the dev-fallback pin probe is not proof); friendly-name decoupled from canonical.host; canonical immutable post-enroll. ADR 0028's browser-bundle rationale was also corrected (schema already imports node builtins; the browser module re-declares rather than imports).

  2. jeonghun-jj-lee commented on Sep 21, 2026

    @jeonghun-jj-lee
    ContributorAuthor

    All sub-issues (#1371, #1372) implemented and merged into feature/free-tier-fleet via #1374 (merge commit 1b2a3df). Closing the parent as merge bookkeeping (feature-branch base skips keyword auto-close).

  3. added 5 commits that reference this issue on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    hitlNeeds human review before merge

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions