fix(sf): supervise dev reload path

This commit is contained in:
Mikael Hugo 2026-05-02 23:11:20 +02:00
parent ef82fbf2c6
commit c481ede338
4 changed files with 213 additions and 40 deletions

View file

@ -8,7 +8,12 @@ const {
rmSync,
writeFileSync,
} = require("node:fs");
const { dirname, join } = require("node:path");
const { dirname, join, resolve } = require("node:path");
const root = resolve(__dirname, "..");
const srcResources = join(root, "src", "resources");
const distResources = join(root, "dist", "resources");
const resourcesTsconfig = join(root, "tsconfig.resources.json");
function copyNonTsFiles(srcDir, destDir) {
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
@ -20,7 +25,12 @@ function copyNonTsFiles(srcDir, destDir) {
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
if (
entry.name.endsWith(".ts") ||
entry.name.endsWith(".tsx") ||
entry.name.endsWith(".js") ||
entry.name.endsWith(".jsx")
) {
continue;
}
@ -48,13 +58,14 @@ function copyNonTsFiles(srcDir, destDir) {
}
}
rmSync("dist/resources", { recursive: true, force: true });
rmSync(distResources, { recursive: true, force: true });
const tscBin = require.resolve("typescript/bin/tsc");
const compile = spawnSync(
process.execPath,
[tscBin, "--project", "tsconfig.resources.json"],
[tscBin, "--project", resourcesTsconfig],
{
cwd: root,
stdio: "inherit",
},
);
@ -63,8 +74,8 @@ if (compile.status !== 0) {
process.exit(compile.status ?? 1);
}
copyNonTsFiles("src/resources", "dist/resources");
copyNonTsFiles(srcResources, distResources);
writeFileSync(
join("dist", "resources", ".sf-resource-build-stamp"),
join(distResources, ".sf-resource-build-stamp"),
`${new Date().toISOString()}\n`,
);

View file

@ -1,7 +1,12 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import { resolve } from "node:path";
import {
existsSync,
readdirSync,
statSync,
} from "node:fs";
import { join, resolve } from "node:path";
const __dirname = import.meta.dirname;
const root = resolve(__dirname, "..");
@ -21,6 +26,63 @@ const resolveTsPath = resolve(
"tests",
"resolve-ts.mjs",
);
const WATCH_INTERVAL_MS = Number(process.env.SF_DEV_SERVER_WATCH_INTERVAL_MS ?? 2_000);
const RESTART_GRACE_MS = Number(process.env.SF_DEV_SERVER_RESTART_GRACE_MS ?? 5_000);
const passthroughArgs = process.argv.slice(2);
const oneShot = passthroughArgs.some((arg) =>
["--help", "-h", "--status", "--install", "--uninstall"].includes(arg),
);
const watchEnabled =
process.env.SF_DEV_SERVER_WATCH !== "0" && !oneShot && WATCH_INTERVAL_MS > 0;
const watchedRoots = [
resolve(root, "packages", "daemon", "src"),
resolve(root, "packages", "daemon", "package.json"),
resolve(root, "scripts", "dev-server.js"),
resolve(root, "scripts", "copy-resources.cjs"),
resolve(root, "scripts", "ensure-source-resources.cjs"),
resolve(root, "package.json"),
];
function newestMtimeMs(path) {
let latest = 0;
const stack = [path];
const skip = new Set(["dist", "node_modules", ".git", ".sf"]);
while (stack.length > 0) {
const current = stack.pop();
if (!current || !existsSync(current)) continue;
let stat;
try {
stat = statSync(current);
} catch {
continue;
}
latest = Math.max(latest, stat.mtimeMs);
if (!stat.isDirectory()) continue;
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (skip.has(entry.name)) continue;
stack.push(join(current, entry.name));
}
}
return latest;
}
function sourceEpoch() {
return Math.max(...watchedRoots.map(newestMtimeMs));
}
const resourceBuild = spawnSync(process.execPath, [ensureResourcesPath], {
cwd: root,
@ -32,40 +94,116 @@ if (resourceBuild.status !== 0) {
process.exit(resourceBuild.status ?? 1);
}
const child = spawn(
process.execPath,
[
"--import",
resolveTsPath,
"--experimental-strip-types",
"--no-warnings",
daemonCliPath,
...process.argv.slice(2),
],
{
cwd: process.cwd(),
stdio: "inherit",
env: {
...process.env,
SF_SOURCE_ROOT: process.env.SF_SOURCE_ROOT || root,
SF_RUNTIME_SOURCE_ROOT: process.env.SF_RUNTIME_SOURCE_ROOT || root,
SF_BIN_PATH: process.env.SF_BIN_PATH || resolve(root, "dist", "loader.js"),
SF_CLI_PATH: process.env.SF_CLI_PATH || sourceBinPath,
let child;
let stopping = false;
let restarting = false;
let currentEpoch = sourceEpoch();
let restartTimer;
function childEnv() {
return {
...process.env,
SF_SOURCE_ROOT: process.env.SF_SOURCE_ROOT || root,
SF_RUNTIME_SOURCE_ROOT: process.env.SF_RUNTIME_SOURCE_ROOT || root,
SF_BIN_PATH: process.env.SF_BIN_PATH || resolve(root, "dist", "loader.js"),
SF_CLI_PATH: process.env.SF_CLI_PATH || sourceBinPath,
};
}
function spawnDaemon() {
child = spawn(
process.execPath,
[
"--import",
resolveTsPath,
"--experimental-strip-types",
"--no-warnings",
daemonCliPath,
...passthroughArgs,
],
{
cwd: process.cwd(),
stdio: "inherit",
env: childEnv(),
},
},
);
child.on("error", (error) => {
console.error(
`[forge] Failed to launch local dev server: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
child.on("error", (error) => {
console.error(
`[forge] Failed to launch local dev server: ${error instanceof Error ? error.message : String(error)}`,
);
if (!watchEnabled) process.exit(1);
});
child.on("exit", (code, signal) => {
child = undefined;
if (stopping) {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
}
if (restarting) {
restarting = false;
spawnDaemon();
return;
}
if (!watchEnabled) {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
}
console.error(
`[forge] sf-server exited (${signal ?? `code ${code ?? 0}`}); restarting in 2s...`,
);
setTimeout(spawnDaemon, 2_000);
});
}
function requestRestart(reason) {
if (stopping || restarting) return;
restarting = true;
console.error(`[forge] ${reason}; restarting sf-server dev child...`);
if (!child || child.killed) {
restarting = false;
spawnDaemon();
return;
}
process.exit(code ?? 0);
});
const victim = child;
victim.kill("SIGTERM");
restartTimer = setTimeout(() => {
if (victim.exitCode == null && victim.signalCode == null) {
victim.kill("SIGKILL");
}
}, RESTART_GRACE_MS);
}
function stop(signal) {
stopping = true;
if (restartTimer) clearTimeout(restartTimer);
if (!child || child.killed) {
process.exit(0);
return;
}
child.kill(signal);
}
process.on("SIGINT", () => stop("SIGINT"));
process.on("SIGTERM", () => stop("SIGTERM"));
spawnDaemon();
if (watchEnabled) {
setInterval(() => {
const nextEpoch = sourceEpoch();
if (nextEpoch <= currentEpoch) return;
currentEpoch = nextEpoch;
requestRestart("daemon/dev source changed");
}, WATCH_INTERVAL_MS);
}

View file

@ -27,7 +27,7 @@ type CompletionMap = Record<string, readonly SfCommandDefinition[]>;
* Comprehensive description of all available SF commands for help text.
*/
export const SF_COMMAND_DESCRIPTION =
"SF — Singularity Forge: /sf help|start|templates|next|autonomous|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|eval-review";
"SF — Singularity Forge: /sf help|start|templates|next|autonomous|stop|pause|reload|status|widget|visualize|queue|quick|discuss|capture|triage|todo|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|harness|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan|scaffold|eval-review";
/**
* Top-level SF subcommands with descriptions.
@ -44,6 +44,10 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
cmd: "pause",
desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)",
},
{
cmd: "reload",
desc: "Reload extensions, skills, prompts, and themes in the TUI",
},
{ cmd: "status", desc: "Progress dashboard" },
{ cmd: "widget", desc: "Cycle widget: full → small → min → off" },
{

View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, test } from "vitest";
const source = readFileSync(join(process.cwd(), "scripts/copy-resources.cjs"), "utf-8");
describe("copy-resources dev runtime contract", () => {
test("copy_resources_when_run_from_other_cwd_uses_repo_root_paths", () => {
assert.match(source, /const root = resolve\(__dirname, "\.\."\)/);
assert.match(source, /rmSync\(distResources/);
assert.match(source, /cwd: root/);
assert.match(source, /copyNonTsFiles\(srcResources, distResources\)/);
});
test("copy_resources_when_source_js_exists_does_not_overwrite_compiled_output", () => {
assert.match(source, /entry\.name\.endsWith\("\.js"\)/);
assert.match(source, /entry\.name\.endsWith\("\.jsx"\)/);
});
});