diff --git a/docs/branch-office.md b/docs/branch-office.md index 16f4897..c36df19 100644 --- a/docs/branch-office.md +++ b/docs/branch-office.md @@ -214,7 +214,7 @@ Harper runs on port 9926 (default Flair port) with its data at `~/.harper/flair` In spoke mode, after Flair is running, `tps office join` configures periodic memory federation from the branch back to the hub: 1. **Config:** Write `~/.tps/flair-sync.json` on the branch with `localUrl`, `remoteUrl` (hub), `agentId`, and the hub's `admin-pass` auth. -2. **Timer + service:** On Linux, install `~/.config/systemd/user/tps-fed-sync-.{service,timer}`. The timer triggers every 30s (with a 30s randomized delay to avoid thundering-herd). The service is `Type=oneshot` running `tps flair sync --once`. +2. **Timer + service:** On Linux, install `~/.config/systemd/user/tps-fed-sync-.{service,timer}`. The timer triggers every 5 minutes (`OnUnitActiveSec=300s`, with a 30s randomized delay to avoid thundering-herd; first fire at boot + 30s). The service is `Type=oneshot` running `tps flair sync --once`. Override via `configureFederation`'s `intervalSeconds` arg. 3. **Validate:** Run a one-shot sync immediately. Success writes the timestamp to the manifest; failure leaves the branch hub-less until the sync is working. ### Opt-outs and re-provisioning diff --git a/packages/cli/src/commands/office-flair-spoke.ts b/packages/cli/src/commands/office-flair-spoke.ts index d9f1bb5..880da3e 100644 --- a/packages/cli/src/commands/office-flair-spoke.ts +++ b/packages/cli/src/commands/office-flair-spoke.ts @@ -140,6 +140,23 @@ export function detectBranchOS(tunnelVia: string): BranchOS { } } +/** + * SSH to the remote branch and return its $HOME path. + * + * Required for macOS launchd plists since launchd doesn't expand `~` in + * path attributes — the plist needs the absolute remote home directory, + * not whatever `homedir()` returns locally. + */ +export function detectRemoteHome(tunnelVia: string): string { + const result = sshExec(tunnelVia, "printf '%s' \"$HOME\"", 10_000); + if (result.status !== 0 || !result.stdout) { + throw new Error( + `Failed to detect remote $HOME on ${tunnelVia}: ${result.stderr || `exit ${result.status}`}` + ); + } + return result.stdout; +} + // ─── Systemd unit generation ────────────────────────────────────────────────── /** @@ -237,6 +254,10 @@ WantedBy=multi-user.target /** * Generate a systemd timer that triggers the fed-sync service periodically. + * + * Uses `OnUnitActiveSec` so the actual cadence honors `intervalSeconds` + * (previous `OnCalendar=*:*:00/30` hardcode fired every 30s regardless + * of the parameter — see ops-r4dm). */ export function generateFedSyncTimer( timerName: string, @@ -248,7 +269,8 @@ Description=Periodic Flair fed-sync spoke→hub Requires=${serviceName}.service [Timer] -OnCalendar=*-*-* *:*:00/30 +OnBootSec=30s +OnUnitActiveSec=${intervalSeconds}s Persistent=true RandomizedDelaySec=30 @@ -481,11 +503,12 @@ export function installFlairSpoke( if (os === "macos") { // Launchd path const label = `ai.tpsdev.flair-${name}`; + const remoteHome = detectRemoteHome(tunnelVia); const plistContent = generateLaunchdFlairPlist( label, flairDirRemote, harperDataDirRemote, - homedir(), // remote's home + remoteHome, DEFAULT_FLAIR_PORT, ); const plistPath = `~/Library/LaunchAgents/${label}.plist`; @@ -671,7 +694,10 @@ export function teardownFlairSpoke( const ext = sup as ExtendedSupervisionManifest; // --- 1. Stop fed-sync units --- - if (ext.fedSync) { + // Fed-sync today only emits systemd units (configureFederation is Linux-only), + // so teardown is only meaningful when the branch ran Linux. Guard the + // systemctl calls so macOS teardowns don't shell out to a missing systemctl. + if (ext.fedSync && ext.flair?.os === "linux") { const fs = ext.fedSync; console.log(` 🛑 Stopping fed-sync: ${fs.timerName}`); sshExec( @@ -688,6 +714,9 @@ export function teardownFlairSpoke( // Remove sync config sshExec(tunnelVia, `rm -f ${fs.syncConfigPath} 2>/dev/null || true`, 10_000); console.log(" ✅ Fed-sync units removed"); + } else if (ext.fedSync) { + // Manifest had fed-sync but no Linux flair — best-effort remove the config file only + sshExec(tunnelVia, `rm -f ${ext.fedSync.syncConfigPath} 2>/dev/null || true`, 10_000); } // --- 2. Stop Flair unit --- diff --git a/packages/cli/test/office-flair-spoke.test.ts b/packages/cli/test/office-flair-spoke.test.ts index 130f491..a45552a 100644 --- a/packages/cli/test/office-flair-spoke.test.ts +++ b/packages/cli/test/office-flair-spoke.test.ts @@ -191,10 +191,26 @@ describe("generateFedSyncTimer", () => { test("generates valid timer unit", () => { const timer = generateFedSyncTimer("tps-fed-sync-reed", "tps-fed-sync-reed", 300); expect(timer).toContain("[Timer]"); - expect(timer).toContain("OnCalendar="); + expect(timer).toContain("OnUnitActiveSec=300s"); expect(timer).toContain("Persistent=true"); expect(timer).toContain("WantedBy=timers.target"); }); + + test("intervalSeconds is honored (ops-r4dm regression)", () => { + // Previous OnCalendar=*:*:00/30 hardcode fired every 30s regardless of param. + // Verify a non-default interval is interpolated. + const fast = generateFedSyncTimer("t", "s", 60); + expect(fast).toContain("OnUnitActiveSec=60s"); + expect(fast).not.toContain("OnUnitActiveSec=300s"); + + const slow = generateFedSyncTimer("t", "s", 3600); + expect(slow).toContain("OnUnitActiveSec=3600s"); + }); + + test("default cadence is 5 minutes when intervalSeconds omitted", () => { + const timer = generateFedSyncTimer("t", "s"); + expect(timer).toContain("OnUnitActiveSec=300s"); + }); }); // ─── 3. launchd plist generation ────────────────────────────────────────────── @@ -216,6 +232,24 @@ describe("generateLaunchdFlairPlist", () => { expect(plist).toContain("HARPER_SET_CONFIG"); expect(plist).toContain("/Users/testuser/.tps/logs/"); }); + + test("uses the remote home explicitly — not whatever happens to be passed (ops-r4dm)", () => { + // The macOS branch may have a different $HOME than rockit (e.g., + // local=/Users/squeued, remote=/Users/exedev). Caller is responsible + // for detecting + passing remote $HOME; the generator must use it + // verbatim for log paths + HOME env var. + const plist = generateLaunchdFlairPlist( + "ai.tpsdev.flair-test", + "~/.flair", + "~/.harper/flair", + "/Users/exedev", + 9926, + ); + expect(plist).toContain("/Users/exedev/.tps/logs/flair-ai.tpsdev.flair-test.log"); + expect(plist).toContain("/Users/exedev/.tps/logs/flair-ai.tpsdev.flair-test.error.log"); + expect(plist).toContain("/Users/exedev"); // HOME env var + expect(plist).not.toContain(homedir()); // no leak of the local home + }); }); // ─── 4. Extended supervision manifest round-trip ──────────────────────────────