From c481ede3382ead93826fab2ede14a951b4510526 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 23:11:20 +0200 Subject: [PATCH] fix(sf): supervise dev reload path --- scripts/copy-resources.cjs | 23 +- scripts/dev-server.js | 204 +++++++++++++++--- .../extensions/sf/commands/catalog.ts | 6 +- src/tests/copy-resources-contract.test.ts | 20 ++ 4 files changed, 213 insertions(+), 40 deletions(-) create mode 100644 src/tests/copy-resources-contract.test.ts diff --git a/scripts/copy-resources.cjs b/scripts/copy-resources.cjs index 33310d6c6..79a0e653e 100644 --- a/scripts/copy-resources.cjs +++ b/scripts/copy-resources.cjs @@ -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`, ); diff --git a/scripts/dev-server.js b/scripts/dev-server.js index d7d5f6a74..5692bd8e3 100644 --- a/scripts/dev-server.js +++ b/scripts/dev-server.js @@ -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); +} diff --git a/src/resources/extensions/sf/commands/catalog.ts b/src/resources/extensions/sf/commands/catalog.ts index d802e86c0..ef89063cc 100644 --- a/src/resources/extensions/sf/commands/catalog.ts +++ b/src/resources/extensions/sf/commands/catalog.ts @@ -27,7 +27,7 @@ type CompletionMap = Record; * 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" }, { diff --git a/src/tests/copy-resources-contract.test.ts b/src/tests/copy-resources-contract.test.ts new file mode 100644 index 000000000..b18ffb6ab --- /dev/null +++ b/src/tests/copy-resources-contract.test.ts @@ -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"\)/); + }); +});