refactor(sf): separate daemon from server identity

This commit is contained in:
Mikael Hugo 2026-05-17 19:18:33 +02:00
parent 187d736930
commit 3adcb833ed
14 changed files with 57 additions and 54 deletions

View file

@ -43,10 +43,10 @@ belong behind a future federation requirement, not M053.
## Registry Sync
`sf-server` owns swarm registry refresh. It scans configured
`sf-daemon` owns swarm registry refresh. It scans configured
`projects.scan_roots` from `~/.sf/daemon.yaml` and atomically rewrites
`~/.sf/swarms.json` on startup and every `swarms.refresh_ms` while the server is
running. Operators can run a one-shot refresh with `sf-server --sync-swarms`.
running. Operators can run a one-shot refresh with `sf-daemon --sync-swarms`.
This replaces the old script/watchdog shape. There is no repo-local enrollment
script and no nested meta-supervisor. The registry is a server-owned read model:
@ -77,8 +77,8 @@ M053 is done when:
- The non-TUI status query writes the versioned atomic status projection.
- Web has a Swarms view backed by `/api/swarms`.
- The web reader survives missing/corrupt projections per repo.
- `sf-server` auto-syncs `~/.sf/swarms.json` from configured scan roots and can
run the same refresh once with `sf-server --sync-swarms`.
- `sf-daemon` auto-syncs `~/.sf/swarms.json` from configured scan roots and can
run the same refresh once with `sf-daemon --sync-swarms`.
- Legacy script/watchdog entrypoints are removed from the normal lifecycle.
- Linux server/package code can create a user-level systemd worker for
`sf autonomous`.

6
package-lock.json generated
View file

