From a86e68c5259cac7fb0860fb493b60638dc830d76 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 9 Jun 2026 21:23:36 -0700 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=E2=9C=A8=20add=20fleet=20engine?= =?UTF-8?q?=20mesh=20core=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../20260609_fleet-engine-title-refiner.md | 95 +++++++++++++++++++ .project/handoffs/README.md | 11 +++ Sources/TVAnarchy/DevicesView.swift | 25 +++++ Sources/TVAnarchyCore/DeviceConfig.swift | 18 ++++ Sources/TVAnarchyCore/MpvTarget.swift | 11 ++- Sources/TVAnarchyCore/PlayerController.swift | 21 ++++ Sources/TVAnarchyCore/PlayerTarget.swift | 10 ++ .../DeviceConfigDecodeTests.swift | 31 ++++++ docs/architecture.md | 35 +++++-- docs/data-model.md | 51 +++++++++- docs/operations.md | 23 ++++- fleet/README.md | 27 +++++- mcp/src/blacktv/black-tv.sh | 60 +++++++++++- 13 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 .project/handoffs/20260609_fleet-engine-title-refiner.md create mode 100644 .project/handoffs/README.md diff --git a/.project/handoffs/20260609_fleet-engine-title-refiner.md b/.project/handoffs/20260609_fleet-engine-title-refiner.md new file mode 100644 index 0000000..b762141 --- /dev/null +++ b/.project/handoffs/20260609_fleet-engine-title-refiner.md @@ -0,0 +1,95 @@ +# Handoff — fleet engine (mesh stage 1+3 core) + MLX TitleRefiner + +Session date: 2026-06-09 (evening). Scope: "do everything left on the roadmap +that is buildable from this repo." Everything below is committed on `main` +(swept into autocommits `8f12f47` "local llm title refiner integration" and +`7ff780f` "restart command support" — the autocommit daemon mixes sessions' +files; the commit titles do NOT map cleanly to this work). + +## What landed + +### 1. Fleet engine — `governor/src/fleet/` (TS/Bun) + +The implemented single-fleet core of the mesh design spec +(`../history/20260608_fleet-manager-mesh-design.md`): + +| File | Owns | +|---|---| +| `types.ts` | FleetHost / Duty / Source / Peer / Holding / verdict types (mirror the spec entities) | +| `registry.ts` | ingest: `~/.config/tv-anarchy/fleet.json` **array form is authoritative** (the app-side fleet registry written by the Devices-tab session — knows apricot/phone); `devices.json` fallback; optional policy keys `floorCopies` / `sources` / `staticHoldings` / object-form `devices` overrides | +| `duties.ts` | deterministic duty assignment (broadcast / f2f_relay / public_swarm_face) + invariants (consumer never gets a duty; one broadcast; seedbox-first face; home-IP exposure warned); `diffDuties` for change logs | +| `custody.ts` | N-copy floor-check (default 2), rolling-baton custodianship, ≥1 always-on slot, `custodians_of`, re-pin **plans** | +| `reaper.ts` | healthy\|stalled\|dead (idle 30 min / 72 h), mesh-first recovery upgrade, re-search fallback | +| `peers.ts` | source model with BOTH private-tracker gates (`search_only` default-closed; `f2f_only` FORCED un-overridable) + `peers_for` (fleet ∪ seedbox ∪ live DHT, provenance-tagged, deduped) | +| `transmission.ts` | ssh→localhost:9091 JSON-RPC view of black's daemon (vitals, holdings, live peers, reannounce/verify/start) | +| `cli.ts` | `portable-net-tv fleet status\|duties\|custody\|reaper [--apply]\|peers ` (all `--json`); state diff at `~/.local/state/tv-anarchy/fleet-state.json` | + +Read-only by default. The ONLY mutating path is `fleet reaper --apply` = +idempotent transmission nudges. Re-pins / mesh recoveries / re-searches are +printed plans by design. + +### 2. MLX TitleRefiner — seam closed + +- `recommender/media_rec/title_refiner.py` — MLX Qwen2.5-1.5B (same model as + the grouper), prompt → `{"title": ...}`, plausibility guard (must share a + token with the filename), degrades to empty title without MLX. +- `Sources/TVAnarchyCore/Metadata/LocalLLMTitleRefiner.swift` — shells uv like + `LocalLLMGrouper`; disk cache `~/.local/state/tv-anarchy/title-refinements.json` + (empties cached too); 2-consecutive-failure session kill-switch. +- Wired in `TVAnarchyApp.init()`. +- **Bug fixed en route:** `FilenameParser.extractTitle` fell back to the raw + filename BEFORE the `<2 chars → refiner` check, so the refiner was + unreachable. Fallback now runs after the consult. + +### 3. Docs aligned + +`docs/roadmap.md` (status table + build order rewritten), `docs/architecture.md` +(§3 fleet engine, §2 refiner, §4 retitled), `docs/data-model.md` (fleet.json +schema as found on disk), `docs/operations.md` (fleet CLI section), +`fleet/README.md` (points at the implementation). + +## Verification (all green at handoff) + +- `cd governor && bun test` → **45 pass** (5 files: duties/custody/reaper/peers/registry) +- `cd governor && bunx tsc --noEmit` → clean +- `xcodebuild -scheme TVAnarchy test` → **149 pass** (incl. 2 new refiner-seam tests) +- Live: `fleet status` (4-member registry from real fleet.json), `fleet reaper` + against black = 218 torrents → 180 healthy / 32 stalled / 6 dead, + `fleet custody` = every title breaches 2-copy floor (true: black is the only + custodian), `title_refiner.py "[Anime Time] Sousou no Frieren - …"` → + `"Sousou no Frieren"` on real MLX. + +## What's NOT done (and why) + +1. **Re-pin actuation** — executing cross-host copies + auto-feeding `research` + actions into `search/`. Plans print today. This is the next real step; + build it in governor (transfer queue territory), never in the Swift app. +2. **Fleet WG fabric (plane 1)** — BLOCKED on an open user decision: is + `10.9.0.4` a general overlay (fleet WG additive) or ad-hoc tv-anarchy + (fleet WG replaces)? Also needs root on each node. Spec section + "Networking — two independent planes." +3. **Seedbox / friend-mesh / private-tracker / Discord** — blocked on external + infrastructure (a provisioned box, other fleets, creds, bot tokens). The + engine already models seedbox class/duties and enforces the private gates. + +## Gotchas for the next session + +- **Autocommit daemon**: your working tree WILL be committed under another + session's message mid-flight. Check `git log` before assuming anything is + uncommitted; don't be surprised when your diff vanishes. +- **fleet.json is app-owned** (array form, written by the Devices-tab work in + a different worktree — no writer exists in THIS tree's Sources). The governor + only reads it. Policy keys (floorCopies/sources/staticHoldings) are additive + top-level keys; if the app-side writer ever rewrites the file wholesale, + check it preserves unknown keys. +- **fleet.json says `reachable: home_lan` for black/apricot**, so no f2f_relay + duty is assigned (needs wireguard|public_ip). The engine is correct; the + registry data is conservative. Flip `reachable` to `wireguard` (or override + in object form) to see relay duties. +- **Swift test suite writes the real `~/.config/tv-anarchy/devices.json`** + (loadOrSeed) — back it up before config/migration verification. +- Reaper thresholds: STALL_AFTER 30 min, DEAD_AFTER 72 h idle (incomplete + + peerless). Complete torrents are always healthy. +- transmissionHost = first registry host with `transmission_rpc` AND an ssh + destination — apricot advertises transmission but has no `user@host` service + detail, so black is the working host today. diff --git a/.project/handoffs/README.md b/.project/handoffs/README.md new file mode 100644 index 0000000..90a3f58 --- /dev/null +++ b/.project/handoffs/README.md @@ -0,0 +1,11 @@ +# .project/handoffs/ + +Session-to-session handoff notes. Multiple Claude sessions work this checkout +concurrently (and an autocommit daemon sweeps the working tree, so one +session's commits routinely contain another session's files) — these notes are +how a session tells the next one what it changed, what it verified, and what it +deliberately left undone. + +Convention: one file per handoff, `YYYYMMDD_topic.md`, newest facts win. +Long-form design conversations go in `../history/`; handoffs are operational: +**what landed, how it was verified, what's next, what will bite you.** diff --git a/Sources/TVAnarchy/DevicesView.swift b/Sources/TVAnarchy/DevicesView.swift index d5d33ee..64be339 100644 --- a/Sources/TVAnarchy/DevicesView.swift +++ b/Sources/TVAnarchy/DevicesView.swift @@ -11,6 +11,8 @@ struct DevicesView: View { @State private var editing: DeviceConfig? // edit sheet (existing device) @State private var adding = false // add sheet @State private var confirmReset = false + @State private var restarting: Set = [] // device ids with a restart in flight + @State private var restartNote: String? private var devices: [DeviceConfig] { controller.editableDevices } @@ -51,10 +53,18 @@ struct DevicesView: View { .help("Cache the next episodes of your recent shows to this device") } if let s = controller.hostStatsByID[d.id] { loadPill(s) } + if restarting.contains(d.id) { + ProgressView().controlSize(.small) + .help("Restarting the player service…") + } Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state)) Menu { Button("Make active") { controller.setActive(d.id) } .disabled(d.id == controller.activeID || !d.services.stream) + if controller.canRestartService(d.id) { + Button("Restart service") { restartService(d) } + .disabled(restarting.contains(d.id)) + } Button("Edit…") { editing = d } Button("Delete", role: .destructive) { controller.deleteDevice(d.id) } .disabled(devices.count <= 1) @@ -66,6 +76,9 @@ struct DevicesView: View { if let s = offline.status { Text(s).font(.caption).foregroundStyle(.secondary) } + if let restartNote { + Text(restartNote).font(.caption).foregroundStyle(.secondary) + } HStack { Button("Reload config") { controller.reload() } @@ -92,6 +105,18 @@ struct DevicesView: View { } } + /// Restart the device's host-side player service (e.g. black's mpv unit) and + /// surface the outcome inline; the row spins while the restart is in flight. + private func restartService(_ d: DeviceConfig) { + restarting.insert(d.id) + restartNote = "Restarting \(d.name)…" + Task { + let ok = await controller.restartService(d.id) + restarting.remove(d.id) + restartNote = ok ? "\(d.name): service restarted" : "\(d.name): service restart failed" + } + } + /// Compact "stream · offline · seed · custody" summary of the on services. private func servicesSummary(_ s: DeviceServices) -> String { var on: [String] = [] diff --git a/Sources/TVAnarchyCore/DeviceConfig.swift b/Sources/TVAnarchyCore/DeviceConfig.swift index 5809ceb..8cd8ad2 100644 --- a/Sources/TVAnarchyCore/DeviceConfig.swift +++ b/Sources/TVAnarchyCore/DeviceConfig.swift @@ -188,6 +188,24 @@ public struct CommandsConfig: Codable, Sendable, Equatable { self.restart = restart } + enum CodingKeys: String, CodingKey { + case launchFile, releases, resolveRelease, stats, stop, restart + } + /// Tolerant decode: a pre-`restart` config whose teardown is the canonical + /// `[, "stop"]` gets `restart` delegated to the same helper — no + /// migration step, same pattern as the legacy type/services inference. Any + /// other stop shape leaves the capability absent. + public init(from d: Decoder) throws { + let c = try d.container(keyedBy: CodingKeys.self) + launchFile = try c.decodeIfPresent([String].self, forKey: .launchFile) + releases = try c.decodeIfPresent([String].self, forKey: .releases) + resolveRelease = try c.decodeIfPresent([String].self, forKey: .resolveRelease) + stats = try c.decodeIfPresent([String].self, forKey: .stats) + stop = try c.decodeIfPresent([String].self, forKey: .stop) + restart = try c.decodeIfPresent([String].self, forKey: .restart) + ?? stop.flatMap { $0.count == 2 && $0[1] == "stop" ? [$0[0], "restart"] : nil } + } + /// The delegated commands for a `black-tv` helper at `bin` — the seed default /// and the legacy-config migration target. public static func blackTVDefaults(bin: String) -> CommandsConfig { diff --git a/Sources/TVAnarchyCore/MpvTarget.swift b/Sources/TVAnarchyCore/MpvTarget.swift index 33ef06e..087e708 100644 --- a/Sources/TVAnarchyCore/MpvTarget.swift +++ b/Sources/TVAnarchyCore/MpvTarget.swift @@ -11,7 +11,7 @@ import Foundation /// so a whole status poll is a single round-trip. Responses are matched by /// `request_id` (mpv echoes it), never by position, so interleaved async event /// lines don't corrupt the parse. -public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider, MediaLaunchable, Enqueueable, TrackSelectable { +public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider, MediaLaunchable, Enqueueable, TrackSelectable, ServiceRestartable { public let id: String public let name: String public let kind: HostKind = .mpvIPC @@ -59,6 +59,15 @@ public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider /// goes through the host's configured `stop` command for identical cleanup. public func stop() async { await runCommand(commands?.stop, [:]) } + // MARK: ServiceRestartable (delegated) + + public var canRestartService: Bool { commands?.restart != nil } + + /// Hard-restart the host's player service (black: relaunch the mpv unit, + /// resuming the live playlist/position when it's still readable). + @discardableResult + public func restartService() async -> Bool { await runCommand(commands?.restart, [:]) } + // MARK: MediaLaunchable (delegated) @discardableResult diff --git a/Sources/TVAnarchyCore/PlayerController.swift b/Sources/TVAnarchyCore/PlayerController.swift index f3fd92e..5bd1e82 100644 --- a/Sources/TVAnarchyCore/PlayerController.swift +++ b/Sources/TVAnarchyCore/PlayerController.swift @@ -145,6 +145,27 @@ public final class PlayerController { /// Re-seed the default set (plum VLC + black mpv-ipc with LAN+overlay endpoints). public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) } + // MARK: Device service restart (Devices tab) + + /// Whether `id`'s host-side player service can be restarted (the target + /// supports it AND has the delegated restart command configured). + public func canRestartService(_ id: String) -> Bool { + (targets.first { $0.id == id } as? ServiceRestartable)?.canRestartService ?? false + } + + /// Restart `id`'s host-side player service, then re-poll the device so its + /// row reflects the outcome. Returns whether the restart command succeeded. + @discardableResult + public func restartService(_ id: String) async -> Bool { + guard let target = targets.first(where: { $0.id == id }), + let restartable = target as? ServiceRestartable, + restartable.canRestartService else { return false } + let ok = await restartable.restartService() + Log.info("service restart on \(target.name): \(ok ? "ok" : "FAILED")") + await refreshSnapshot(for: target) + return ok + } + /// True while the Player tab is on screen. Off-tab we still poll the active /// target — slowly — so the HostSelector dots stay fresh and an armed sleep /// timer's end-of-episode check keeps running; but the fast 1.5s transport diff --git a/Sources/TVAnarchyCore/PlayerTarget.swift b/Sources/TVAnarchyCore/PlayerTarget.swift index 36962f8..9ba504b 100644 --- a/Sources/TVAnarchyCore/PlayerTarget.swift +++ b/Sources/TVAnarchyCore/PlayerTarget.swift @@ -60,3 +60,13 @@ public protocol PlayerTarget: AnyObject { func previous() async func stop() async } + +/// A target whose host-side player service can be restarted in place (black: +/// relaunch the root-owned mpv unit when it hangs or its socket goes stale, +/// resuming what was playing). The restart is delegated to a per-host command, +/// so conformance alone isn't enough — `canRestartService` reflects whether +/// that command is actually configured. Drives the Devices tab action. +public protocol ServiceRestartable: AnyObject { + var canRestartService: Bool { get } + @discardableResult func restartService() async -> Bool +} diff --git a/Tests/TVAnarchyCoreTests/DeviceConfigDecodeTests.swift b/Tests/TVAnarchyCoreTests/DeviceConfigDecodeTests.swift index 1d3da0c..71459b7 100644 --- a/Tests/TVAnarchyCoreTests/DeviceConfigDecodeTests.swift +++ b/Tests/TVAnarchyCoreTests/DeviceConfigDecodeTests.swift @@ -38,6 +38,22 @@ final class DeviceConfigDecodeTests: XCTestCase { XCTAssertEqual(h.mpv?.volumeScale, 130) XCTAssertEqual(h.commands?.stop, ["btv", "stop"]) XCTAssertNil(h.commands?.releases) // unspecified capability → nil + // pre-`restart` config with canonical `[helper, "stop"]` teardown → + // restart inferred onto the same helper (no migration step) + XCTAssertEqual(h.commands?.restart, ["btv", "restart"]) + } + + /// The restart inference is limited to the canonical `[helper, "stop"]` + /// shape — a bespoke teardown command must NOT grow a guessed restart. + func testRestartNotInferredFromBespokeStopCommand() throws { + let json = #""" + {"devices":[ + {"id":"x","name":"X","kind":"mpv-ipc","mpv":{"endpoints":["a@b"]}, + "commands":{"stop":["ssh-helper","teardown","--force"]}} + ]} + """# + let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8)) + XCTAssertNil(cfg.devices[0].commands?.restart) } @MainActor @@ -59,9 +75,24 @@ final class DeviceConfigDecodeTests: XCTestCase { XCTAssertEqual(back.devices.map(\.kind), seed.devices.map(\.kind)) XCTAssertEqual(back.devices.first(where: { $0.kind == .mpvIPC })?.commands?.launchFile, ["/usr/local/bin/black-tv", "play", "{path}"]) + XCTAssertEqual(back.devices.first(where: { $0.kind == .mpvIPC })?.commands?.restart, + ["/usr/local/bin/black-tv", "restart"]) XCTAssertEqual(back.devices.map(\.type), [.laptop, .storage]) } + /// The Devices-tab restart action keys off the configured command: a host + /// with a `restart` template can restart; one without reports it can't. + @MainActor + func testRestartCapabilityFollowsConfiguredCommand() { + let migrated = PlayerController.makeTarget( + DeviceConfig(id: "black", name: "Black", kind: .blacktv, + ssh: SSHConn(endpoints: ["lilith@10.9.0.4"], bin: "/usr/local/bin/black-tv"))) + XCTAssertEqual((migrated as? ServiceRestartable)?.canRestartService, true) + + let bare = MpvTarget(id: "m", name: "M", mpv: MpvConn(endpoints: ["x@y"]), commands: nil) + XCTAssertFalse(bare.canRestartService) + } + // MARK: device type + services (Part B) /// A pre-`type` config must infer each device's type from its player backend, diff --git a/docs/architecture.md b/docs/architecture.md index b37bcfe..5b4dec3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,8 +99,11 @@ completion callback so finished folders get incrementally indexed. `FilenameParser` (regex: title/year/SxxEyy/quality/codec/source) → `EnrichService` (subprocess to `recommender`, provider routed by category) → `MetaWriter` (path-digest `.meta` sidecars, best-effort black mirror) and -`ArtworkService` (ffmpeg frame-grab fallback). An MLX `TitleRefiner` seam exists -but is unwired (see [roadmap.md](./roadmap.md)). +`ArtworkService` (ffmpeg frame-grab fallback). Degenerate (<2-char) regex +titles are refined by `LocalLLMTitleRefiner` — a subprocess to +`media_rec/title_refiner.py` (local MLX Qwen, same model as the show grouper), +disk-cached per filename and self-disabling after consecutive failures so a +scan never blocks on a missing model. Wired at app startup. ### Device registry (Devices tab, `DeviceConfig`) @@ -157,8 +160,25 @@ bridge **server** is not part of this repo's `mcp/` tree — it lives with - **`governor/` (`portable-net-tv`, TS/Bun).** A standalone launchd daemon on plum: follows VLC playback, appends to the shared watch log, prefetches the next *N* episodes within a bandwidth budget, and GCs the buffer. The app does - **not** invoke it; it runs on its own. In the mesh design this same - bandwidth-arbitration brain is the intended fleet orchestrator (not yet built). + **not** invoke it; it runs on its own. It also hosts the **fleet engine** + (`src/fleet/`) — the implemented single-fleet core of the mesh design: + - `registry.ts` — joins the app-side fleet registry (`fleet.json`, + authoritative when present) / `devices.json` (fallback) into `FleetHost` + records; + - `duties.ts` — deterministic duty assignment (broadcast / f2f_relay / + public_swarm_face) with the spec invariants (a consumer never gets a duty; + home-IP exposure warned); + - `custody.ts` — N-copy floor-check, rolling-baton custodianship, + `custodians_of`, re-pin planning; + - `reaper.ts` — `healthy | stalled | dead` classification + mesh-first + recovery planning; + - `peers.ts` — the source model with both private-tracker policy gates and + `peers_for` (fleet ∪ seedbox ∪ live DHT, provenance-tagged); + - `transmission.ts` — ssh-tunneled JSON-RPC view of black's daemon. + CLI: `portable-net-tv fleet status|duties|custody|reaper [--apply]|peers `. + Read-only by default; `reaper --apply` runs only idempotent + reannounce/verify nudges. Re-pins and re-sourcing are printed plans — + cross-host actuation is the remaining stage-1 work. - **`mcp/` (`plum-control-mcp`, TS/Bun).** An MCP stdio server *and* a CLI bridge. The app uses the CLI (`TorrentService`, `EnrichService`); MCP clients (Claude) use the server. Domains: VLC, black-tv (SSH→mpv on DRM console), transmission @@ -167,10 +187,13 @@ bridge **server** is not part of this repo's `mcp/` tree — it lives with (TMDB/IMDb/TVmaze/AniList, routed by category) and local recommendations (`recommend_local.py`, keyless). Invoked only during indexing/enrichment. -## 4. Planned mesh layer (designed, unbuilt) +## 4. Mesh layer (single-fleet core implemented; federation designed) The fleet/mesh turns the single-host client into "a private tracker made of your -friends." Two graphs ride one Discord identity layer: +friends." The single-fleet core (registry → duties → custody floor → reaper → +`peers_for`) is implemented in the governor's fleet engine (§3); everything +cross-fleet below — F2F relay, friend sources, Discord planes — remains design. +Two graphs ride one Discord identity layer: - **Custody graph** — narrow, trust-bounded (1° friends + always-on nodes). Holds the seeder floor; the zombie-prevention guarantee lives here. diff --git a/docs/data-model.md b/docs/data-model.md index 3397d18..79355d2 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,7 +1,8 @@ # Data Model Two sets: the **config + state schemas in use today** (the app and helpers read -and write these), and the **planned fleet/mesh data model** (designed, unbuilt). +and write these), and the **fleet/mesh data model** (single-fleet core +implemented in the governor's fleet engine; cross-fleet parts still design). --- @@ -52,7 +53,8 @@ entries without a `type` get one inferred from the player backend. "releases": ["/usr/local/bin/black-tv","releases"], "resolveRelease": ["/usr/local/bin/black-tv","resolve-release","{releaseId}"], "stats": ["/usr/local/bin/black-tv","stats"], - "stop": ["/usr/local/bin/black-tv","stop"] + "stop": ["/usr/local/bin/black-tv","stop"], + "restart": ["/usr/local/bin/black-tv","restart"] } } ] @@ -120,11 +122,52 @@ ratings, genres, enrichedAt }`. --- -## Planned fleet/mesh data model +### `fleet.json` — the fleet registry (app-side) + governor policy + +Path: `~/.config/tv-anarchy/fleet.json`. The `devices` **array** is the +app-side fleet registry (authoritative for the governor's fleet engine when +present; `devices.json` is the fallback): + +```jsonc +{ + "devices": [ + { + "id": "black", "name": "black", + "deviceClass": "server", // server|roamer|consumer|seedbox|broadcast + "alwaysOn": true, "onHomeIp": true, "reachable": "home_lan", + "duties": ["custody_floor"], // app-side record; the governor recomputes + "services": [ + { "id": "black", "kind": "mpv-ipc", "detail": "lilith@10.0.0.11" }, + { "id": "black-transmission", "kind": "transmission", "detail": "transmission RPC" } + ] + } + ], + // Optional governor policy keys (read by governor/src/fleet/registry.ts): + "floorCopies": 2, // custody floor (default 2) + "sources": [ // peer sources; gates enforced on load + { "id": "dht", "kind": "dht" } // implicit when absent + ], + "staticHoldings": { "apricot": ["Show Name S01"] } // copies on api-less hosts +} +``` + +The governor derives from this: `api` (a `transmission` service → +`transmission_rpc`), `ssh`/`addr` (a `user@host` service detail), and capacity +defaults. Engine state (last duty assignment, for change diffs) lives at +`~/.local/state/tv-anarchy/fleet-state.json`. Title-refiner cache: +`~/.local/state/tv-anarchy/title-refinements.json` (filename → refined title, +empties cached too). + +--- + +## Fleet/mesh data model (single-fleet core implemented) Synthesized from [`../.project/history/20260608_fleet-manager-mesh-design.md`](../.project/history/20260608_fleet-manager-mesh-design.md). -**None of this is implemented.** +The single-fleet subset — host registry, duty rules, custody floor, reaper, +source gates, `peers_for`/`custodians_of` — is implemented in +`governor/src/fleet/` (types in `types.ts` mirror the entities below). +Identity/Fleet entities and everything cross-fleet remain design. ### Entities diff --git a/docs/operations.md b/docs/operations.md index 7a1fa03..b7588e7 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -115,7 +115,10 @@ localhost+overlay networking). Devices are editable in-app (Devices tab: add/edit/delete, make-active, set type/services, reload, reset, reveal `devices.json`). The list shows a -per-device system-load badge (low/med/high). +per-device system-load badge (low/med/high). Devices with a configured `restart` +command template (black by default) get a **Restart service** menu action that +hard-restarts the host-side player (`black-tv restart`: relaunch the mpv unit, +resuming the live playlist/position; clean teardown when idle/hung). ## governor (`portable-net-tv`) @@ -132,6 +135,24 @@ Runs as a launchd background agent (Apple Events to VLC are blocked there, which is why it reads VLC over HTTP, not AppleScript). Config: see [data-model.md](./data-model.md#configjson--governor-portable-net-tv). +### Fleet engine (`portable-net-tv fleet …`) + +```sh +portable-net-tv fleet status # registry + duty assignment + warnings +portable-net-tv fleet duties # assign duties; Δ-log changes since last run +portable-net-tv fleet custody # floor-check every title; print re-pin plans +portable-net-tv fleet reaper # classify torrents healthy|stalled|dead +portable-net-tv fleet reaper --apply # + safe nudges only (reannounce/verify) +portable-net-tv fleet peers # peers_for(infohash|title), provenance-tagged +``` + +All subcommands take `--json`. Reads the fleet registry from +`~/.config/tv-anarchy/fleet.json` (array form; `devices.json` fallback) — see +[data-model.md](./data-model.md#fleetjson--the-fleet-registry-app-side--governor-policy). +Read-only by default: re-pins, mesh recoveries, and re-searches are printed as +plans; only `reaper --apply` mutates anything (idempotent transmission ops). +Tests: `bun test` in `governor/`. + ## mcp (`plum-control-mcp`) TypeScript/Bun. Serves both an MCP stdio server (for Claude) and the CLI bridge diff --git a/fleet/README.md b/fleet/README.md index 32674df..550f134 100644 --- a/fleet/README.md +++ b/fleet/README.md @@ -1,5 +1,28 @@ # fleet/ — fleet manager + self-healing torrent mesh Design spec: ../.project/history/20260608_fleet-manager-mesh-design.md -Not yet implemented. Build order starts at stage 1 (host registry + duty -assignment, single fleet) per the spec. + +**Status:** the single-fleet core (stage 1 + stage 3) is **implemented in the +governor** — `../governor/src/fleet/` — not here: + +- registry ingest (`~/.config/tv-anarchy/fleet.json` array form authoritative, + `devices.json` fallback) +- deterministic duty assignment (broadcast / f2f_relay / public_swarm_face) + with the spec invariants +- custody floor-check (`custodians_of`, rolling baton, re-pin planning) +- zombie reaper (healthy | stalled | dead; mesh-first recovery planning) +- peer-source model with both private-tracker policy gates + `peers_for` + +CLI: `portable-net-tv fleet status|duties|custody|reaper [--apply]|peers ` +(see ../docs/operations.md). Tests: `bun test` in `../governor`. + +This directory remains the placeholder for the parts that are still design-only +and/or blocked on infrastructure outside this repo: + +- re-pin **actuation** (cross-host torrent copies) and automated re-search +- the fleet WireGuard fabric (plane 1 — blocked on the `10.9.0.4` question) +- a broadcast host serving `peers_for` to other devices +- friend-mesh / F2F relay (stage 4), private-tracker sources (stage 5), + Discord planes, multi-identity + +Build order and the up-to-date status table live in ../docs/roadmap.md. diff --git a/mcp/src/blacktv/black-tv.sh b/mcp/src/blacktv/black-tv.sh index 7cb4ea7..b6b4fe0 100644 --- a/mcp/src/blacktv/black-tv.sh +++ b/mcp/src/blacktv/black-tv.sh @@ -5,8 +5,9 @@ # Deployed to /usr/local/bin/black-tv on black; invoked over SSH by the # plum-control MCP `blacktv` module (mirrors transmission-remote-over-ssh). # -# One long-lived mpv instance plays to the TV; every verb except `play`/`stop` -# goes through its JSON IPC socket, so volume/seek/pause never restart playback. +# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`/ +# `restart` goes through its JSON IPC socket, so volume/seek/pause never restart +# playback. # black has no graphical session — mpv renders straight to KMS (--vo=drm) and # the GPU driver is brought up on demand (see ensure_display). # No `pipefail`: several pipes end in `grep -q`/`head -1`, which exit early and @@ -79,7 +80,7 @@ kill_existing() { sudo systemctl stop "$UNIT" psych-mpv 2>/dev/null || true # psych-mpv = legacy ad-hoc unit sudo systemctl reset-failed "$UNIT" psych-mpv 2>/dev/null || true sudo pkill -x mpv 2>/dev/null || true - rm -f "$SOCK" 2>/dev/null || true + sudo rm -f "$SOCK" 2>/dev/null || true # root-owned socket in sticky /tmp — plain rm can't sleep 1 } launch() { # launch [resume_seconds] @@ -99,6 +100,40 @@ launch() { # launch [resume_seconds] --no-resume-playback "${hook[@]}" \ --fs --really-quiet --playlist="$1" } +# Live state for `restart`: first line = position seconds, then the LIVE playlist +# from the current entry onward — read over IPC, not from $PLAYLIST, because an +# IPC-built queue (the app's enqueue) never touches that file. Empty output when +# idle or unreadable. timeout-guarded: a hung mpv is the main reason to restart, +# so the capture itself must never hang. +capture_state() { + [ -S "$SOCK" ] || return 0 + printf '%s\n%s\n' \ + '{"command":["get_property","playlist"],"request_id":1}' \ + '{"command":["get_property","time-pos"],"request_id":2}' \ + | sudo timeout 5 socat - "$SOCK" 2>/dev/null \ + | python3 -c ' +import json, sys +pl, secs = None, None +for line in sys.stdin: + try: + o = json.loads(line) + except ValueError: + continue + if o.get("error") != "success": + continue + if o.get("request_id") == 1: pl = o.get("data") + if o.get("request_id") == 2: secs = o.get("data") +if not pl: + sys.exit(0) +cur = next((i for i, e in enumerate(pl) if e.get("current")), None) +if cur is None: + sys.exit(0) +print(int(secs or 0)) +for e in pl[cur:]: + if e.get("filename"): + print(e["filename"]) +' 2>/dev/null || true +} # --- playlist building ------------------------------------------------------ build_dir_playlist() { # -> writes $PLAYLIST, echoes count @@ -361,9 +396,24 @@ case "$cmd" in seek) [ $# -ge 1 ] || die "usage: black-tv seek "; ipc "{\"command\":[\"seek\",$1]}" >/dev/null; echo "seek ${1}s" ;; next) ipc '{"command":["playlist-next"]}' >/dev/null; echo next ;; prev) ipc '{"command":["playlist-prev"]}' >/dev/null; echo prev ;; - stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; rm -f "$SOCK"; echo stopped ;; + stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; sudo rm -f "$SOCK" 2>/dev/null || true; echo stopped ;; + restart) + # Hard-restart the player service: tear down the unit and, if something was + # playing, relaunch the remaining playlist resuming at the captured position. + # Idle / hung-unreadable mpv → clean teardown only (a fresh slate to play into). + state=$(capture_state) + if [ -n "$state" ]; then + secs=$(head -1 <<<"$state") + tail -n +2 <<<"$state" > "$PLAYLIST" + launch "$PLAYLIST" "$secs" + echo "restarted: resumed at ${secs}s" + else + kill_existing + echo "restarted: nothing playing — unit/socket cleaned up" + fi + ;; status) status_json ;; stats) stats_json ;; ensure-display) ensure_display; echo "display ready: $(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null)" ;; - *) die "usage: black-tv {play |play-show [S] [E]|resume-show |enqueue |goto-ep N|releases|resolve-release |switch |pause|resume|toggle|vol N|seek S|next|prev|stop|status|stats|watched [q]|ensure-display}" ;; + *) die "usage: black-tv {play |play-show [S] [E]|resume-show |enqueue |goto-ep N|releases|resolve-release |switch |pause|resume|toggle|vol N|seek S|next|prev|stop|restart|status|stats|watched [q]|ensure-display}" ;; esac