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,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} = require("node:fs");
|
} = 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) {
|
function copyNonTsFiles(srcDir, destDir) {
|
||||||
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
||||||
|
|
@ -20,7 +25,12 @@ function copyNonTsFiles(srcDir, destDir) {
|
||||||
continue;
|
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;
|
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 tscBin = require.resolve("typescript/bin/tsc");
|
||||||
const compile = spawnSync(
|
const compile = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[tscBin, "--project", "tsconfig.resources.json"],
|
[tscBin, "--project", resourcesTsconfig],
|
||||||
{
|
{
|
||||||
|
cwd: root,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -63,8 +74,8 @@ if (compile.status !== 0) {
|
||||||
process.exit(compile.status ?? 1);
|
process.exit(compile.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyNonTsFiles("src/resources", "dist/resources");
|
copyNonTsFiles(srcResources, distResources);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join("dist", "resources", ".sf-resource-build-stamp"),
|
join(distResources, ".sf-resource-build-stamp"),
|
||||||
`${new Date().toISOString()}\n`,
|
`${new Date().toISOString()}\n`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
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 __dirname = import.meta.dirname;
|
||||||
const root = resolve(__dirname, "..");
|
const root = resolve(__dirname, "..");
|
||||||
|
|
@ -21,6 +26,63 @@ const resolveTsPath = resolve(
|
||||||
"tests",
|
"tests",
|
||||||
"resolve-ts.mjs",
|
"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], {
|
const resourceBuild = spawnSync(process.execPath, [ensureResourcesPath], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
|
|
@ -32,7 +94,24 @@ if (resourceBuild.status !== 0) {
|
||||||
process.exit(resourceBuild.status ?? 1);
|
process.exit(resourceBuild.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(
|
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,
|
process.execPath,
|
||||||
[
|
[
|
||||||
"--import",
|
"--import",
|
||||||
|
|
@ -40,18 +119,12 @@ const child = spawn(
|
||||||
"--experimental-strip-types",
|
"--experimental-strip-types",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
daemonCliPath,
|
daemonCliPath,
|
||||||
...process.argv.slice(2),
|
...passthroughArgs,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
env: {
|
env: childEnv(),
|
||||||
...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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -59,13 +132,78 @@ child.on("error", (error) => {
|
||||||
console.error(
|
console.error(
|
||||||
`[forge] Failed to launch local dev server: ${error instanceof Error ? error.message : String(error)}`,
|
`[forge] Failed to launch local dev server: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
if (!watchEnabled) process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
|
child = undefined;
|
||||||
|
if (stopping) {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
process.kill(process.pid, signal);
|
process.kill(process.pid, signal);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.exit(code ?? 0);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
* Comprehensive description of all available SF commands for help text.
|
||||||
*/
|
*/
|
||||||
export const SF_COMMAND_DESCRIPTION =
|
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.
|
* Top-level SF subcommands with descriptions.
|
||||||
|
|
@ -44,6 +44,10 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly SfCommandDefinition[] = [
|
||||||
cmd: "pause",
|
cmd: "pause",
|
||||||
desc: "Pause autonomous mode (preserves state, /sf autonomous to resume)",
|
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: "status", desc: "Progress dashboard" },
|
||||||
{ cmd: "widget", desc: "Cycle widget: full → small → min → off" },
|
{ 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