@ -69,8 +69,7 @@
"bin": {
"sf": "dist/loader.js",
"sf-cli": "dist/loader.js",
"sf-daemon": "packages/daemon/dist/cli.js",
"sf-server": "packages/daemon/dist/cli.js"
"sf-daemon": "packages/daemon/dist/cli.js"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
@ -17542,8 +17541,7 @@
"zod": "^4.4.3"
},
"bin": {
"sf-daemon": "dist/cli.js",
"sf-server": "dist/cli.js"
"sf-daemon": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^25.6.2",

View file

@ -18,8 +18,7 @@
"bin": {
"sf": "dist/loader.js",
"sf-cli": "dist/loader.js",
"sf-daemon": "packages/daemon/dist/cli.js",
"sf-server": "packages/daemon/dist/cli.js"
"sf-daemon": "packages/daemon/dist/cli.js"
},
"files": [
"dist",
@ -79,10 +78,12 @@
"build:native:dev": "node rust-engine/scripts/build.js --dev",
"dev": "node scripts/dev.js",
"sf": "node scripts/dev-cli.js",
"sf-dev": "node scripts/dev-server.js --verbose --start .",
"sf-dev": "node scripts/dev-cli.js server",
"sf:dev": "npm run sf-dev",
"sf:server": "node scripts/dev-server.js",
"sf:server:dist": "node packages/daemon/dist/cli.js",
"sf:server": "node scripts/dev-cli.js server",
"sf:server:dist": "node dist/loader.js server",
"sf:daemon": "node scripts/dev-server.js",
"sf:daemon:dist": "node packages/daemon/dist/cli.js",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/ensure-workspace-builds.cjs && node scripts/postinstall.js",
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",

View file

@ -21,8 +21,7 @@
}
},
"bin": {
"sf-daemon": "./dist/cli.js",
"sf-server": "./dist/cli.js"
"sf-daemon": "./dist/cli.js"
},
"scripts": {
"build": "tsgo",

View file

@ -8,17 +8,15 @@ import { Logger } from "./logger.js";
import { scanForProjects } from "./project-scanner.js";
import { syncSwarmRegistryFromProjects } from "./swarm-registry.js";
export const COMMAND_NAME = "sf-server";
export const COMMAND_NAME = "sf-daemon";
export const USAGE = `Usage: sf-server [options]
Alias: sf-daemon
export const USAGE = `Usage: sf-daemon [options]
Options:
--config <path> Path to YAML config file (default: ~/.sf/daemon.yaml)
--verbose Lower log level to debug AND mirror entries to stderr
--sync-swarms One-shot SF server swarm registry refresh, then exit
Normal sf-server runs auto-sync by default; set swarms.auto_start=true
Normal sf-daemon runs auto-sync by default; set swarms.auto_start=true
to also start missing clients during daemon sync
--start <path> Start an autonomous SF session for this project path
--command <text> Command to send for --start (default: /sf autonomous)

View file

@ -661,8 +661,7 @@ describe("CLI integration", () => {
[resolveCliPath()!, "--help"],
{ encoding: "utf-8", timeout: 5000 },
);
assert.ok(result.includes("Usage: sf-server"));
assert.ok(result.includes("Alias: sf-daemon"));
assert.ok(result.includes("Usage: sf-daemon"));
assert.ok(result.includes("--config"));
assert.ok(result.includes("--verbose"));
assert.ok(result.includes("--start"));

View file

@ -253,7 +253,7 @@ export class Daemon {
/**
* Start missing daemon-managed autonomous clients for discovered repos.
*
* Purpose: let sf-server reconcile both the SF server swarm registry and the
* Purpose: let sf-daemon reconcile both the SF server swarm registry and the
* corresponding per-repo autonomous clients from one configured scan root.
*
* Consumer: syncSwarms() when `swarms.auto_start` is enabled.

View file

@ -30,7 +30,7 @@ export interface SwarmRegistryEntry {
}
/**
* Stores the full swarm registry document written by sf-server.
* Stores the full swarm registry document written by sf-daemon.
*
* Purpose: version the SF server's cross-repo discovery file so future schema
* changes can degrade cleanly instead of breaking the dashboard.
@ -49,7 +49,7 @@ export interface SwarmRegistry {
* Purpose: let the daemon log and CLI report what the server scan changed
* without exposing the full registry document.
*
* Consumer: Daemon.syncSwarms() and `sf-server --sync-swarms`.
* Consumer: Daemon.syncSwarms() and `sf-daemon --sync-swarms`.
*/
export interface SyncSwarmRegistryResult {
registryPath: string;

View file

@ -1,10 +1,5 @@
import assert from "node:assert/strict";
import {
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, it } from "vitest";
@ -100,13 +95,13 @@ describe("generateUnit", () => {
// ---------- getServicePath ----------
describe("getServicePath", () => {
it("returns ~/.config/systemd/user/sf-server.service", () => {
it("returns ~/.config/systemd/user/sf-daemon.service", () => {
const expected = resolve(
homedir(),
".config",
"systemd",
"user",
"sf-server.service",
"sf-daemon.service",
);
assert.equal(getServicePath(), expected);
});
@ -114,7 +109,7 @@ describe("getServicePath", () => {
it("honors SF_SYSTEMD_USER_DIR for tests and sandboxed installs", () => {
const dir = fakeSystemdDir();
assert.equal(getServicePath(), join(dir, "sf-server.service"));
assert.equal(getServicePath(), join(dir, "sf-daemon.service"));
});
});
@ -147,7 +142,7 @@ describe("install", () => {
it("disables existing unit before reinstalling", () => {
const tmpDir = fakeSystemdDir();
writeFileSync(join(tmpDir, "sf-server.service"), "[Service]\n", "utf-8");
writeFileSync(join(tmpDir, "sf-daemon.service"), "[Service]\n", "utf-8");
const calls: string[] = [];
const mockRun: RunCommandFn = (cmd: string) => {
@ -157,9 +152,9 @@ describe("install", () => {
install(baseUnitOpts(), mockRun);
assert.ok(calls.some((c) => c.includes("disable --now sf-server.service")));
assert.ok(calls.some((c) => c.includes("disable --now sf-daemon.service")));
assert.ok(calls.some((c) => c.includes("daemon-reload")));
assert.ok(calls.some((c) => c.includes("enable --now sf-server.service")));
assert.ok(calls.some((c) => c.includes("enable --now sf-daemon.service")));
});
});
@ -180,10 +175,10 @@ describe("uninstall", () => {
it("handles already-stopped service gracefully", () => {
const dir = fakeSystemdDir();
writeFileSync(join(dir, "sf-server.service"), "[Service]\n", "utf-8");
writeFileSync(join(dir, "sf-daemon.service"), "[Service]\n", "utf-8");
const mockRun: RunCommandFn = (cmd: string) => {
if (cmd.includes("disable --now")) {
throw new Error("Failed to stop sf-server.service: Unit not loaded.");
throw new Error("Failed to stop sf-daemon.service: Unit not loaded.");
}
return "";
};
@ -194,7 +189,7 @@ describe("uninstall", () => {
it("calls daemon-reload after removing unit file (when file exists)", () => {
const dir = fakeSystemdDir();
writeFileSync(join(dir, "sf-server.service"), "[Service]\n", "utf-8");
writeFileSync(join(dir, "sf-daemon.service"), "[Service]\n", "utf-8");
const calls: string[] = [];
const mockRun: RunCommandFn = (cmd: string) => {
calls.push(cmd);
@ -202,7 +197,7 @@ describe("uninstall", () => {
};
uninstall(mockRun);
assert.ok(calls.some((c) => c.includes("disable --now sf-server.service")));
assert.ok(calls.some((c) => c.includes("disable --now sf-daemon.service")));
assert.ok(calls.some((c) => c.includes("daemon-reload")));
});
});
@ -286,7 +281,7 @@ describe("status", () => {
status(mockRun);
assert.equal(cmds.length, 1);
assert.ok(cmds[0].includes("systemctl --user show sf-server.service"));
assert.ok(cmds[0].includes("systemctl --user show sf-daemon.service"));
assert.ok(cmds[0].includes("MainPID"));
assert.ok(cmds[0].includes("ExecMainStatus"));
assert.ok(cmds[0].includes("LoadState"));

View file

@ -27,7 +27,7 @@ export type RunCommandFn = (cmd: string) => string;
// --------------- constants ---------------
const SERVICE_NAME = "sf-server.service";
const SERVICE_NAME = "sf-daemon.service";
// --------------- helpers ---------------
@ -41,14 +41,14 @@ export function getServicePath(): string {
// --------------- unit file generation ---------------
/** Generate a systemd user unit file for the SF server. */
/** Generate a systemd user unit file for the SF daemon supervisor. */
export function generateUnit(opts: SystemdUnitOptions): string {
const home = homedir();
const stdoutPath = resolve(home, ".sf", "daemon-stdout.log");
const stderrPath = resolve(home, ".sf", "daemon-stderr.log");
return `[Unit]
Description=SF Server (singularity-forge operator entrypoint)
Description=SF Daemon (singularity-forge background supervisor)
After=network.target
[Service]
@ -127,7 +127,7 @@ export function uninstall(runCommand: RunCommandFn = defaultRunCommand): void {
* Returns structured information about registration, PID, and last exit code.
*
* Parses output from:
* systemctl --user show sf-server.service --property MainPID,ExecMainStatus,LoadState
* systemctl --user show sf-daemon.service --property MainPID,ExecMainStatus,LoadState
*/
export function status(
runCommand: RunCommandFn = defaultRunCommand,

View file

@ -245,7 +245,7 @@ try {
process.exit(1);
}
console.log("==> Running installed daemon binary (sf-server --help)...");
console.log("==> Running installed daemon binary (sf-daemon --help)...");
try {
const helpOutput = execFileSync(
process.execPath,
@ -258,12 +258,12 @@ try {
maxBuffer: DEFAULT_MAX_BUFFER,
},
);
if (!helpOutput.includes("Usage: sf-server")) {
console.log("ERROR: sf-server --help returned unexpected output.");
if (!helpOutput.includes("Usage: sf-daemon")) {
console.log("ERROR: sf-daemon --help returned unexpected output.");
process.exit(1);
}
} catch (err) {
console.log("ERROR: Running sf-server --help failed after install.");
console.log("ERROR: Running sf-daemon --help failed after install.");
if (err.stdout) console.log(err.stdout);
if (err.stderr) console.log(err.stderr);
process.exit(1);

View file

@ -45,7 +45,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
"Start the full SF operator server for a project.",
"",
"The server owns the browser surface and daemon-backed project operations.",
"sf-server keeps the swarm registry synced from configured scan roots by",
"sf-daemon keeps the swarm registry synced from configured scan roots by",
"default. Set swarms.auto_start=true to also start missing per-repo",
"autonomous clients.",
"",

View file

@ -35,9 +35,17 @@ test("package hooks declare a concrete staged web host", () => {
"npm --prefix web run build && npm run stage:web-host",
);
assert.equal(rootPackage.scripts["sf"], "node scripts/dev-cli.js");
assert.equal(rootPackage.scripts["sf:server"], "node scripts/dev-server.js");
assert.equal(
rootPackage.scripts["sf:server"],
"node scripts/dev-cli.js server",
);
assert.equal(
rootPackage.scripts["sf:server:dist"],
"node dist/loader.js server",
);
assert.equal(rootPackage.scripts["sf:daemon"], "node scripts/dev-server.js");
assert.equal(
rootPackage.scripts["sf:daemon:dist"],
"node packages/daemon/dist/cli.js",
);
assert.equal(rootPackage.scripts["sf:web"], undefined);

View file

@ -8,13 +8,18 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
scripts?: Record<string, string>;
};
test("sf:server starts the daemon-backed development server", () => {
test("sf:server starts the source web server surface", () => {
const script = packageJson.scripts?.["sf:server"];
assert.ok(script, "package.json must define a sf:server script");
assert.match(
script,
/node scripts\/dev-cli\.js server/,
"sf:server must run the real sf server entrypoint",
);
assert.match(
packageJson.scripts?.["sf:daemon"] ?? "",
/node scripts\/dev-server\.js/,
"sf:server must run the daemon-backed server entrypoint",
"sf:daemon must own the background daemon dev entrypoint",
);
assert.equal(packageJson.scripts?.["sf:web"], undefined);
});