Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/branch-office.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>.{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-<name>.{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
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/src/commands/office-flair-spoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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(
Expand All @@ -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 ---
Expand Down
36 changes: 35 additions & 1 deletion packages/cli/test/office-flair-spoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────
Expand All @@ -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("<string>/Users/exedev</string>"); // HOME env var
expect(plist).not.toContain(homedir()); // no leak of the local home
});
});

// ─── 4. Extended supervision manifest round-trip ──────────────────────────────
Expand Down
Loading