Repository navigation
fleet: self-reported device identity (friendly name + device type) in the roster #1368
Description
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 asystem_profiler"Model Name" string tolaptop/desktop/undefined(rehomed from the extension'sclassifyDeviceType; existing behavior preserved). -
classifyLinuxChassis(chassis)mapslaptop|notebook|portable → laptop,desktop|tower → desktop,server|rack → server, elseundefined. -
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/versioncontaining a Microsoft/WSL marker, false otherwise — extracted as a pure function from the currently-inline regex in the extension'sdetectWSLVersion(rebuild/host_matrix.ts). -
classifyDeviceTypeis exported from@amicode/schemaandsidebar_view.tsimports 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:
hostnamectlchassis first,/sys/class/dmi/id/chassis_typesecond, then abstain.hostnamectlabsent (minimal container / no systemd) ⇒ prettified hostname +device_typeomitted.
Slice 1 — client row producer (amico-run enroll)
- A client enroll POSTs a roster row whose
nameis the resolved friendly name and whosedevice_typeis 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 opaquemachineNamestub. - The friendly-name seam is decoupled from the canonical-host derivation: wiring a friendly
nameMUST NOT changecanonical.host. (TodayenrollAsServerderiveshostfrommachineName; a friendly name must not leak intocanonical/the join token.) - On macOS the row reflects ComputerName + model type; on Linux
PRETTY_HOSTNAME+ chassis type; on WSL a name withdevice_typeomitted.
Slice 2 — server self-registration + both views single
-
enroll --as-serverPOSTs its own roster row to the loopback/amicode/rosteronly after verifying the hub is reachable (retry-or-honest-fail like the client verify-attach path); it does not treat thedev-fallback pin probe as proof of reachability. - The server row is keyed by
machine_id = canonical.host, carries the resolved friendly name, anddevice_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(socanonical.host ≠ os.hostname()). Satisfied by resolving the server's self-row identity (readLocalDevice().machineId) tocanonical.hostwhen this machine is the server, OR de-duping the self-row against the roster oncanonical.host. (Guards the live-fleet casecanonical.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/versionWSL strings, thenormalizeDeviceNametable). - Extend
packages/amico-run's enroll tests (injected seams): the POSTed client row'sname/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/schemabesideKNOWN_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.hostfor 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 atsidebar_view.ts) + a newamicode.device.type. Theamicode.device.namesetting 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
undefinedrather than an unreliable in-VM chassis guess; the override covers intentional labelling. canonical.hostis invariant after first server enroll. A re-enroll that changes--hostwould 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.nameandRosterRow.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 newamicode.device.type(string; suggestedserver|desktop|laptop, open). Both read by the extension self-row AND the enroll verb (which already readssettings.jsonforamicode.fleetTransport). - New
@amicode/schemaexports:classifyMacModel,classifyLinuxChassis,normalizeDeviceName,isWslKernel— all pure. - New
amico-run/extension seams: adeviceTypedetector seam and a single command-runner seam (injectable); the friendly-name seam kept separate from thecanonical.hostderivation.
Constraints & Invariants
fleet.jsonuntouched by the roster work (one-parser invariant, ADR 0023);canonicaltreated 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/schemastays side-effect-free; only pure functions are added.- No silent fallback (ADR 0024/0025): undetected type →
server_modefallback; 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
namenever feedscanonical.hostor 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
devfallback) inamico-run. - The existing name/type derivation (
scutilComputerName,system_profilerclassifier,readLocalDevicereadingamicode.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/versionregex) in the extension's rebuild host-matrix code.
Source
- Design of record; paired with ADR 0028 (self-reported device identity in the roster) and a
CONTEXT.mdglossary entry ("Device identity"). - Follows fleet: sidebar shows only self on a client — client-side roster read + server-node synthesis (#1321 follow-up) #1363 (client peer visibility) and fleet: orphaned opencode engines survive VS Code reload and squat on the configured port #1359 (
device_typefield + type pill). - To be decomposed into two vertical slices (client-row producer; server self-registration + both-views-single).
- Deferred follow-ups: a periodic self-report heartbeat (auto-refresh without re-enroll); a stale-row prune path.
- Reviewed by two independent
/deliberatecritics (code-grounding + architecture/testability); their blocking findings (server self-double-render; override-key split) and advisories are folded into the ACs above.
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>makesmachine_idan IP; DHCP churn would mint a new row and orphan the old one. Prefer a stable--hoststring; stale-row pruning is a deferred follow-up.
Activity
jeonghun-jj-lee commentedon Sep 21, 2026
Reviewed by two independent
/deliberatecritics (code-grounding + architecture/testability), fresh context, artifact + code only. Two ship-blocking defects were caught and folded into the ACs before any implementation:machine_id = canonical.hostfixes the client view, but the server's own self-row is keyed byos.hostname()(sidebar_view.ts:559) — which differs fromcanonical.hostunder a non-default--hostor macOS FQDN drift (the live fleet'sMac.mynetworksettings.com). The server would render itself twice. Slice 2 now reconciles the self-row identity too, with an explicit AC.amicode.deviceName, but the extension self-row already readsamicode.device.name(sidebar_view.ts:556). Two different keys = split identity on the override path — the exact bug this closes. Unified onamicode.device.name+ newamicode.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
normalizeDeviceNamerule + table (Mac.mynetworksettings.com → Mac); Linux type-source order + hostnamectl-absent AC; honest hub-reachability check before the server self-registers (thedev-fallback pin probe is not proof); friendly-name decoupled fromcanonical.host;canonicalimmutable post-enroll. ADR 0028's browser-bundle rationale was also corrected (schema already imports node builtins; the browser module re-declares rather than imports).docs(fleet): ADR 0028 (device identity) + ADR 0029 (unified fleet) + …
feat(fleet): server self-registers its roster row + single-row reconc…
fleet: self-reported device identity in the roster (#1368) (#1374)
jeonghun-jj-lee commentedon Sep 21, 2026
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).
fleet(#1379): buildBootSelfReportRow — serving advertisement + device…
fleet(#1379): serving advertisement — roster serving tag + device_typ…
docs(fleet): ADR 0028 (device identity) + ADR 0029 (unified fleet) + …
fleet: self-reported device identity in the roster (#1368) (#1374)
fleet(#1379): serving advertisement — roster serving tag + device_typ…