fix: reload sf source runtime on extension changes
This commit is contained in:
parent
343ee5c89e
commit
426fea7334
4 changed files with 170 additions and 6 deletions
|
|
@ -53,8 +53,23 @@ if [[ "$IS_HEADLESS" == "1" ]]; then
|
|||
echo "[forge] Launching source CLI..."
|
||||
fi
|
||||
|
||||
exec "$NODE_BIN" \
|
||||
--import "$SF_SOURCE_ROOT/src/resources/extensions/sf/tests/resolve-ts.mjs" \
|
||||
--experimental-strip-types \
|
||||
--no-warnings \
|
||||
"$SF_SOURCE_ROOT/src/loader.ts" "$@"
|
||||
ORIGINAL_ARGS=("$@")
|
||||
NEXT_ARGS=("${ORIGINAL_ARGS[@]}")
|
||||
while true; do
|
||||
set +e
|
||||
"$NODE_BIN" \
|
||||
--import "$SF_SOURCE_ROOT/src/resources/extensions/sf/tests/resolve-ts.mjs" \
|
||||
--experimental-strip-types \
|
||||
--no-warnings \
|
||||
"$SF_SOURCE_ROOT/src/loader.ts" "${NEXT_ARGS[@]}"
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$status" == "12" && "$IS_HEADLESS" != "1" && -t 0 && -t 1 ]]; then
|
||||
echo "[forge] Runtime reload requested — restarting source CLI with --continue..."
|
||||
NEXT_ARGS=("--continue")
|
||||
continue
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
done
|
||||
|
|
|
|||
|
|
@ -144,3 +144,92 @@ describe("ExtensionRunner UI compatibility", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExtensionRunner command conflicts", () => {
|
||||
it("registered_command_when_reserved_for_builtin_delegation_is_hidden_without_warning", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "runner-command-test-"));
|
||||
try {
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = new ModelRegistry(
|
||||
authStorage,
|
||||
join(dir, "models.json"),
|
||||
);
|
||||
const commands = new Map();
|
||||
commands.set("exit", {
|
||||
name: "exit",
|
||||
description: "Graceful extension exit",
|
||||
handler: async () => undefined,
|
||||
});
|
||||
const extension = {
|
||||
path: "/test/sf-ext",
|
||||
handlers: new Map(),
|
||||
commands,
|
||||
shortcuts: [],
|
||||
diagnostics: [],
|
||||
} as unknown as Extension;
|
||||
const runner = new ExtensionRunner(
|
||||
[extension],
|
||||
makeMinimalRuntime(),
|
||||
dir,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
|
||||
const commandsForAutocomplete = runner.getRegisteredCommands(
|
||||
new Set(["exit"]),
|
||||
new Set(["exit"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(commandsForAutocomplete, []);
|
||||
assert.deepEqual(runner.getCommandDiagnostics(), []);
|
||||
assert.equal(
|
||||
runner.getCommand("exit")?.description,
|
||||
"Graceful extension exit",
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("registered_command_when_reserved_without_delegation_emits_conflict_warning", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "runner-command-test-"));
|
||||
try {
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = new ModelRegistry(
|
||||
authStorage,
|
||||
join(dir, "models.json"),
|
||||
);
|
||||
const commands = new Map();
|
||||
commands.set("reload", {
|
||||
name: "reload",
|
||||
description: "Duplicate reload",
|
||||
handler: async () => undefined,
|
||||
});
|
||||
const extension = {
|
||||
path: "/test/duplicate-ext",
|
||||
handlers: new Map(),
|
||||
commands,
|
||||
shortcuts: [],
|
||||
diagnostics: [],
|
||||
} as unknown as Extension;
|
||||
const runner = new ExtensionRunner(
|
||||
[extension],
|
||||
makeMinimalRuntime(),
|
||||
dir,
|
||||
sessionManager,
|
||||
modelRegistry,
|
||||
);
|
||||
|
||||
assert.deepEqual(runner.getRegisteredCommands(new Set(["reload"])), []);
|
||||
assert.equal(runner.getCommandDiagnostics().length, 1);
|
||||
assert.match(
|
||||
runner.getCommandDiagnostics()[0]?.message ?? "",
|
||||
/conflicts with built-in commands/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -509,7 +509,10 @@ export class ExtensionRunner {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
|
||||
getRegisteredCommands(
|
||||
reserved?: Set<string>,
|
||||
delegatedReserved?: Set<string>,
|
||||
): RegisteredCommand[] {
|
||||
this.commandDiagnostics = [];
|
||||
|
||||
const commands: RegisteredCommand[] = [];
|
||||
|
|
@ -517,6 +520,9 @@ export class ExtensionRunner {
|
|||
for (const ext of this.extensions) {
|
||||
for (const command of ext.commands.values()) {
|
||||
if (reserved?.has(command.name)) {
|
||||
if (delegatedReserved?.has(command.name)) {
|
||||
continue;
|
||||
}
|
||||
const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`;
|
||||
this.commandDiagnostics.push({
|
||||
type: "warning",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as crypto from "node:crypto";
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { listDescendants } from "@singularity-forge/native";
|
||||
import type { AgentMessage } from "@singularity-forge/pi-agent-core";
|
||||
import type {
|
||||
|
|
@ -156,6 +157,47 @@ type CompactionQueuedMessage = {
|
|||
mode: "steer" | "followUp";
|
||||
};
|
||||
|
||||
const INTERACTIVE_RELOAD_EXIT_CODE = 12;
|
||||
|
||||
function firstExistingRuntimeFile(candidates: string[]): string | undefined {
|
||||
return candidates.find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash runtime modules that cannot be hot-swapped safely inside an active TUI.
|
||||
*
|
||||
* Purpose: distinguish plain resource reloads from package/runtime updates that
|
||||
* need a process restart so the next session uses fresh already-imported code.
|
||||
*
|
||||
* Consumer: handleReloadCommand() before it attempts an in-process reload.
|
||||
*/
|
||||
function computeInteractiveRuntimeFingerprint(): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const files = [
|
||||
firstExistingRuntimeFile([
|
||||
path.join(here, "interactive-mode.js"),
|
||||
path.join(here, "interactive-mode.ts"),
|
||||
]),
|
||||
firstExistingRuntimeFile([
|
||||
path.resolve(here, "../../core/extensions/runner.js"),
|
||||
path.resolve(here, "../../core/extensions/runner.ts"),
|
||||
]),
|
||||
firstExistingRuntimeFile([
|
||||
path.resolve(here, "slash-command-handlers.js"),
|
||||
path.resolve(here, "slash-command-handlers.ts"),
|
||||
]),
|
||||
].filter((file): file is string => Boolean(file));
|
||||
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files.sort()) {
|
||||
hash.update(path.relative(here, file));
|
||||
hash.update("\0");
|
||||
hash.update(fs.readFileSync(file));
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for InteractiveMode initialization.
|
||||
*/
|
||||
|
|
@ -202,6 +244,8 @@ export class InteractiveMode {
|
|||
private keybindings: KeybindingsManager;
|
||||
private version: string;
|
||||
private isInitialized = false;
|
||||
private readonly processRestartFingerprint =
|
||||
computeInteractiveRuntimeFingerprint();
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
private readonly defaultWorkingMessage = "Working...";
|
||||
|
|
@ -438,6 +482,7 @@ export class InteractiveMode {
|
|||
const extensionCommands: SlashCommand[] = (
|
||||
this.session.extensionRunner?.getRegisteredCommands(
|
||||
builtinCommandNames,
|
||||
new Set(["exit"]),
|
||||
) ?? []
|
||||
).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
|
|
@ -3707,6 +3752,15 @@ export class InteractiveMode {
|
|||
return;
|
||||
}
|
||||
|
||||
const currentFingerprint = computeInteractiveRuntimeFingerprint();
|
||||
if (currentFingerprint !== this.processRestartFingerprint) {
|
||||
this.showStatus(
|
||||
"Runtime changed on disk; restarting SF and resuming this session...",
|
||||
);
|
||||
this.ui.requestRender();
|
||||
process.exit(INTERACTIVE_RELOAD_EXIT_CODE);
|
||||
}
|
||||
|
||||
this.resetExtensionUI();
|
||||
|
||||
const loader = new BorderedLoader(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue