fix(sf): supervise dev reload path
This commit is contained in:
parent
ef82fbf2c6
commit
c481ede338
4 changed files with 213 additions and 40 deletions
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
{
|
||||
|
|
|
|||
20
src/tests/copy-resources-contract.test.ts
Normal file
20
src/tests/copy-resources-contract.test.ts
Normal 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"\)/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue