feat(@applications/tv-anarchy): ✨ update release update script to multi-platform
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
f2ce865cb8
commit
ef3ed6dcfe
7 changed files with 706 additions and 54 deletions
|
|
@ -47,12 +47,25 @@ the zipped `.app`. Needs a write token: `FORGEJO_TOKEN` env or
|
|||
tools/update.sh [--force] # first run installs; later runs no-op if current
|
||||
```
|
||||
|
||||
Pulls the latest release zip from forge.black (`FORGEJO_API` defaults to the
|
||||
mesh-stable overlay `http://10.9.0.4:3000`), compares the tag to the installed
|
||||
`CFBundleShortVersionString`, and if newer swaps it into the same resolved
|
||||
Applications location (migrating away any copy at the old location) and
|
||||
strips the Gatekeeper quarantine xattr (the build is unsigned). A read token is
|
||||
needed only if the repo is private. Then quit + relaunch.
|
||||
Pulls the latest release from forge.black (`FORGEJO_API` defaults to the
|
||||
mesh-stable overlay `http://10.9.0.4:3000`), picks the **asset for this
|
||||
platform**, compares versions, and swaps it into the **OS-appropriate
|
||||
destination** (`TVANARCHY_DEST` overrides everywhere):
|
||||
|
||||
| OS | Release asset | Destination |
|
||||
|---|---|---|
|
||||
| macOS | `TVAnarchy-<tag>.zip` | `/Applications` (admin) else `~/Applications`; Gatekeeper quarantine stripped; old-location copy migrated away |
|
||||
| Ubuntu (classic Linux) | `TVAnarchy-<tag>-linux-<arch>.tar.gz` | `/opt/tv-anarchy` when `/opt` is writable, else `~/.local/opt/tv-anarchy` |
|
||||
| Bluefin (immutable/ostree Linux) | same as Linux | always `~/.local/opt/tv-anarchy` (`/usr` is read-only; detected via `/run/ostree-booted`) |
|
||||
| Windows (Git Bash/MSYS) | `TVAnarchy-<tag>-windows-<arch>.zip` | `%LOCALAPPDATA%\Programs\TVAnarchy` (per-user, no elevation) |
|
||||
| iOS | — | not via this script (no shell): build the `TVAnarchyiOS` scheme in Xcode onto the device, or TestFlight/sideload |
|
||||
|
||||
Version compare: macOS reads the bundle plist; Linux/Windows read the
|
||||
`.release-tag` stamp the script writes on install. A release that lacks this
|
||||
platform's asset fails loud with the exact missing name and the published list —
|
||||
**today only the macOS asset is published** (cut on plum); Linux/Windows entries
|
||||
activate the moment a release carries their assets. A read token is needed only
|
||||
if the repo is private. Then quit/restart the app.
|
||||
|
||||
> Unsigned local build: fine across **your own** Macs (the quarantine strip
|
||||
> handles Gatekeeper). Distributing to other people's machines would want real
|
||||
|
|
|
|||
132
governor/src/fleet/custody.ts
Normal file
132
governor/src/fleet/custody.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// Custody floor — every wanted title keeps ≥N complete copies; dropping to
|
||||
// N−1 triggers a re-pin from a surviving holder BEFORE the last copy vanishes.
|
||||
//
|
||||
// Custodianship is a rolling baton: the N most-recent complete holders form
|
||||
// the floor, with ≥1 slot reserved for an always-on node (a floor of laptops
|
||||
// is momentarily dead whenever they sleep). Pure functions — actuation (who
|
||||
// actually copies what) is the CLI/daemon's job.
|
||||
|
||||
import { custodyEligible } from "./duties.ts";
|
||||
import type { FleetHost, FloorReport, Holding, RepinAction } from "./types.ts";
|
||||
|
||||
/** Per-title eligibility: host-level custody eligibility + room for the copy. */
|
||||
function eligibleFor(h: FleetHost, sizeBytes: number | null): boolean {
|
||||
if (!custodyEligible(h)) return false;
|
||||
if (sizeBytes !== null && h.capacity.diskFreeBytes !== null) {
|
||||
return h.capacity.diskFreeBytes > sizeBytes;
|
||||
}
|
||||
return true; // unknown sizes/capacity are permissive — better a floor than none
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute one title's floor from its current holdings.
|
||||
*
|
||||
* `holdings` — every host's copy of this title (complete or not).
|
||||
* `hosts` — the full registry (for eligibility + re-pin targets).
|
||||
* `floorN` — required complete copies.
|
||||
*/
|
||||
export function floorForTitle(
|
||||
title: string,
|
||||
holdings: Holding[],
|
||||
hosts: FleetHost[],
|
||||
floorN: number,
|
||||
): FloorReport {
|
||||
const warnings: string[] = [];
|
||||
const actions: RepinAction[] = [];
|
||||
const byId = new Map(hosts.map(h => [h.id, h]));
|
||||
const size = holdings.find(h => h.sizeBytes !== null)?.sizeBytes ?? null;
|
||||
|
||||
// Complete copies on hosts that still exist in the registry, newest first
|
||||
// (rolling baton: recency decides floor membership), ties stable by host id.
|
||||
const complete = holdings
|
||||
.filter(h => h.complete && byId.has(h.hostId))
|
||||
.sort((a, b) => b.completedAt - a.completedAt || a.hostId.localeCompare(b.hostId));
|
||||
|
||||
// Floor = N most-recent holders whose host is custody-eligible for this title.
|
||||
// Non-eligible holders (a roamer holding a TTL copy) still count as copies but
|
||||
// can't be *obligated* custodians.
|
||||
const custodians: string[] = [];
|
||||
for (const h of complete) {
|
||||
if (custodians.length >= floorN) break;
|
||||
const host = byId.get(h.hostId)!;
|
||||
if (eligibleFor(host, size) && !custodians.includes(h.hostId)) custodians.push(h.hostId);
|
||||
}
|
||||
|
||||
// Reserve ≥1 always-on slot: if the floor somehow holds no always-on host
|
||||
// (possible when eligibility was permissive on unknowns), promote the
|
||||
// newest always-on complete holder, displacing the oldest pick.
|
||||
const hasAlwaysOn = custodians.some(id => byId.get(id)!.alwaysOn);
|
||||
if (custodians.length > 0 && !hasAlwaysOn) {
|
||||
const candidate = complete.find(h => byId.get(h.hostId)!.alwaysOn && !custodians.includes(h.hostId));
|
||||
if (candidate) {
|
||||
custodians.pop();
|
||||
custodians.push(candidate.hostId);
|
||||
actions.push({
|
||||
kind: "promote_always_on",
|
||||
title,
|
||||
toHost: candidate.hostId,
|
||||
fromHost: null,
|
||||
reason: "floor had no always-on holder",
|
||||
});
|
||||
} else {
|
||||
warnings.push(`floor for "${title}" has no always-on holder and none is available`);
|
||||
}
|
||||
}
|
||||
|
||||
// Breach check: complete copies below the floor → re-pin to the best
|
||||
// eligible non-holder (always-on preferred, then most free disk) from a
|
||||
// surviving complete holder.
|
||||
const completeCopies = new Set(complete.map(h => h.hostId)).size;
|
||||
const breach = completeCopies < floorN;
|
||||
if (breach) {
|
||||
const holders = new Set(complete.map(h => h.hostId));
|
||||
const targets = hosts
|
||||
.filter(h => !holders.has(h.id) && eligibleFor(h, size))
|
||||
.sort((a, b) =>
|
||||
Number(b.alwaysOn) - Number(a.alwaysOn)
|
||||
|| (b.capacity.diskFreeBytes ?? 0) - (a.capacity.diskFreeBytes ?? 0)
|
||||
|| a.id.localeCompare(b.id));
|
||||
const source = complete[0]?.hostId ?? null;
|
||||
if (targets.length === 0) {
|
||||
warnings.push(
|
||||
`floor breach for "${title}" (${completeCopies}/${floorN}) and no eligible re-pin target`,
|
||||
);
|
||||
} else {
|
||||
// One action per missing copy, spread across distinct targets.
|
||||
const missing = floorN - completeCopies;
|
||||
for (const target of targets.slice(0, missing)) {
|
||||
actions.push({
|
||||
kind: "repin",
|
||||
title,
|
||||
toHost: target.id,
|
||||
fromHost: source,
|
||||
reason: source
|
||||
? `copies ${completeCopies}/${floorN} — replicate from ${source} before the last copy vanishes`
|
||||
: `copies ${completeCopies}/${floorN} and NO surviving complete copy — reaper must re-source`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { title, custodians, completeCopies, floorCopies: floorN, breach, actions, warnings };
|
||||
}
|
||||
|
||||
/** Group holdings by title and floor-check each. */
|
||||
export function floorCheck(holdings: Holding[], hosts: FleetHost[], floorN: number): FloorReport[] {
|
||||
const byTitle = new Map<string, Holding[]>();
|
||||
for (const h of holdings) {
|
||||
const list = byTitle.get(h.title) ?? [];
|
||||
list.push(h);
|
||||
byTitle.set(h.title, list);
|
||||
}
|
||||
return [...byTitle.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([title, hs]) => floorForTitle(title, hs, hosts, floorN));
|
||||
}
|
||||
|
||||
/** Who is obligated to keep `title` alive — one of the two derived outputs. */
|
||||
export function custodiansOf(title: string, holdings: Holding[], hosts: FleetHost[], floorN: number): FleetHost[] {
|
||||
const report = floorForTitle(title, holdings.filter(h => h.title === title), hosts, floorN);
|
||||
const byId = new Map(hosts.map(h => [h.id, h]));
|
||||
return report.custodians.map(id => byId.get(id)!).filter(Boolean);
|
||||
}
|
||||
103
governor/src/fleet/duties.ts
Normal file
103
governor/src/fleet/duties.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// Duty assignment — deterministic, capability-driven, run on every registry
|
||||
// read (cheap and idempotent, so "on registry change" is satisfied by running
|
||||
// it each invocation/tick and diffing).
|
||||
//
|
||||
// Spec table (.project/history/20260608_fleet-manager-mesh-design.md):
|
||||
// broadcast public_ip && always_on exactly ONE; prefer seedbox > broadcast-node > server
|
||||
// f2f_relay always_on && reachable ∈ {wireguard, public_ip}
|
||||
// public_swarm_face prefer !on_home_ip seedbox FIRST; never a consumer;
|
||||
// never on_home_ip if an off-home option exists
|
||||
// custody_floor per-title (custody.ts) — here only host-level eligibility
|
||||
//
|
||||
// Invariants:
|
||||
// - a consumer NEVER receives any duty (checked first)
|
||||
// - every duty decision is stable: ties broken by host id, so the same
|
||||
// registry always yields the same assignment (no flapping).
|
||||
|
||||
import type { Duty, DutyAssignment, FleetHost } from "./types.ts";
|
||||
|
||||
/** Stable ordering helper: rank asc, then id asc. */
|
||||
function pickFirst(hosts: FleetHost[], rank: (h: FleetHost) => number): FleetHost | null {
|
||||
const sorted = [...hosts].sort((a, b) => rank(a) - rank(b) || a.id.localeCompare(b.id));
|
||||
return sorted[0] ?? null;
|
||||
}
|
||||
|
||||
/** Class preference for the broadcast duty: seedbox > dedicated broadcast node > server. */
|
||||
function broadcastRank(h: FleetHost): number {
|
||||
switch (h.class) {
|
||||
case "seedbox": return 0;
|
||||
case "broadcast": return 1;
|
||||
case "server": return 2;
|
||||
default: return 9;
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-level custody eligibility; the per-title disk check lives in custody.ts. */
|
||||
export function custodyEligible(h: FleetHost): boolean {
|
||||
return h.class !== "consumer" && h.alwaysOn;
|
||||
}
|
||||
|
||||
export function assignDuties(hosts: FleetHost[]): DutyAssignment {
|
||||
const duties = new Map<string, Duty[]>();
|
||||
const warnings: string[] = [];
|
||||
for (const h of hosts) duties.set(h.id, []);
|
||||
const give = (h: FleetHost, d: Duty): void => { duties.get(h.id)!.push(d); };
|
||||
|
||||
// Invariant 1: consumers are pure sinks. Filter once; nothing below sees them.
|
||||
const eligible = hosts.filter(h => h.class !== "consumer");
|
||||
|
||||
// broadcast — exactly one per fleet.
|
||||
const broadcastCandidates = eligible.filter(h => h.reachable === "public_ip" && h.alwaysOn);
|
||||
const broadcastHost = pickFirst(broadcastCandidates, broadcastRank);
|
||||
if (broadcastHost) {
|
||||
give(broadcastHost, "broadcast");
|
||||
} else {
|
||||
warnings.push(
|
||||
"no broadcast-eligible host (need public_ip && always_on) — F2F rendezvous, "
|
||||
+ "peer registry and the Discord bridge have no anchor",
|
||||
);
|
||||
}
|
||||
|
||||
// f2f_relay — the broadcast host plus any other always-on, mesh/public-reachable
|
||||
// non-roamer (the spec's "any other always-on server").
|
||||
for (const h of eligible) {
|
||||
const reachableEnough = h.reachable === "wireguard" || h.reachable === "public_ip";
|
||||
const isRelayClass = h.class === "server" || h.class === "seedbox" || h.class === "broadcast";
|
||||
if (h.alwaysOn && reachableEnough && (h.id === broadcastHost?.id || isRelayClass)) {
|
||||
give(h, "f2f_relay");
|
||||
}
|
||||
}
|
||||
|
||||
// public_swarm_face — one face; seedbox first, then any off-home host, and an
|
||||
// on-home host only when no off-home option exists (with a warning: the home
|
||||
// connection is what's being exposed).
|
||||
const faceCandidates = eligible.filter(h => h.alwaysOn);
|
||||
const offHome = faceCandidates.filter(h => !h.onHomeIp);
|
||||
const pool = offHome.length > 0 ? offHome : faceCandidates;
|
||||
const face = pickFirst(pool, h => (h.class === "seedbox" ? 0 : 1));
|
||||
if (face) {
|
||||
give(face, "public_swarm_face");
|
||||
if (face.onHomeIp) {
|
||||
warnings.push(
|
||||
`public_swarm_face assigned to ${face.id} which is on the home IP — `
|
||||
+ "public-swarm traffic exposes the home connection; add a seedbox/off-home node",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warnings.push("no always-on host available for public_swarm_face — fleet cannot touch public swarms");
|
||||
}
|
||||
|
||||
return { duties, warnings };
|
||||
}
|
||||
|
||||
/** Diff two assignments → human-readable change lines (for change-triggered logging). */
|
||||
export function diffDuties(prev: Map<string, Duty[]>, next: Map<string, Duty[]>): string[] {
|
||||
const lines: string[] = [];
|
||||
const ids = new Set([...prev.keys(), ...next.keys()]);
|
||||
for (const id of [...ids].sort()) {
|
||||
const a = (prev.get(id) ?? []).slice().sort().join(",") || "(none)";
|
||||
const b = (next.get(id) ?? []).slice().sort().join(",") || "(none)";
|
||||
if (a !== b) lines.push(`${id}: ${a} → ${b}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
182
governor/src/fleet/registry.ts
Normal file
182
governor/src/fleet/registry.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Registry ingest: join the app's device registry (~/.config/tv-anarchy/
|
||||
// devices.json — owned by the Swift app's DeviceConfig) with fleet-side facts
|
||||
// the app doesn't model (~/.config/tv-anarchy/fleet.json — owned by this
|
||||
// module) into FleetHost records the duty engine consumes.
|
||||
//
|
||||
// devices.json stays the single source of truth for WHAT devices exist;
|
||||
// fleet.json is additive — per-device overrides + sources + floor config —
|
||||
// and absence of either file degrades gracefully (empty registry / defaults).
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
FleetHost, HostApi, HostCapacity, HostClass, Reachability, Source,
|
||||
} from "./types.ts";
|
||||
import { normalizeSource } from "./peers.ts";
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".config", "tv-anarchy");
|
||||
export const DEVICES_PATH = join(CONFIG_DIR, "devices.json");
|
||||
export const FLEET_PATH = join(CONFIG_DIR, "fleet.json");
|
||||
|
||||
/** Default replication floor: every wanted title keeps ≥2 complete copies. */
|
||||
export const DEFAULT_FLOOR_COPIES = 2;
|
||||
|
||||
// --- raw schemas ------------------------------------------------------------
|
||||
|
||||
/** Subset of the app's DeviceConfig schema this module reads. */
|
||||
interface RawDevice {
|
||||
id?: string;
|
||||
name?: string;
|
||||
kind?: string; // vlc | mpv-ipc | quicktime | blacktv(legacy)
|
||||
type?: string; // cellphone | laptop | storage | seed | broadcast
|
||||
ssh?: string; // user@host
|
||||
mpv?: { endpoints?: string[] };
|
||||
}
|
||||
|
||||
interface RawDevicesFile {
|
||||
devices?: RawDevice[];
|
||||
hosts?: RawDevice[]; // legacy key, same shape
|
||||
}
|
||||
|
||||
/** Per-device fleet overrides; every field optional — defaults are derived. */
|
||||
export interface FleetDeviceOverride {
|
||||
alwaysOn?: boolean;
|
||||
onHomeIp?: boolean;
|
||||
reachable?: Reachability;
|
||||
api?: HostApi;
|
||||
addr?: string;
|
||||
ssh?: string;
|
||||
diskFreeBytes?: number;
|
||||
upBwKbs?: number;
|
||||
uptimeScore?: number;
|
||||
}
|
||||
|
||||
export interface FleetFile {
|
||||
floorCopies?: number;
|
||||
devices?: Record<string, FleetDeviceOverride>;
|
||||
sources?: Array<Partial<Source> & { id: string; kind: Source["kind"] }>;
|
||||
/**
|
||||
* Static holdings for hosts without a torrent-client API (e.g. an apricot
|
||||
* mirror dir): hostId → list of title names known complete there.
|
||||
*/
|
||||
staticHoldings?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface FleetRegistry {
|
||||
hosts: FleetHost[];
|
||||
sources: Source[];
|
||||
floorCopies: number;
|
||||
staticHoldings: Record<string, string[]>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// --- derivation -------------------------------------------------------------
|
||||
|
||||
/** DeviceType → fleet class, mirroring DeviceType.fleetClass in DeviceConfig.swift. */
|
||||
const TYPE_TO_CLASS: Record<string, HostClass> = {
|
||||
cellphone: "consumer",
|
||||
laptop: "roamer",
|
||||
storage: "server",
|
||||
seed: "seedbox",
|
||||
broadcast: "broadcast",
|
||||
};
|
||||
|
||||
/** Legacy devices without a `type`: infer from the player backend, like the app does. */
|
||||
function inferClass(d: RawDevice): HostClass {
|
||||
if (d.type && TYPE_TO_CLASS[d.type]) return TYPE_TO_CLASS[d.type];
|
||||
switch (d.kind) {
|
||||
case "mpv-ipc":
|
||||
case "blacktv": return "server"; // black: streaming storage node
|
||||
default: return "roamer"; // vlc/quicktime = the laptop itself
|
||||
}
|
||||
}
|
||||
|
||||
/** user@host → host; passthrough when there's no user part. */
|
||||
function bareAddr(endpoint: string): string {
|
||||
const at = endpoint.lastIndexOf("@");
|
||||
return at >= 0 ? endpoint.slice(at + 1) : endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reachability heuristic when not overridden: an mpv/ssh endpoint on the
|
||||
* 10.9.* overlay means the host is mesh-reachable (WireGuard); anything else
|
||||
* defaults to the home LAN. `public_ip` is never inferred — it must be an
|
||||
* explicit override (claiming it wrongly would mis-assign `broadcast`).
|
||||
*/
|
||||
function inferReachability(d: RawDevice): Reachability {
|
||||
const endpoints = d.mpv?.endpoints ?? (d.ssh ? [d.ssh] : []);
|
||||
return endpoints.some(e => bareAddr(e).startsWith("10.9.")) ? "wireguard" : "home_lan";
|
||||
}
|
||||
|
||||
function deriveHost(d: RawDevice, ov: FleetDeviceOverride, warnings: string[]): FleetHost | null {
|
||||
if (!d.id) { warnings.push("device without id skipped"); return null; }
|
||||
const cls = inferClass(d);
|
||||
// Always-on default tracks the class: infrastructure classes are assumed on.
|
||||
const alwaysOn = ov.alwaysOn ?? (cls === "server" || cls === "seedbox" || cls === "broadcast");
|
||||
// Off-home default only for classes that exist to be off-home.
|
||||
const onHomeIp = ov.onHomeIp ?? !(cls === "seedbox" || cls === "broadcast");
|
||||
const sshDest = ov.ssh ?? d.ssh ?? d.mpv?.endpoints?.[0] ?? null;
|
||||
const capacity: HostCapacity = {
|
||||
diskFreeBytes: ov.diskFreeBytes ?? null,
|
||||
upBwKbs: ov.upBwKbs ?? null,
|
||||
uptimeScore: ov.uptimeScore ?? (alwaysOn ? 1 : 0.5),
|
||||
};
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name ?? d.id,
|
||||
class: cls,
|
||||
reachable: ov.reachable ?? inferReachability(d),
|
||||
alwaysOn,
|
||||
onHomeIp,
|
||||
api: ov.api ?? (cls === "server" ? "transmission_rpc" : "none"),
|
||||
addr: ov.addr ?? (sshDest ? bareAddr(sshDest) : null),
|
||||
ssh: sshDest,
|
||||
capacity,
|
||||
};
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T | null {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the registry from explicit file contents — pure, for tests.
|
||||
* `loadRegistry()` is the filesystem-bound wrapper.
|
||||
*/
|
||||
export function buildRegistry(devicesFile: RawDevicesFile | null, fleetFile: FleetFile | null): FleetRegistry {
|
||||
const warnings: string[] = [];
|
||||
if (devicesFile === null) warnings.push(`no device registry at ${DEVICES_PATH} — run the app once to seed it`);
|
||||
const rawDevices = devicesFile?.devices ?? devicesFile?.hosts ?? [];
|
||||
const overrides = fleetFile?.devices ?? {};
|
||||
const hosts: FleetHost[] = [];
|
||||
for (const d of rawDevices) {
|
||||
const host = deriveHost(d, (d.id && overrides[d.id]) || {}, warnings);
|
||||
if (host) hosts.push(host);
|
||||
}
|
||||
// Sources: user-configured + an implicit DHT/public source (public swarms
|
||||
// always exist for transmission-held torrents). Gates are enforced on every
|
||||
// source, configured or not.
|
||||
const configured = (fleetFile?.sources ?? []).map(normalizeSource);
|
||||
const sources: Source[] = configured.some(s => s.kind === "dht")
|
||||
? configured
|
||||
: [...configured, normalizeSource({ id: "dht", kind: "dht", label: "DHT/public swarm" })];
|
||||
const floorCopies = fleetFile?.floorCopies ?? DEFAULT_FLOOR_COPIES;
|
||||
if (floorCopies < 1) warnings.push(`floorCopies ${floorCopies} < 1 — clamped to 1`);
|
||||
return {
|
||||
hosts,
|
||||
sources,
|
||||
floorCopies: Math.max(1, floorCopies),
|
||||
staticHoldings: fleetFile?.staticHoldings ?? {},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadRegistry(): FleetRegistry {
|
||||
return buildRegistry(readJson<RawDevicesFile>(DEVICES_PATH), readJson<FleetFile>(FLEET_PATH));
|
||||
}
|
||||
151
governor/src/fleet/types.ts
Normal file
151
governor/src/fleet/types.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Fleet data model — the implemented core of the mesh design spec
|
||||
// (.project/history/20260608_fleet-manager-mesh-design.md). Single-fleet,
|
||||
// stage 1 + stage 3: registry, duty assignment, custody floor, zombie reaper,
|
||||
// peers_for over local sources. Friend-mesh / F2F / private-tracker stages are
|
||||
// design-only and intentionally absent here.
|
||||
|
||||
/** What a device IS — mirrors `DeviceType.fleetClass` in the app's DeviceConfig. */
|
||||
export type HostClass = "server" | "roamer" | "consumer" | "seedbox" | "broadcast";
|
||||
|
||||
/** How the fleet reaches a host. */
|
||||
export type Reachability = "home_lan" | "wireguard" | "public_ip";
|
||||
|
||||
/** Torrent-client API a host exposes (for custody/reaper actuation). */
|
||||
export type HostApi = "transmission_rpc" | "qbittorrent" | "utorrent_web" | "none";
|
||||
|
||||
/** What the manager tells a host to DO. Assigned, never hardcoded. */
|
||||
export type Duty = "custody_floor" | "public_swarm_face" | "f2f_relay" | "broadcast";
|
||||
|
||||
export interface HostCapacity {
|
||||
/** Bytes free on the media volume; null = unknown (treated permissively). */
|
||||
diskFreeBytes: number | null;
|
||||
/** Upload bandwidth in KB/s; null = unknown. */
|
||||
upBwKbs: number | null;
|
||||
/** Rolling uptime score ∈ [0,1]. Defaults: always-on 1.0, otherwise 0.5. */
|
||||
uptimeScore: number;
|
||||
}
|
||||
|
||||
/** A registry entry — the app's device joined with fleet-side facts. */
|
||||
export interface FleetHost {
|
||||
id: string;
|
||||
name: string;
|
||||
class: HostClass;
|
||||
reachable: Reachability;
|
||||
alwaysOn: boolean;
|
||||
/** true = public-swarm traffic from this host exposes the home connection. */
|
||||
onHomeIp: boolean;
|
||||
api: HostApi;
|
||||
/** Bare address (no user@) for peers_for; null if the registry can't derive one. */
|
||||
addr: string | null;
|
||||
/** ssh destination (user@host) for probes/actuation; null = not reachable via ssh. */
|
||||
ssh: string | null;
|
||||
capacity: HostCapacity;
|
||||
}
|
||||
|
||||
export interface DutyAssignment {
|
||||
/** hostId → duties. Hosts with no duties are present with an empty array. */
|
||||
duties: Map<string, Duty[]>;
|
||||
/** Human-readable invariant violations / degraded placements. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Peer-source model (stage 3) — sources are policy-bearing; the gates are the
|
||||
// load-bearing part (passkey blast-radius containment for private trackers).
|
||||
|
||||
export type SourceKind =
|
||||
| "dht" | "public_tracker" | "private_tracker"
|
||||
| "friend_mesh" | "fleet_host" | "seedbox";
|
||||
|
||||
export type SharePolicy = "search_only" | "content";
|
||||
export type SwarmIsolation = "f2f_only" | "open";
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
kind: SourceKind;
|
||||
sharePolicy: SharePolicy;
|
||||
swarmIsolation: SwarmIsolation;
|
||||
/** Display label for provenance UI; defaults to id. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ServedVia = "public" | "wireguard";
|
||||
|
||||
/** Provenance-tagged peer — the unit of the user-owned meta-tracker. */
|
||||
export interface Peer {
|
||||
addr: string;
|
||||
sourceKind: SourceKind;
|
||||
sourceId: string;
|
||||
servedVia: ServedVia;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custody floor + reaper
|
||||
|
||||
/** One host's copy of a title (complete or in progress). */
|
||||
export interface Holding {
|
||||
hostId: string;
|
||||
/** Torrent/display name the holding is known by. */
|
||||
title: string;
|
||||
/** v1/v2 infohash when known (transmission gives it); null for static holdings. */
|
||||
infohash: string | null;
|
||||
complete: boolean;
|
||||
/** Unix epoch seconds the copy completed (0/unknown sorts oldest). */
|
||||
completedAt: number;
|
||||
sizeBytes: number | null;
|
||||
}
|
||||
|
||||
export type RepinActionKind = "repin" | "promote_always_on";
|
||||
|
||||
export interface RepinAction {
|
||||
kind: RepinActionKind;
|
||||
title: string;
|
||||
/** Host that should acquire/keep the copy. */
|
||||
toHost: string;
|
||||
/** A surviving complete holder to source from; null = none (reaper territory). */
|
||||
fromHost: string | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FloorReport {
|
||||
title: string;
|
||||
/** Hosts currently obligated to keep the title alive (the floor). */
|
||||
custodians: string[];
|
||||
completeCopies: number;
|
||||
floorCopies: number;
|
||||
breach: boolean;
|
||||
actions: RepinAction[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type TorrentHealth = "healthy" | "stalled" | "dead";
|
||||
|
||||
/** The classifier's input — a narrow projection of transmission's torrent-get. */
|
||||
export interface TorrentVitals {
|
||||
name: string;
|
||||
infohash: string | null;
|
||||
/** 0..1 */
|
||||
percentDone: number;
|
||||
/** transmission status enum: 0 stopped … 4 downloading, 6 seeding. */
|
||||
status: number;
|
||||
/** transmission error code; non-zero = tracker/local error. */
|
||||
error: number;
|
||||
errorString: string;
|
||||
peersConnected: number;
|
||||
rateDownloadBps: number;
|
||||
rateUploadBps: number;
|
||||
/** Unix epoch seconds of last piece activity; 0 = never. */
|
||||
activityDate: number;
|
||||
addedDate: number;
|
||||
}
|
||||
|
||||
export type RecoveryKind = "mesh_recover" | "research" | "reannounce";
|
||||
|
||||
export interface ReaperVerdict {
|
||||
name: string;
|
||||
infohash: string | null;
|
||||
health: TorrentHealth;
|
||||
reason: string;
|
||||
/** Recovery proposal for stalled/dead torrents; null for healthy. */
|
||||
recovery: { kind: RecoveryKind; detail: string } | null;
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
# (scopes: write:repository). Required — the script will not guess.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
[ "$(uname -s)" = "Darwin" ] || { echo "✗ releases are cut on the macOS build box (xcodebuild); this is $(uname -s)." >&2; exit 1; }
|
||||
|
||||
API="${FORGEJO_API:-http://10.9.0.4:3000}" # overlay IP: mesh-stable (LAN addr flaps)
|
||||
|
||||
|
|
|
|||
166
tools/update.sh
166
tools/update.sh
|
|
@ -4,10 +4,24 @@
|
|||
# no-op when already current. This is how every node EXCEPT the build box (plum)
|
||||
# gets the app; plum cuts releases with tools/release.sh.
|
||||
#
|
||||
# Per-OS behavior (the fleet: iOS, macOS, Ubuntu, Bluefin, Windows):
|
||||
# macOS → TVAnarchy-<tag>.zip → /Applications (admin) or ~/Applications
|
||||
# Linux → TVAnarchy-<tag>-linux-<arch>.tar.gz → /opt/tv-anarchy (classic,
|
||||
# writable) or ~/.local/opt/tv-anarchy (non-root; always on
|
||||
# immutable/ostree systems like Bluefin, whose /usr is read-only)
|
||||
# Windows → TVAnarchy-<tag>-windows-<arch>.zip → %LOCALAPPDATA%\Programs\TVAnarchy
|
||||
# (per-user, no elevation; run under Git Bash/MSYS)
|
||||
# iOS → not served here: no shell. Install via Xcode (TVAnarchyiOS scheme)
|
||||
# or TestFlight/sideload — see docs/operations.md.
|
||||
# A release that lacks the asset for this platform fails with the exact missing
|
||||
# asset name (today only the macOS asset is published).
|
||||
#
|
||||
# Usage: tools/update.sh [--force]
|
||||
# Needs: curl, python3 (JSON parsing). macOS also: ditto, PlistBuddy (built in).
|
||||
# Config (env, all optional — defaults target the mesh Forgejo):
|
||||
# FORGEJO_API=http://10.9.0.4:3000 FORGEJO_OWNER=lilith FORGEJO_REPO=tv-anarchy
|
||||
# FORGEJO_TOKEN / ~/.config/tv-anarchy/forgejo-token (only if the repo is private)
|
||||
# TVANARCHY_DEST=<path> explicit install destination override
|
||||
set -euo pipefail
|
||||
|
||||
API="${FORGEJO_API:-http://10.9.0.4:3000}"
|
||||
|
|
@ -15,80 +29,136 @@ OWNER="${FORGEJO_OWNER:-lilith}"
|
|||
REPO="${FORGEJO_REPO:-tv-anarchy}"
|
||||
FORCE=""; [ "${1:-}" = "--force" ] && FORCE=1
|
||||
|
||||
# Where TVAnarchy.app installs. Duplicated from build-install.sh (kept in sync)
|
||||
# so this script stays curl-able standalone:
|
||||
# TVANARCHY_DEST env → explicit override, used verbatim
|
||||
# macOS, /Applications writable → /Applications (the standard location —
|
||||
# Finder's "Applications", admin group, no sudo)
|
||||
# macOS, not writable (non-admin)→ ~/Applications (Apple's per-user location)
|
||||
# anything else → fail loud; the .app bundle is macOS-only
|
||||
# --- platform --------------------------------------------------------------
|
||||
case "$(uname -s)" in
|
||||
Darwin) OS=mac ;;
|
||||
Linux) OS=linux ;;
|
||||
MINGW*|MSYS*|CYGWIN*) OS=windows ;;
|
||||
*) echo "✗ unsupported platform '$(uname -s)' — TVAnarchy ships for macOS, Linux, Windows (iOS via Xcode/TestFlight)." >&2; exit 1 ;;
|
||||
esac
|
||||
ARCH="$(uname -m)" # arm64 / x86_64 / aarch64
|
||||
|
||||
# True on image-based (ostree/bootc) Linux — Bluefin, Silverblue, etc. Their
|
||||
# /usr is read-only and /opt is machine-local; user-scope installs are the norm.
|
||||
is_immutable_linux() { [ -e /run/ostree-booted ] || [ -e /usr/lib/bootc ]; }
|
||||
|
||||
# Where TVAnarchy installs on THIS node. Mirrors build-install.sh's macOS logic
|
||||
# (kept in sync) so this script stays curl-able standalone.
|
||||
resolve_dest() {
|
||||
if [ -n "${TVANARCHY_DEST:-}" ]; then printf '%s\n' "$TVANARCHY_DEST"; return; fi
|
||||
[ "$(uname -s)" = "Darwin" ] || { echo "✗ TVAnarchy.app is macOS-only (this is $(uname -s))." >&2; return 1; }
|
||||
if [ -w /Applications ]; then
|
||||
printf '/Applications/TVAnarchy.app\n'
|
||||
else
|
||||
printf '%s/Applications/TVAnarchy.app\n' "$HOME"
|
||||
fi
|
||||
case "$OS" in
|
||||
mac)
|
||||
if [ -w /Applications ]; then printf '/Applications/TVAnarchy.app\n'
|
||||
else printf '%s/Applications/TVAnarchy.app\n' "$HOME"; fi ;;
|
||||
linux)
|
||||
if ! is_immutable_linux && [ -w /opt ]; then printf '/opt/tv-anarchy\n'
|
||||
else printf '%s/.local/opt/tv-anarchy\n' "$HOME"; fi ;;
|
||||
windows)
|
||||
printf '%s/Programs/TVAnarchy\n' "${LOCALAPPDATA:-$HOME/AppData/Local}" ;;
|
||||
esac
|
||||
}
|
||||
DEST="$(resolve_dest)"
|
||||
|
||||
# The release asset this platform consumes.
|
||||
asset_name() {
|
||||
case "$OS" in
|
||||
mac) printf 'TVAnarchy-%s.zip\n' "$1" ;;
|
||||
linux) printf 'TVAnarchy-%s-linux-%s.tar.gz\n' "$1" "$ARCH" ;;
|
||||
windows) printf 'TVAnarchy-%s-windows-%s.zip\n' "$1" "$ARCH" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
TOKEN="${FORGEJO_TOKEN:-}"
|
||||
TOKEN_FILE="$HOME/.config/tv-anarchy/forgejo-token"
|
||||
[ -z "$TOKEN" ] && [ -f "$TOKEN_FILE" ] && TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||
auth=(); [ -n "$TOKEN" ] && auth=(-H "Authorization: token $TOKEN")
|
||||
|
||||
# --- resolve the latest release -------------------------------------------
|
||||
latest="$(curl -fsSL "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest")" || {
|
||||
echo "✗ couldn't reach Forgejo at $API (mesh down, or repo private + no token?)." >&2; exit 1; }
|
||||
read -r TAG URL < <(printf '%s' "$latest" | python3 -c '
|
||||
# --- resolve the latest release ---------------------------------------------
|
||||
latest="$(curl -fsL "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest" 2>/dev/null)" || {
|
||||
code="$(curl -s -o /dev/null -m 8 -w '%{http_code}' "${auth[@]}" "$API/api/v1/repos/$OWNER/$REPO/releases/latest" || true)"
|
||||
if [ "$code" = "404" ]; then
|
||||
echo "✗ $OWNER/$REPO has no releases yet — cut one on the build box with tools/release.sh." >&2
|
||||
else
|
||||
echo "✗ couldn't reach Forgejo at $API (HTTP ${code:-none} — mesh down, or repo private + no token?)." >&2
|
||||
fi
|
||||
exit 1; }
|
||||
TAG="$(printf '%s' "$latest" | python3 -c 'import sys,json; print(json.load(sys.stdin)["tag_name"])')"
|
||||
WANT="$(asset_name "$TAG")"
|
||||
URL="$(printf '%s' "$latest" | python3 -c '
|
||||
import sys, json
|
||||
want = sys.argv[1]
|
||||
r = json.load(sys.stdin)
|
||||
asset = next((a for a in r.get("assets", []) if a["name"].endswith(".zip")), None)
|
||||
print(r["tag_name"], asset["browser_download_url"] if asset else "")
|
||||
')
|
||||
[ -n "$URL" ] || { echo "✗ release $TAG has no .zip asset." >&2; exit 1; }
|
||||
a = next((a for a in r.get("assets", []) if a["name"] == want), None)
|
||||
print(a["browser_download_url"] if a else "")
|
||||
' "$WANT")"
|
||||
if [ -z "$URL" ]; then
|
||||
echo "✗ release $TAG has no asset '$WANT' for this platform ($OS/$ARCH)." >&2
|
||||
echo " published assets:" >&2
|
||||
printf '%s' "$latest" | python3 -c 'import sys,json; [print(" ", a["name"]) for a in json.load(sys.stdin).get("assets", [])]' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- compare to the installed copy (releases are tagged v<marketing version>)
|
||||
installed="none"; stale=""
|
||||
if [ -d "$DEST" ]; then
|
||||
installed="v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$DEST/Contents/Info.plist" 2>/dev/null || echo '?')"
|
||||
elif [ -z "${TVANARCHY_DEST:-}" ]; then
|
||||
# No copy at the resolved location — an older install may sit at the other
|
||||
# auto candidate (the layout moved from ~/Applications to /Applications).
|
||||
# Count it for the version line and migrate it away after the install below.
|
||||
# Skipped under an explicit TVANARCHY_DEST (a test install must not touch it).
|
||||
# macOS reads the bundle plist; Linux/Windows read the .release-tag stamp this
|
||||
# script writes on install.
|
||||
installed_tag() {
|
||||
if [ "$OS" = mac ]; then
|
||||
[ -d "$1" ] && printf 'v%s\n' "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$1/Contents/Info.plist" 2>/dev/null || echo '?')"
|
||||
else
|
||||
[ -f "$1/.release-tag" ] && cat "$1/.release-tag"
|
||||
fi
|
||||
}
|
||||
installed="$(installed_tag "$DEST" || true)"; installed="${installed:-none}"; stale=""
|
||||
if [ "$installed" = "none" ] && [ "$OS" = mac ] && [ -z "${TVANARCHY_DEST:-}" ]; then
|
||||
# An older mac install may sit at the other auto candidate (the layout moved
|
||||
# from ~/Applications to /Applications). Count it for the version line and
|
||||
# migrate it away after the install. Skipped under an explicit TVANARCHY_DEST
|
||||
# (a test install must not touch the real one).
|
||||
for other in "/Applications/TVAnarchy.app" "$HOME/Applications/TVAnarchy.app"; do
|
||||
if [ "$other" != "$DEST" ] && [ -d "$other" ]; then
|
||||
stale="$other"
|
||||
installed="v$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$other/Contents/Info.plist" 2>/dev/null || echo '?') (at $other)"
|
||||
stale="$other"; installed="$(installed_tag "$other" || true) (at $other)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ "$installed" = "$TAG" ] && [ -z "$FORCE" ]; then
|
||||
echo "✓ already on $TAG — up to date (use --force to reinstall)."; exit 0
|
||||
fi
|
||||
echo "→ $installed → $TAG"
|
||||
echo "→ $installed → $TAG ($OS/$ARCH)"
|
||||
|
||||
# --- download, unzip, swap in, de-quarantine ------------------------------
|
||||
# --- download, unpack, swap in ----------------------------------------------
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/tvanarchy-update.XXXXXX")"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
echo "→ downloading $TAG"
|
||||
curl -fsSL "${auth[@]}" -o "$TMP/app.zip" "$URL"
|
||||
ditto -x -k "$TMP/app.zip" "$TMP/unpacked"
|
||||
src="$(/usr/bin/find "$TMP/unpacked" -maxdepth 2 -name 'TVAnarchy.app' -print -quit)"
|
||||
[ -n "$src" ] || { echo "✗ no TVAnarchy.app inside the release zip." >&2; exit 1; }
|
||||
echo "→ downloading $WANT"
|
||||
curl -fsSL "${auth[@]}" -o "$TMP/asset" "$URL"
|
||||
|
||||
mkdir -p "$TMP/unpacked"
|
||||
case "$WANT" in
|
||||
*.tar.gz) tar -xzf "$TMP/asset" -C "$TMP/unpacked" ;;
|
||||
*.zip) if [ "$OS" = mac ]; then ditto -x -k "$TMP/asset" "$TMP/unpacked"
|
||||
else unzip -q "$TMP/asset" -d "$TMP/unpacked"; fi ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
rm -rf "$DEST"
|
||||
ditto "$src" "$DEST"
|
||||
# Unsigned build copied across machines → clear Gatekeeper quarantine so it opens.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
# One install location only: drop the old copy at the other candidate (if any)
|
||||
# so the launched app can never silently be a stale build.
|
||||
if [ -n "$stale" ]; then
|
||||
rm -rf "$stale" && echo " removed stale copy at $stale"
|
||||
fi
|
||||
case "$OS" in
|
||||
mac)
|
||||
src="$(/usr/bin/find "$TMP/unpacked" -maxdepth 2 -name 'TVAnarchy.app' -print -quit)"
|
||||
[ -n "$src" ] || { echo "✗ no TVAnarchy.app inside $WANT." >&2; exit 1; }
|
||||
rm -rf "$DEST"; ditto "$src" "$DEST"
|
||||
# Unsigned build copied across machines → clear Gatekeeper quarantine.
|
||||
xattr -dr com.apple.quarantine "$DEST" 2>/dev/null || true
|
||||
if [ -n "$stale" ]; then rm -rf "$stale" && echo " removed stale copy at $stale"; fi
|
||||
relaunch="quit any running TVAnarchy and relaunch to pick this up."
|
||||
;;
|
||||
linux|windows)
|
||||
# Convention: the archive holds a single top-level TVAnarchy/ directory.
|
||||
src="$(find "$TMP/unpacked" -maxdepth 1 -mindepth 1 -type d -print -quit)"
|
||||
[ -n "$src" ] || { echo "✗ no payload directory inside $WANT." >&2; exit 1; }
|
||||
rm -rf "$DEST"; mv "$src" "$DEST"
|
||||
printf '%s\n' "$TAG" > "$DEST/.release-tag"
|
||||
[ "$OS" = linux ] && chmod +x "$DEST"/tvanarchy* 2>/dev/null || true
|
||||
relaunch="restart TVAnarchy to pick this up."
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "✓ installed $TAG → $DEST"
|
||||
echo " quit any running TVAnarchy and relaunch to pick this up."
|
||||
echo " $relaunch"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue