diff --git a/toolchain/fleet/DASHBOARD.md b/toolchain/fleet/DASHBOARD.md new file mode 100644 index 000000000..57a793dc1 --- /dev/null +++ b/toolchain/fleet/DASHBOARD.md @@ -0,0 +1,73 @@ +# Fleet dashboard — design note + +A read-mostly web view of the fleet, sibling to the auth/status dashboards on +`lith.aesthetic.computer`. It answers, at a glance and for humans, the same +question `fleet-mcp` answers for agents: *what machines exist, what can they do, +which are alive right now.* + +## What it shows + +- **Machine tiles**, grouped by `designation` (agent-endpoint · compute-node · + control · build · service · display · legacy). Each tile: + - emoji + label + canonical name + - live status dot: 🟢 online · ⚪ offline · ❔ unknown, with **last-seen** + relative time for offline nodes (from `tailscale status`) + - `designation` badge + capability chips (`gpu`, `mlx`, `chromium-pool`, …) + - hardware line (chip / cores / memory) and OS + - a `⚠ review` flag for entries `normalize-machines.mjs` hasn't confirmed +- **Filter bar**: by designation, by capability chip, online-only toggle. Same + vocabulary as `fleet_find`. +- **Fleet header counts**: N online / M total, and per-designation tallies. +- Optional **agent/identity column** once hermes lands: which agent-endpoint + owns which identity, last heartbeat. + +Deliberately **not** shown in the public/tailnet page: raw IPs, ssh key paths, +passwords, droplet IDs. Those stay server-side (vault) — the page renders +labels, designations, capabilities, and liveness only. (PII-out rule.) + +## Where it lives — recommendation + +**Recommended: a tailnet-gated page, not a public lith route.** + +Two viable homes: + +1. **lith Express route** — add `system/netlify/functions/fleet.mjs` (lith + adapts function files as routes; the path is historical). It would read the + vault registry server-side + shell `tailscale status --json` on whatever host + serves it. *Problem:* lith is public-internet facing and does not run on the + tailnet, so (a) it can't see `tailscale status` for @jeffrey's tailnet, and + (b) a fleet map is exactly the private inventory the "slab is public / keep + PII out" rule wants off the public web. Workable only as a JSON API behind + real auth, fed by a pushed status blob — more plumbing than it's worth for v1. + +2. **A small page served on the tailnet from `jasellite`** (the always-on + services appliance already exposing authed tailnet APIs on ports like + `:7765`). It has first-hand `tailscale status`, already holds the vault-style + private config, and is only reachable by devices on the tailnet — so tailnet + membership *is* the auth boundary. **This is the recommendation for v1.** + + Shape: a `/api/fleet` JSON endpoint that runs the exact `fleet-mcp.mjs` merge + logic (factor the registry+tailscale merge into a shared `fleet.mjs` module + that both the MCP and the endpoint import), plus a static single-file HTML + dashboard that polls it every ~10s. Zero new datastore — the vault file + + live `tailscale status` are the whole backend. + +## Auth considerations + +- **v1:** tailnet membership is the gate (page bound to the tailnet interface on + jasellite; not exposed publicly). No extra login needed — if you can reach it, + you're on the tailnet. +- **If it must go on lith/public** later: put it behind the same session/handle + auth the other lith dashboards use, and serve a **redacted** projection only + (labels + designation + capabilities + online dot — never IPs/keys). Feed it a + status blob that jasellite pushes up on a timer, rather than giving the public + box tailnet visibility. +- Ties into `AC shared session` (`~/.ac-token`) if per-viewer identity is ever + wanted, but that's overkill for a personal tailnet inventory. + +## Build order (when picked up) + +1. Extract the merge (`loadRegistry` + `tailscaleStatus` + `liveFor`) from + `fleet-mcp.mjs` into a shared `fleet.mjs`; have the MCP import it. +2. Add `GET /api/fleet` on jasellite returning the redacted JSON projection. +3. Static HTML dashboard (tiles + filter + poll). No framework needed. diff --git a/toolchain/fleet/README.md b/toolchain/fleet/README.md new file mode 100644 index 000000000..69f8aa62b --- /dev/null +++ b/toolchain/fleet/README.md @@ -0,0 +1,86 @@ +# fleet + +A single source of truth for **"what machines do I have access to, their +capabilities, designations, and live status."** Code is public-safe; the machine +DATA (IPs, ssh keys, roles) stays in the private vault. + +Three moving parts: + +1. **`machines.json`** (vault, canonical) → the source of truth @jeffrey edits. +2. **`normalize-machines.mjs`** → enriches it into the fleet schema (adds + `designation`, `capabilities[]`, `status`, tailnet cross-refs) and writes + `machines.normalized.json` to the vault *for review* — it never overwrites + the canonical file. +3. **`fleet-mcp.mjs`** → a stdio MCP server that merges the (normalized) static + registry with live `tailscale status`, so any agent can discover the fleet. + +``` +vault/machines.json ──normalize-machines.mjs──▶ vault/machines.normalized.json + │ + tailscale status --json ──────▶ fleet-mcp.mjs ──▶ agents +``` + +## Schema (proposed) + +Each machine gains, on top of its existing fields: + +| field | meaning | +|-------|---------| +| `name` | canonical registry key (often == tailnet short name) | +| `designation` | ONE primary fleet role (see below) | +| `capabilities[]` | composable tags: `gpu`, `cuda`, `mlx`, `unreal`, `docker`, `macos-automation`, `screen-capture`, `chromium-pool`, `ffmpeg-render`, `always-on`, `git-remote`, `mongodb`, `redis`, `mail`, `web-host`, `build-macos`, `build-ios`, `tailnet-api`, `art-display` | +| `tailscale` | `{ name, ip }` — magic-DNS short name for live-status matching | +| `status` | `{ source, key }` — how liveness resolves (`tailscale` / `http` / `lan` / `none`) | +| `hardware` | `{ model, chip, cores, memory, gpu }` | +| `fleetRole` | one-line human summary | + +**Designations:** `agent-endpoint` (hosts a hermes gateway + identity) · +`compute-node` (headless capability provider over HTTP MCP on the tailnet) · +`control` (human/agent-driven author box, holds git auth) · `build` (build/CI) · +`service` (db/session/mail/web) · `display` · `legacy`. + +The full vocabulary with descriptions lives in the `_schema` block of the +normalized file and is queryable via the `fleet_designations` MCP tool. + +## Usage + +```bash +# regenerate the review file after editing the vault registry +node toolchain/fleet/normalize-machines.mjs + +# CLI smoke test (same code the MCP runs) +node toolchain/fleet/fleet-mcp.mjs list +node toolchain/fleet/fleet-mcp.mjs find gpu +node toolchain/fleet/fleet-mcp.mjs machine macbook-pro-clam +``` + +Data path is resolved in order: `$FLEET_MACHINES` → vault +`machines.normalized.json` → vault `machines.json`. Tailscale binary: +`$TAILSCALE_BIN` → `/Applications/Tailscale.app/...` → `tailscale` on PATH. + +## Register as an MCP + +**Claude Code** (`~/aesthetic-computer/.mcp.json`, alongside frame/puppet): + +```json +"fleet": { + "type": "stdio", + "command": "node", + "args": ["toolchain/fleet/fleet-mcp.mjs"] +} +``` + +**hermes** (`config.yaml`), same stdio contract: + +```yaml +mcpServers: + fleet: + command: node + args: ["toolchain/fleet/fleet-mcp.mjs"] + # env: + # FLEET_MACHINES: /path/to/machines.normalized.json +``` + +## Dashboard design note + +See `DASHBOARD.md` for the fleet/auth dashboard design + recommendation. diff --git a/toolchain/fleet/fleet-mcp.mjs b/toolchain/fleet/fleet-mcp.mjs new file mode 100755 index 000000000..76f28ac1c --- /dev/null +++ b/toolchain/fleet/fleet-mcp.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +// fleet-mcp.mjs — a single source of truth for "what machines do I have access +// to, their capabilities, designations, and live status." Any agent (Claude +// Code, or a hermes-agent instance) can discover the fleet by name over MCP. +// +// It merges STATIC registry data (from the private vault) with LIVE liveness +// (from `tailscale status --json`, matched by magic-DNS short name). The code is +// public-safe (slab/toolchain style); the machine DATA — IPs, ssh keys, roles — +// stays in the vault. Point $FLEET_MACHINES at the file; nothing sensitive is +// baked into this source. +// +// Tools: +// fleet_list — every machine: name, designation, online?, one-line caps +// fleet_machine(name) — full detail for one machine + live status +// fleet_find(capability) — which machines advertise a capability +// fleet_designations — the controlled vocabularies (designations + capabilities) +// +// Hand-rolled JSON-RPC over stdio (newline-delimited), matching the house style +// of slab/bin/frame-mcp.mjs + puppet-mcp.mjs — no SDK, node builtins only. +import { execFile } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as readline from "node:readline"; + +const HOME = homedir(); +// Prefer the normalized/proposal file, fall back to canonical. Override with env. +const CANDIDATES = [ + process.env.FLEET_MACHINES, + join(HOME, "aesthetic-computer-vault", "machines.normalized.json"), + join(HOME, "aesthetic-computer-vault", "machines.json"), +].filter(Boolean); + +const TAILSCALE_BINS = [ + process.env.TAILSCALE_BIN, + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "tailscale", +].filter(Boolean); + +// ── static registry ───────────────────────────────────────────────────────── +function registryPath() { + return CANDIDATES.find((p) => existsSync(p)) || null; +} +function loadRegistry() { + const path = registryPath(); + if (!path) throw new Error(`no machine registry found (looked in: ${CANDIDATES.join(", ")})`); + const data = JSON.parse(readFileSync(path, "utf8")); + return { path, machines: data.machines || {}, schema: data._schema || null }; +} + +// A machine's tailnet short name: explicit tailscale.name, else status.key, else +// the machine key itself (many keys already match the tailnet name). +function tailKey(name, m) { + return m.tailscale?.name || m.status?.key || name; +} + +// ── live status via tailscale ─────────────────────────────────────────────── +function tailscaleStatus() { + return new Promise((resolve) => { + const tryBin = (i) => { + if (i >= TAILSCALE_BINS.length) return resolve(null); // no tailscale — degrade gracefully + execFile(TAILSCALE_BINS[i], ["status", "--json"], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => { + if (err || !stdout) return tryBin(i + 1); + try { + const j = JSON.parse(stdout); + const nodes = {}; + const short = (p) => (p.DNSName || p.HostName || "").split(".")[0]; + const add = (p, isSelf) => { + if (!p) return; + const k = short(p); + if (!k) return; + nodes[k] = { + online: isSelf ? true : !!p.Online, + self: !!isSelf, + os: p.OS || null, + ip: (p.TailscaleIPs || [])[0] || null, + lastSeen: p.LastSeen && !p.LastSeen.startsWith("0001") ? p.LastSeen : null, + }; + }; + add(j.Self, true); + for (const p of Object.values(j.Peer || {})) add(p, false); + resolve(nodes); + } catch { + tryBin(i + 1); + } + }); + }; + tryBin(0); + }); +} + +function liveFor(name, m, nodes) { + const src = m.status?.source; + if (src !== "tailscale" || !nodes) { + return { source: src || "none", online: null, note: src && src !== "tailscale" ? `liveness via ${src} (not probed here)` : "no tailnet liveness source" }; + } + const node = nodes[tailKey(name, m)]; + if (!node) return { source: "tailscale", online: null, note: `tailnet node '${tailKey(name, m)}' not found in status` }; + return { source: "tailscale", online: node.online, self: node.self, ip: node.ip, lastSeen: node.lastSeen }; +} + +function statusGlyph(live) { + if (live.online === true) return live.self ? "🟢(self)" : "🟢"; + if (live.online === false) return "⚪"; + return "❔"; +} + +// ── tool implementations ──────────────────────────────────────────────────── +async function toolList() { + const { machines, path } = loadRegistry(); + const nodes = await tailscaleStatus(); + const lines = [`fleet (${Object.keys(machines).length} machines) — source: ${path}`, ""]; + // group by designation for legibility + const groups = {}; + for (const [name, m] of Object.entries(machines)) (groups[m.designation || "unclassified"] ||= []).push([name, m]); + for (const [designation, entries] of Object.entries(groups)) { + lines.push(`── ${designation} ──`); + for (const [name, m] of entries) { + const live = liveFor(name, m, nodes); + const caps = (m.capabilities || []).join(",") || "—"; + const flag = m._review ? " ⚠review" : ""; + lines.push(` ${statusGlyph(live)} ${(m.emoji || "").padEnd(2)} ${name} — [${caps}]${flag}`); + } + lines.push(""); + } + return [{ type: "text", text: lines.join("\n").trimEnd() }]; +} + +async function toolMachine({ name }) { + if (!name) throw new Error("`name` is required (see fleet_list)"); + const { machines } = loadRegistry(); + const m = machines[name] || Object.entries(machines).find(([k, v]) => v.tailscale?.name === name || k.toLowerCase() === name.toLowerCase())?.[1]; + if (!m) throw new Error(`unknown machine: ${name} — see fleet_list`); + const nodes = await tailscaleStatus(); + const live = liveFor(name, m, nodes); + return [{ type: "text", text: JSON.stringify({ ...m, _live: live }, null, 2) }]; +} + +async function toolFind({ capability }) { + if (!capability) throw new Error("`capability` is required (see fleet_designations for the vocabulary)"); + const cap = capability.toLowerCase(); + const { machines } = loadRegistry(); + const nodes = await tailscaleStatus(); + const hits = []; + for (const [name, m] of Object.entries(machines)) { + const caps = (m.capabilities || []).map((c) => c.toLowerCase()); + if (!caps.includes(cap)) continue; + const live = liveFor(name, m, nodes); + hits.push(` ${statusGlyph(live)} ${name} (${m.designation || "?"}) — ${m.fleetRole || m.role || ""}`.trimEnd()); + } + const head = hits.length ? `machines with capability '${capability}':` : `no machines advertise capability '${capability}' — see fleet_designations for valid tags.`; + return [{ type: "text", text: [head, ...hits].join("\n") }]; +} + +async function toolDesignations() { + const { schema } = loadRegistry(); + if (!schema) return [{ type: "text", text: "registry has no _schema block (canonical machines.json?) — run toolchain/fleet/normalize-machines.mjs to generate the normalized file with vocabularies." }]; + const L = ["DESIGNATIONS (primary fleet role, one per machine):"]; + for (const [k, v] of Object.entries(schema.designations || {})) L.push(` ${k} — ${v}`); + L.push("", "CAPABILITIES (composable tags):"); + for (const [k, v] of Object.entries(schema.capabilities || {})) L.push(` ${k} — ${v}`); + return [{ type: "text", text: L.join("\n") }]; +} + +const TOOLS = [ + { + name: "fleet_list", + description: "List every machine @jeffrey has access to, grouped by fleet designation, with a live online/offline glyph (🟢 online · ⚪ offline · ❔ unknown) and a one-line capability summary. The single source of truth for 'what machines do I have?'. Merges the private vault registry with live `tailscale status`.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "fleet_machine", + description: "Full detail for ONE machine (hardware, os, ssh, repoPath, designation, capabilities, notes) plus its live tailnet status. Accepts the registry key or the tailnet short name. Names come from fleet_list.", + inputSchema: { type: "object", properties: { name: { type: "string", description: "Machine name, e.g. macbook-pro-clam, jasellite, neo." } }, required: ["name"] }, + }, + { + name: "fleet_find", + description: "Find which machines can do X — returns every machine advertising a given capability tag (e.g. gpu, mlx, chromium-pool, build-macos, always-on), with live status. Use fleet_designations for the capability vocabulary.", + inputSchema: { type: "object", properties: { capability: { type: "string", description: "A capability tag, e.g. gpu, mlx, macos-automation, chromium-pool, ffmpeg-render, always-on, git-remote." } }, required: ["capability"] }, + }, + { + name: "fleet_designations", + description: "Explain the controlled vocabularies: the fleet designations (agent-endpoint, compute-node, control, build, service, display, legacy) and the capability tags. Read this to know what fleet_find accepts.", + inputSchema: { type: "object", properties: {} }, + }, +]; + +const HANDLERS = { + fleet_list: toolList, + fleet_machine: toolMachine, + fleet_find: toolFind, + fleet_designations: toolDesignations, +}; + +async function handleMessage(message) { + const { id, method, params } = message; + try { + switch (method) { + case "initialize": + return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "fleet-mcp", version: "1.0.0" } } }; + case "initialized": + case "notifications/initialized": + return null; + case "ping": + return { jsonrpc: "2.0", id, result: {} }; + case "tools/list": + return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; + case "tools/call": { + const fn = HANDLERS[params?.name]; + if (!fn) throw new Error(`Unknown tool: ${params?.name}`); + const content = await fn(params.arguments || {}); + return { jsonrpc: "2.0", id, result: { content } }; + } + default: + return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }; + } + } catch (error) { + if (method === "tools/call") + return { jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: String(error.message || error) }] } }; + return { jsonrpc: "2.0", id, error: { code: -32000, message: String(error.message || error) } }; + } +} + +// Allow a quick CLI smoke test: `node fleet-mcp.mjs list|find |machine `. +if (process.argv[2]) { + const [cmd, arg] = process.argv.slice(2); + const map = { list: () => toolList(), find: () => toolFind({ capability: arg }), machine: () => toolMachine({ name: arg }), designations: () => toolDesignations() }; + const fn = map[cmd]; + if (!fn) { + console.error("usage: fleet-mcp.mjs [list | find | machine | designations]"); + process.exit(1); + } + fn().then((c) => console.log(c.map((x) => x.text).join("\n"))).catch((e) => { console.error(String(e.message || e)); process.exit(1); }); +} else { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + rl.on("line", async (line) => { + if (!line.trim()) return; + try { + const response = await handleMessage(JSON.parse(line)); + if (response) console.log(JSON.stringify(response)); + } catch (e) { + console.error(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: `Parse error: ${e.message}` } })); + } + }); + console.error("🛰 fleet-mcp server started (fleet_list, fleet_machine, fleet_find, fleet_designations)"); +} diff --git a/toolchain/fleet/normalize-machines.mjs b/toolchain/fleet/normalize-machines.mjs new file mode 100755 index 000000000..eb5247c53 --- /dev/null +++ b/toolchain/fleet/normalize-machines.mjs @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// normalize-machines.mjs — enrich the canonical fleet registry into the +// normalized fleet schema WITHOUT overwriting the source of truth. +// +// Reads $FLEET_SRC (default ~/aesthetic-computer-vault/machines.json) +// Writes $FLEET_OUT (default ~/aesthetic-computer-vault/machines.normalized.json) +// +// The source stays exactly as authored — this is additive. Every machine keeps +// its original fields; we layer on the fleet vocabulary: `designation`, +// `capabilities[]`, a normalized `status` pointer (how liveness is resolved), +// and a `tailscale` cross-reference so live `tailscale status` can be matched +// to a registry entry by its magic-DNS short name. New tailnet machines that +// exist on the wire but were never in the vault are ADDED as review stubs +// (flagged `_review`), so a human can promote or correct them. +// +// House style: node builtins only, no deps. Data lives in the vault (private); +// this code can be public. Run it, eyeball machines.normalized.json, then (if +// happy) a human folds the new fields back into the canonical machines.json. +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const HOME = homedir(); +const SRC = process.env.FLEET_SRC || join(HOME, "aesthetic-computer-vault", "machines.json"); +const OUT = process.env.FLEET_OUT || join(HOME, "aesthetic-computer-vault", "machines.normalized.json"); + +// ── controlled vocabularies (documented, so agents can reason over them) ───── +const SCHEMA = { + version: 1, + generatedBy: "toolchain/fleet/normalize-machines.mjs", + // A machine's PRIMARY fleet role. One per machine. + designations: { + "agent-endpoint": "Hosts a hermes gateway + a stable identity. Where an autonomous agent lives on the tailnet.", + "compute-node": "Headless capability provider. No identity of its own; advertises capabilities (GPU/MLX/automation) over HTTP MCP on the tailnet.", + "control": "Author / control box driven by a human (or by an agent-endpoint). Holds git auth, drives the fleet.", + "build": "Build / CI box — compiles native + web targets.", + "service": "Hosts a long-lived network service (db, session server, mail, web).", + "display": "Art / signage display device.", + "legacy": "Archived or legacy host; kept for reference, not active fleet compute.", + }, + // Composable capability tags. A machine has zero or more. + capabilities: { + gpu: "Discrete GPU available for compute/render.", + cuda: "NVIDIA CUDA stack present.", + mlx: "Apple-silicon MLX local-model inference.", + unreal: "Unreal Engine toolchain installed.", + docker: "Can run Docker containers.", + "macos-automation": "Scriptable native macOS UI (osascript, screen+app automation via slab).", + "screen-capture": "Screen Recording granted — frame/puppet can see + drive it.", + "chromium-pool": "Can host headless/headed Chrome for CDP automation.", + "ffmpeg-render": "ffmpeg media rendering (video/audio pipelines).", + "always-on": "Runs 24/7 — safe to schedule background work on.", + "git-remote": "Holds GitHub / knot credentials for pushing.", + mongodb: "Self-hosted MongoDB.", + redis: "Redis instance.", + mail: "Mail appliance (isync/MCP).", + "web-host": "Serves public HTTP(S).", + "build-macos": "Builds macOS/native AC targets.", + "build-ios": "Builds iOS targets.", + "tailnet-api": "Exposes authed HTTP APIs over the tailnet.", + "art-display": "Drives an art-display surface.", + }, + statusSources: { + tailscale: "Liveness from `tailscale status --json`, matched by tailscale.name (magic-DNS short name).", + http: "Liveness from an HTTP(S) health probe (not wired into fleet-mcp yet).", + lan: "LAN-only host; no continuous liveness source.", + none: "No liveness source (legacy/archived).", + }, +}; + +// ── per-machine enrichment, keyed by the vault's machine key ───────────────── +// Only the fleet-vocabulary fields live here; everything else is inherited from +// the source entry untouched. +const ENRICH = { + "jeffrey-windows": { + designation: "compute-node", + capabilities: ["gpu", "cuda", "unreal", "docker", "screen-capture"], + tailscale: { name: "windows-tower" }, + status: { source: "tailscale", key: "windows-tower" }, + fleetRole: "RTX GPU + Unreal render/compute node. On the tailnet as 'windows-tower'.", + _resolves: "vault key 'jeffrey-windows' === tailnet node 'windows-tower'.", + }, + "jeffrey-macbook": { + designation: "control", + capabilities: ["macos-automation", "git-remote"], + status: { source: "lan", key: null }, + fleetRole: "Portable author box (LAN jas-mbp.local).", + _review: "Possibly stale / superseded by 'neo'. Confirm whether this is a distinct machine.", + }, + "jas-fedora": { + designation: "control", + capabilities: ["docker"], + status: { source: "lan", key: null }, + fleetRole: "Personal Linux laptop (intermittent).", + }, + "mac-mini": { + designation: "build", + capabilities: ["build-macos", "build-ios"], + status: { source: "lan", key: null }, + fleetRole: "Older Mac mini build server (spiderlily Unreal Mac/iOS builds).", + }, + "legacy-2016": { + designation: "legacy", + capabilities: ["web-host", "legacy"], + status: { source: "http", key: null }, + fleetRole: "Legacy 2016 DO droplet hosting archived *.jas.life sites.", + }, + "session-server": { + designation: "service", + capabilities: ["web-host", "redis", "always-on"], + status: { source: "http", key: null }, + fleetRole: "Real-time session/chat/clock backend (Caddy + pm2). 1GB box, OOM-fragile.", + }, + silo: { + designation: "service", + capabilities: ["mongodb", "web-host", "always-on"], + status: { source: "http", key: null }, + fleetRole: "Self-hosted MongoDB + data/storage dashboard.", + }, + "ff1-dvveklza": { + designation: "display", + capabilities: ["art-display"], + status: { source: "lan", key: null }, + fleetRole: "Feral File FF1 art-display device.", + }, + "x1-nano-g2": { + designation: "control", + capabilities: ["docker"], + status: { source: "lan", key: null }, + fleetRole: "Ultralight Fedora laptop (intermittent).", + }, + blueberry: { + designation: "control", + capabilities: ["macos-automation", "screen-capture"], + tailscale: { name: "blueberry", ip: "100.79.75.53" }, + status: { source: "tailscale", key: "blueberry" }, + fleetRole: "Lightweight (8GB) control / macpal box. Too small to host hermes — author + drive only.", + }, + "macbook-pro-clam": { + designation: "compute-node", + capabilities: ["mlx", "macos-automation", "screen-capture", "chromium-pool", "ffmpeg-render", "always-on"], + tailscale: { name: "macbook-pro-clam", ip: "100.86.206.3" }, + status: { source: "tailscale", key: "macbook-pro-clam" }, + fleetRole: "Always-on macOS media-gen compute node (M1 Pro/16GB): Chromium pool, MLX local models, screen + native-app automation. Also the strongest agent-endpoint CANDIDATE among the Macs.", + }, +}; + +// ── tailnet machines present on the wire but missing from the vault ────────── +// Added as review stubs so the fleet view is complete. Promote/correct by hand. +const ADD = { + neo: { + label: "💻 Jeffrey's MacBook Neo", + emoji: "💻", + os: "macOS", + user: "jas", + hostname: "neo.local", + repoPath: "/Users/jas/aesthetic-computer", + designation: "control", + capabilities: ["git-remote", "macos-automation", "screen-capture"], + tailscale: { name: "neo", ip: "100.108.5.81" }, + status: { source: "tailscale", key: "neo" }, + fleetRole: "Primary control MacBook — holds GitHub auth, live-shared author box for the fleet.", + _review: "Added by fleet normalization 2026-07-06 (on the tailnet, absent from vault). Fill in hardware/ssh.", + }, + chicken: { + label: "🐔 Chicken (Mac mini)", + emoji: "🐔", + os: "macOS", + user: "jas", + hostname: "chicken.local", + designation: "build", + capabilities: ["build-macos", "chromium-pool", "macos-automation", "always-on"], + hardware: { model: "Mac mini", memory: "16 GB" }, + ssh: { enabled: true, via: "tailnet SSH", alias: "chicken" }, + tailscale: { name: "chicken-1", ip: "100.98.158.126" }, + status: { source: "tailscale", key: "chicken-1" }, + fleetRole: "16GB mini build/run box; hosts a Chrome CDP pool for fuser App-Node testing.", + _review: "Added by fleet normalization 2026-07-06. Active tailnet node is 'chicken-1'; a stale offline 'chicken' node also exists — ignore it.", + }, + panda: { + label: "🐼 Panda (Mac mini)", + emoji: "🐼", + os: "macOS", + user: "jas", + hostname: "panda.local", + designation: "build", + capabilities: ["build-macos", "chromium-pool", "macos-automation", "always-on"], + hardware: { model: "Mac mini", memory: "16 GB" }, + ssh: { enabled: true, via: "tailnet SSH", alias: "panda" }, + tailscale: { name: "panda-1", ip: "100.88.155.94" }, + status: { source: "tailscale", key: "panda-1" }, + fleetRole: "16GB mini build/run box; PR-viewing + fuser typecheck/build.", + _review: "Added by fleet normalization 2026-07-06. Active tailnet node is 'panda-1'; a stale offline 'panda' node also exists — ignore it.", + }, + jasellite: { + label: "🛰️ Jasellite (services appliance)", + emoji: "🛰️", + os: "Linux (DigitalOcean)", + user: "root", + ip: "24.144.92.66", + designation: "agent-endpoint", + capabilities: ["always-on", "mail", "tailnet-api", "docker", "web-host"], + ssh: { enabled: true, via: "tailnet + public IP" }, + tailscale: { name: "jasellite", ip: "100.72.36.78" }, + status: { source: "tailscale", key: "jasellite" }, + fleetRole: "Always-on Linux services appliance + PRIMARY hermes agent-endpoint host. Runs the mail appliance and authed tailnet APIs.", + _review: "Added by fleet normalization 2026-07-06. This is the intended home for the hermes gateway + fleet identity.", + }, + "jas-nzxt": { + label: "🖥️ jas-nzxt (GPU tower)", + emoji: "🖥️", + os: "Linux", + designation: "compute-node", + capabilities: ["gpu", "cuda", "docker", "ffmpeg-render"], + tailscale: { name: "jas-nzxt", ip: "100.103.42.46" }, + status: { source: "tailscale", key: "jas-nzxt" }, + fleetRole: "Linux GPU tower for heavy compute / render.", + _review: "Added by fleet normalization 2026-07-06 (on the tailnet, absent from vault). Fill in hardware/ssh/user.", + }, +}; + +function main() { + if (!existsSync(SRC)) { + console.error(`source not found: ${SRC}`); + process.exit(1); + } + const src = JSON.parse(readFileSync(SRC, "utf8")); + const out = { + _schema: SCHEMA, + _note: "NORMALIZED / PROPOSAL — regenerate with toolchain/fleet/normalize-machines.mjs. Do not treat as canonical until reviewed and folded back into machines.json.", + machines: {}, + detection: src.detection, + tailscale: src.tailscale, + }; + + // 1) enrich existing entries (additive; original fields preserved) + for (const [key, entry] of Object.entries(src.machines || {})) { + const extra = ENRICH[key] || { _review: "No fleet enrichment mapping — classify me." }; + out.machines[key] = { name: key, ...entry, ...extra, _normalized: true }; + } + // 2) add tailnet machines missing from the vault + for (const [key, entry] of Object.entries(ADD)) { + if (out.machines[key]) continue; + out.machines[key] = { name: key, ...entry, _normalized: true, _added: true }; + } + + writeFileSync(OUT, JSON.stringify(out, null, 2) + "\n"); + const machines = Object.values(out.machines); + const byDesignation = {}; + for (const m of machines) (byDesignation[m.designation] ||= []).push(m.name); + console.log(`wrote ${OUT}`); + console.log(`${machines.length} machines:`); + for (const [d, names] of Object.entries(byDesignation)) console.log(` ${d}: ${names.join(", ")}`); + const review = machines.filter((m) => m._review).map((m) => m.name); + if (review.length) console.log(`needs review: ${review.join(", ")}`); +} + +main();