feat: harden sf server build and routing
This commit is contained in:
parent
3d5ce1a4bb
commit
0acb0f9be0
24 changed files with 796 additions and 94 deletions
251
flake.nix
251
flake.nix
|
|
@ -24,7 +24,257 @@
|
|||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
lib = pkgs.lib;
|
||||
nodejs26 = pkgs.stdenv.mkDerivation {
|
||||
pname = "nodejs";
|
||||
version = "26.1.0";
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://nodejs.org/dist/v26.1.0/node-v26.1.0-linux-x64.tar.xz";
|
||||
hash = "sha256-n8byG2xKYkOXJxI+UQ6cOf67L1Y3OPSSfNPgsojJs8k=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [pkgs.autoPatchelfHook];
|
||||
buildInputs = [pkgs.stdenv.cc.cc.lib];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out"
|
||||
cp -R . "$out/"
|
||||
ln -s "$out/bin/node" "$out/bin/nodejs"
|
||||
substituteInPlace "$out/lib/node_modules/npm/bin/npm-cli.js" \
|
||||
--replace-fail "#!/usr/bin/env node" "#!$out/bin/node"
|
||||
substituteInPlace "$out/lib/node_modules/npm/bin/npx-cli.js" \
|
||||
--replace-fail "#!/usr/bin/env node" "#!$out/bin/node"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru.python = pkgs.python3;
|
||||
};
|
||||
source = lib.cleanSourceWith {
|
||||
src = ./.;
|
||||
filter = path: type: let
|
||||
rel = lib.removePrefix "${toString ./.}/" (toString path);
|
||||
base = baseNameOf path;
|
||||
in
|
||||
!(lib.hasPrefix ".git/" rel)
|
||||
&& !(lib.hasPrefix "node_modules/" rel)
|
||||
&& !(lib.hasPrefix "web/node_modules/" rel)
|
||||
&& !(lib.hasPrefix "dist/" rel)
|
||||
&& !(lib.hasPrefix ".next/" rel)
|
||||
&& !(lib.hasPrefix "web/.next/" rel)
|
||||
&& !(lib.hasPrefix "rust-engine/target/" rel)
|
||||
&& base != ".direnv";
|
||||
};
|
||||
nativeNpmBuildInputs = with pkgs; [
|
||||
git
|
||||
libsecret
|
||||
makeWrapper
|
||||
pkg-config
|
||||
python3
|
||||
stdenv.cc
|
||||
];
|
||||
rootNodeModules = pkgs.importNpmLock.buildNodeModules {
|
||||
npmRoot = ./.;
|
||||
nodejs = nodejs26;
|
||||
derivationArgs = {
|
||||
nativeBuildInputs = nativeNpmBuildInputs;
|
||||
buildInputs = [pkgs.libsecret pkgs.zlib pkgs.stdenv.cc.cc.lib];
|
||||
npmInstallFlags = ["--ignore-scripts"];
|
||||
npm_config_ignore_scripts = "true";
|
||||
};
|
||||
};
|
||||
webNodeModules = pkgs.importNpmLock.buildNodeModules {
|
||||
npmRoot = ./web;
|
||||
nodejs = nodejs26;
|
||||
derivationArgs = {
|
||||
nativeBuildInputs = nativeNpmBuildInputs;
|
||||
buildInputs = [pkgs.libsecret pkgs.zlib pkgs.stdenv.cc.cc.lib];
|
||||
npmInstallFlags = ["--ignore-scripts"];
|
||||
npm_config_ignore_scripts = "true";
|
||||
};
|
||||
};
|
||||
sfServerRoot = pkgs.stdenv.mkDerivation {
|
||||
pname = "sf-server-root";
|
||||
version = "2.75.4";
|
||||
src = source;
|
||||
|
||||
nativeBuildInputs = nativeNpmBuildInputs ++ [nodejs26];
|
||||
buildInputs = [pkgs.libsecret pkgs.zlib pkgs.stdenv.cc.cc.lib];
|
||||
|
||||
SF_GIT_SHA = self.rev or self.dirtyRev or "dirty";
|
||||
SF_GIT_REF = self.ref or "main";
|
||||
SF_IMAGE_REPOSITORY = "registry.infra.centralcloud.com/singularity/sf-server";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
export CI=1
|
||||
export HOME="$TMPDIR/home"
|
||||
export npm_config_cache="$TMPDIR/npm-cache"
|
||||
mkdir -p "$HOME" "$npm_config_cache"
|
||||
mkdir -p node_modules
|
||||
ln -s ${rootNodeModules}/node_modules/.bin node_modules/.bin
|
||||
for entry in ${rootNodeModules}/node_modules/*; do
|
||||
name="$(basename "$entry")"
|
||||
if [ "$name" = "@singularity-forge" ]; then
|
||||
mkdir -p node_modules/@singularity-forge
|
||||
for scoped_entry in "$entry"/*; do
|
||||
ln -s "$scoped_entry" "node_modules/@singularity-forge/$(basename "$scoped_entry")"
|
||||
done
|
||||
else
|
||||
ln -s "$entry" "node_modules/$name"
|
||||
fi
|
||||
done
|
||||
ln -s ${webNodeModules}/node_modules web/node_modules
|
||||
node scripts/link-workspace-packages.cjs
|
||||
npm run build:core
|
||||
npm run build:web-host
|
||||
npm run release:manifest -- --out dist/sf-release-manifest.json
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
prune_runtime_node_modules() {
|
||||
local modules="$1"
|
||||
[ -d "$modules" ] || return 0
|
||||
chmod -R u+w "$modules"
|
||||
|
||||
# Production server target: Linux x64 glibc on Vega. Keep runtime
|
||||
# Linux x64 GNU native packages and remove optional packages for
|
||||
# other OS/CPU/libc targets that npm lockfiles fetch for portability.
|
||||
rm -rf \
|
||||
"$modules/playwright" \
|
||||
"$modules/playwright-core" \
|
||||
"$modules/@playwright" \
|
||||
"$modules/next/dist/experimental/testmode/playwright" \
|
||||
"$modules/next/experimental/testmode/playwright" \
|
||||
"$modules/node-pty/deps/winpty"
|
||||
rm -f \
|
||||
"$modules/.bin/playwright" \
|
||||
"$modules/.bin/playwright-core" \
|
||||
"$modules/next/experimental/testmode/playwright.js" \
|
||||
"$modules/next/experimental/testmode/playwright.d.ts" \
|
||||
"$modules/node-pty"/src/windows* \
|
||||
"$modules/node-pty"/lib/windows* \
|
||||
"$modules/@lydell/node-pty"/windows*
|
||||
|
||||
find "$modules" -mindepth 1 -maxdepth 8 -type d \( \
|
||||
-name "*android*" -o \
|
||||
-name "*darwin*" -o \
|
||||
-name "*freebsd*" -o \
|
||||
-name "*openbsd*" -o \
|
||||
-name "*win32*" -o \
|
||||
-name "*windows*" -o \
|
||||
-name "*linux_ia32*" -o \
|
||||
-name "*linux_arm*" -o \
|
||||
-name "*linux-arm*" -o \
|
||||
-name "*linux-arm64*" -o \
|
||||
-name "*linux_loong*" -o \
|
||||
-name "*linux-loong*" -o \
|
||||
-name "*linux_riscv*" -o \
|
||||
-name "*linux-riscv*" -o \
|
||||
-name "*linuxmusl*" -o \
|
||||
-name "*musl_x64*" -o \
|
||||
-name "*arm64*" -o \
|
||||
-name "*armhf*" -o \
|
||||
-name "*armv7*" -o \
|
||||
-name "*riscv*" -o \
|
||||
-name "*loong*" -o \
|
||||
-name "*ppc64*" -o \
|
||||
-name "*s390x*" -o \
|
||||
-name "*ia32*" -o \
|
||||
-name "*musl*" -o \
|
||||
-name "*wasm32*" \
|
||||
\) ! \( \
|
||||
-name "*linux-x64-gnu*" -o \
|
||||
-name "*linux_x64*" -o \
|
||||
-name "*linux-x64" -o \
|
||||
-name "linux-x64" \
|
||||
\) -prune -exec rm -rf {} +
|
||||
|
||||
find "$modules" -type d \( \
|
||||
-name ".cache" -o \
|
||||
-name "__tests__" -o \
|
||||
-name "test" -o \
|
||||
-name "tests" -o \
|
||||
-name "docs" -o \
|
||||
-name "examples" \
|
||||
\) -prune -exec rm -rf {} +
|
||||
}
|
||||
|
||||
mkdir -p "$out/opt/sf"
|
||||
cp package.json package-lock.json README.md "$out/opt/sf/"
|
||||
cp -R packages dist pkg src scripts rust-engine web "$out/opt/sf/"
|
||||
cp -R ${rootNodeModules}/node_modules "$out/opt/sf/node_modules"
|
||||
prune_runtime_node_modules "$out/opt/sf/node_modules"
|
||||
rm -rf \
|
||||
"$out/opt/sf/web/node_modules" \
|
||||
"$out/opt/sf/web/.next/cache" \
|
||||
"$out/opt/sf/web/.next/standalone"
|
||||
cp -R ${webNodeModules}/node_modules "$out/opt/sf/web/node_modules"
|
||||
prune_runtime_node_modules "$out/opt/sf/web/node_modules"
|
||||
if [ -d "$out/opt/sf/dist/web/standalone/node_modules/@singularity-forge" ]; then
|
||||
for pkg in "$out/opt/sf/dist/web/standalone/node_modules/@singularity-forge"/*; do
|
||||
[ -e "$pkg" ] || continue
|
||||
name="$(basename "$pkg")"
|
||||
source="$out/opt/sf/packages/$name"
|
||||
if [ -d "$source" ]; then
|
||||
rm -rf "$pkg"
|
||||
cp -R "$source" "$pkg"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
prune_runtime_node_modules "$out/opt/sf/dist/web/standalone/node_modules"
|
||||
find "$out/opt/sf" -name tsconfig.tsbuildinfo -delete
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
node26SlimBase = pkgs.dockerTools.pullImage {
|
||||
imageName = "node";
|
||||
imageDigest = "sha256:424cafd2a035ed2b2d74acc3142b68b426fb62a47742c80a75e7117db02d6b30";
|
||||
finalImageName = "node";
|
||||
finalImageTag = "26.1-slim";
|
||||
sha256 = lib.fakeSha256;
|
||||
};
|
||||
in {
|
||||
packages = {
|
||||
inherit nodejs26 rootNodeModules webNodeModules sfServerRoot;
|
||||
sf-server-image = pkgs.dockerTools.streamLayeredImage {
|
||||
name = "registry.infra.centralcloud.com/singularity/sf-server";
|
||||
tag = self.rev or self.dirtyRev or "dirty";
|
||||
fromImage = node26SlimBase;
|
||||
contents = [
|
||||
sfServerRoot
|
||||
pkgs.ca-certificates
|
||||
pkgs.git
|
||||
pkgs.libsecret
|
||||
pkgs.procps
|
||||
pkgs.tini
|
||||
];
|
||||
config = {
|
||||
WorkingDir = "/workspace";
|
||||
ExposedPorts = {
|
||||
"4000/tcp" = {};
|
||||
};
|
||||
Env = [
|
||||
"NODE_ENV=production"
|
||||
"SF_RELEASE_MANIFEST=/opt/sf/dist/sf-release-manifest.json"
|
||||
"SF_WEB_PACKAGE_ROOT=/opt/sf"
|
||||
"SF_WEB_PREFER_SOURCE=0"
|
||||
"SF_WEB_HOST=0.0.0.0"
|
||||
"SF_WEB_PORT=4000"
|
||||
"HOSTNAME=0.0.0.0"
|
||||
"PORT=4000"
|
||||
];
|
||||
Entrypoint = ["${pkgs.tini}/bin/tini" "--"];
|
||||
Cmd = ["node" "/opt/sf/dist/web/standalone/server.js"];
|
||||
};
|
||||
};
|
||||
default = sfServerRoot;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bash
|
||||
|
|
@ -40,6 +290,7 @@
|
|||
rustfmt
|
||||
uv
|
||||
zlib
|
||||
nodejs26
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
|
|
|||
|
|
@ -25,11 +25,30 @@ function expandTilde(p: string): string {
|
|||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return default repo scan roots for daemon-managed swarm registration.
|
||||
*
|
||||
* Purpose: let the k3s/web server discover repos from the same mounted
|
||||
* workspace root used by the project picker, so an empty daemon.yaml does not
|
||||
* leave the swarms dashboard permanently empty.
|
||||
*
|
||||
* Consumer: defaults() and validateConfig() when no explicit projects block is
|
||||
* configured.
|
||||
*/
|
||||
function defaultScanRoots(): string[] {
|
||||
const roots = new Set<string>();
|
||||
const workspacesDir = process.env["SF_WORKSPACES_DIR"];
|
||||
if (workspacesDir) roots.add(expandTilde(workspacesDir));
|
||||
const projectCwd = process.env["SF_WEB_PROJECT_CWD"];
|
||||
if (projectCwd) roots.add(resolve(expandTilde(projectCwd), ".."));
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
/** Default config values when no file is present or fields are missing. */
|
||||
function defaults(): DaemonConfig {
|
||||
return {
|
||||
discord: undefined,
|
||||
projects: { scan_roots: [] },
|
||||
projects: { scan_roots: defaultScanRoots() },
|
||||
swarms: DEFAULT_SWARMS_CONFIG,
|
||||
log: {
|
||||
file: resolve(homedir(), ".sf", "daemon.log"),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* Skipped in CI (where the full build pipeline handles this) and when
|
||||
* installing as an end-user dependency (no packages/ directory).
|
||||
*/
|
||||
const { existsSync, statSync, readdirSync } = require("node:fs");
|
||||
const { existsSync, statSync, readdirSync, readFileSync } = require("node:fs");
|
||||
const { resolve, join } = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
|
|
@ -89,18 +89,7 @@ if (require.main === module) {
|
|||
// Skip in CI — the pipeline runs `npm run build` explicitly
|
||||
if (process.env.CI === "true" || process.env.CI === "1") process.exit(0);
|
||||
|
||||
// Workspace packages that need dist/index.js at runtime.
|
||||
// Order matters: dependencies must build before dependents.
|
||||
const WORKSPACE_PACKAGES = [
|
||||
"native",
|
||||
"pi-tui",
|
||||
"google-gemini-cli-provider",
|
||||
"pi-ai",
|
||||
"pi-agent-core",
|
||||
"pi-coding-agent",
|
||||
"rpc-client",
|
||||
"daemon",
|
||||
];
|
||||
const WORKSPACE_PACKAGES = discoverWorkspacePackageDirs(packagesDir);
|
||||
|
||||
const stale = detectStalePackages(root, WORKSPACE_PACKAGES);
|
||||
|
||||
|
|
@ -123,4 +112,98 @@ if (require.main === module) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { newestSrcMtime, detectStalePackages };
|
||||
/**
|
||||
* Return workspace package directory names in dependency order.
|
||||
*
|
||||
* Purpose: keep postinstall rebuilds aligned with the real workspace package
|
||||
* graph as package directories are renamed or added.
|
||||
*
|
||||
* Consumer: this script's postinstall entrypoint before runtime imports resolve
|
||||
* packages from dist/.
|
||||
*
|
||||
* @param {string} packagesDir
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function discoverWorkspacePackageDirs(packagesDir) {
|
||||
if (!existsSync(packagesDir)) return [];
|
||||
|
||||
const packages = new Map();
|
||||
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = join(packagesDir, entry.name, "package.json");
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
if (
|
||||
typeof manifest.name === "string" &&
|
||||
manifest.name.startsWith("@singularity-forge/")
|
||||
) {
|
||||
packages.set(entry.name, {
|
||||
dir: entry.name,
|
||||
name: manifest.name,
|
||||
dependencies: {
|
||||
...(manifest.dependencies ?? {}),
|
||||
...(manifest.peerDependencies ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed manifests; the normal runtime import error will be clearer.
|
||||
}
|
||||
}
|
||||
|
||||
const byName = new Map(
|
||||
[...packages.values()].map((pkg) => [pkg.name, pkg.dir]),
|
||||
);
|
||||
const ordered = [];
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
|
||||
function visit(dir) {
|
||||
if (visited.has(dir)) return;
|
||||
if (visiting.has(dir)) return;
|
||||
const pkg = packages.get(dir);
|
||||
if (!pkg) return;
|
||||
visiting.add(dir);
|
||||
for (const depName of Object.keys(pkg.dependencies)) {
|
||||
const depDir = byName.get(depName);
|
||||
if (depDir) visit(depDir);
|
||||
}
|
||||
visiting.delete(dir);
|
||||
visited.add(dir);
|
||||
ordered.push(dir);
|
||||
}
|
||||
|
||||
for (const dir of [...packages.keys()].sort(workspaceBuildPriority))
|
||||
visit(dir);
|
||||
return ordered.sort(workspaceBuildPriority);
|
||||
}
|
||||
|
||||
function workspaceBuildPriority(a, b) {
|
||||
const priority = [
|
||||
"native",
|
||||
"tui",
|
||||
"google-gemini-cli-provider",
|
||||
"openai-codex-provider",
|
||||
"ai",
|
||||
"agent-core",
|
||||
"coding-agent",
|
||||
"rpc-client",
|
||||
"daemon",
|
||||
];
|
||||
const ai = priority.indexOf(a);
|
||||
const bi = priority.indexOf(b);
|
||||
if (ai !== -1 || bi !== -1) {
|
||||
return (
|
||||
(ai === -1 ? Number.MAX_SAFE_INTEGER : ai) -
|
||||
(bi === -1 ? Number.MAX_SAFE_INTEGER : bi)
|
||||
);
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
newestSrcMtime,
|
||||
detectStalePackages,
|
||||
discoverWorkspacePackageDirs,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const {
|
|||
lstatSync,
|
||||
readlinkSync,
|
||||
unlinkSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} = require("node:fs");
|
||||
const { resolve, join } = require("node:path");
|
||||
|
||||
|
|
@ -32,27 +34,15 @@ const packagesDir = join(root, "packages");
|
|||
const scope = "@singularity-forge";
|
||||
const scopeDir = join(root, "node_modules", scope);
|
||||
|
||||
// Directory names under packages/ that should be linked as @singularity-forge/<dir>
|
||||
const packageDirs = [
|
||||
"native",
|
||||
"pi-agent-core",
|
||||
"google-gemini-cli-provider",
|
||||
"pi-ai",
|
||||
"pi-coding-agent",
|
||||
"pi-tui",
|
||||
"rpc-client",
|
||||
"daemon",
|
||||
];
|
||||
|
||||
if (!existsSync(scopeDir)) {
|
||||
mkdirSync(scopeDir, { recursive: true });
|
||||
}
|
||||
|
||||
let linked = 0;
|
||||
let copied = 0;
|
||||
for (const dir of packageDirs) {
|
||||
const source = join(packagesDir, dir);
|
||||
const target = join(scopeDir, dir);
|
||||
for (const pkg of discoverWorkspacePackages(packagesDir, scope)) {
|
||||
const source = join(packagesDir, pkg.dir);
|
||||
const target = join(scopeDir, pkg.name);
|
||||
|
||||
if (!existsSync(source)) continue;
|
||||
|
||||
|
|
@ -110,11 +100,7 @@ if (copied > 0)
|
|||
// Wire them into node_modules/@singularity-forge/ so native.ts can require() them without
|
||||
// a registry install. Only link platforms where the binary (forge_engine.node) is present.
|
||||
const nativeNpmDir = join(root, "native", "npm");
|
||||
const engineSuffixes = [
|
||||
"linux-x64-gnu",
|
||||
"linux-arm64-gnu",
|
||||
"win32-x64-msvc",
|
||||
];
|
||||
const engineSuffixes = ["linux-x64-gnu"];
|
||||
for (const suffix of engineSuffixes) {
|
||||
const source = join(nativeNpmDir, suffix);
|
||||
const binaryPath = join(source, "forge_engine.node");
|
||||
|
|
@ -153,3 +139,31 @@ for (const suffix of engineSuffixes) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function discoverWorkspacePackages(packagesDir, scope) {
|
||||
if (!existsSync(packagesDir)) return [];
|
||||
|
||||
const packages = [];
|
||||
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = join(packagesDir, entry.name, "package.json");
|
||||
if (!existsSync(manifestPath)) continue;
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
if (
|
||||
typeof manifest.name === "string" &&
|
||||
manifest.name.startsWith(`${scope}/`)
|
||||
) {
|
||||
packages.push({
|
||||
dir: entry.name,
|
||||
name: manifest.name.slice(scope.length + 1),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed workspace manifests; package resolution will fail loudly
|
||||
// later if a required package was not linked.
|
||||
}
|
||||
}
|
||||
|
||||
return packages.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
|
|
@ -61,13 +63,13 @@ rmSync(distWebRoot, { recursive: true, force: true });
|
|||
mkdirSync(distStandaloneRoot, { recursive: true });
|
||||
|
||||
cpSync(standaloneAppRoot, distStandaloneRoot, COPY_OPTIONS);
|
||||
const distNodeModulesRoot = join(distStandaloneRoot, "node_modules");
|
||||
rmSync(distNodeModulesRoot, { recursive: true, force: true });
|
||||
makeWritable(distStandaloneRoot);
|
||||
|
||||
if (existsSync(standaloneNodeModulesRoot)) {
|
||||
cpSync(
|
||||
standaloneNodeModulesRoot,
|
||||
join(distStandaloneRoot, "node_modules"),
|
||||
COPY_OPTIONS,
|
||||
);
|
||||
cpSync(standaloneNodeModulesRoot, distNodeModulesRoot, COPY_OPTIONS);
|
||||
makeWritable(distNodeModulesRoot);
|
||||
}
|
||||
|
||||
if (existsSync(staticRoot)) {
|
||||
|
|
@ -87,3 +89,18 @@ if (hydratedTargets.length > 0) {
|
|||
`[forge] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
function makeWritable(path) {
|
||||
if (!existsSync(path)) return;
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
chmodSync(path, stat.mode | 0o200);
|
||||
if (!stat.isDirectory()) return;
|
||||
for (const entry of readdirSync(path)) {
|
||||
makeWritable(join(path, entry));
|
||||
}
|
||||
} catch {
|
||||
// Best effort: copied Next standalone trees can contain store-backed
|
||||
// symlinks during Nix builds; later writes will report any real failure.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,9 @@ export function findMilestoneIds(basePath) {
|
|||
if (dbRows.length > 0) {
|
||||
const ids = dbRows.map((m) => m.id);
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
return sortByQueueOrder(ids, customOrder);
|
||||
const tierMap = new Map(dbRows.map((m) => [m.id, m.tier ?? 5]));
|
||||
const sequenceMap = new Map(dbRows.map((m) => [m.id, m.sequence ?? 0]));
|
||||
return sortByQueueOrder(ids, customOrder, tierMap, sequenceMap);
|
||||
}
|
||||
} catch {
|
||||
// DB not open yet — fall through to filesystem scan
|
||||
|
|
|
|||
|
|
@ -39,10 +39,7 @@ export function loadQueueOrder(basePath) {
|
|||
if (milestones.some((m) => (m.sequence ?? 0) > 0)) {
|
||||
return milestones
|
||||
.filter((m) => (m.sequence ?? 0) > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.sequence ?? 0) - (b.sequence ?? 0) || a.id.localeCompare(b.id),
|
||||
)
|
||||
.sort((a, b) => tierSequenceSortFromRows(a, b))
|
||||
.map((m) => m.id);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,26 +65,84 @@ export function saveQueueOrder(basePath, order) {
|
|||
/**
|
||||
* Sort milestone IDs respecting a custom order.
|
||||
*
|
||||
* - IDs present in `customOrder` appear in that exact sequence.
|
||||
* - IDs on disk but NOT in `customOrder` are appended at the end,
|
||||
* sorted by the default `milestoneIdSort` (numeric).
|
||||
* - IDs in `customOrder` but NOT on disk are silently skipped.
|
||||
* - When `customOrder` is null, falls back to `milestoneIdSort`.
|
||||
* When tierMap + sequenceMap are provided (milestones from getAllMilestones()),
|
||||
* sorting uses tier as the primary dimension:
|
||||
*
|
||||
* - IDs present in `customOrder` are placed first — this preserves explicit
|
||||
* operator queue-order overrides (e.g. `/queue reorder`) as the highest
|
||||
* priority, matching the R073 contract where PROJECT.md tier edits should
|
||||
* govern but operator overrides take precedence.
|
||||
* - All other IDs are sorted by tier ASC, sequence ASC, id for tiebreaking —
|
||||
* matching the DB layer's `ORDER BY tier ASC, sequence ASC, id` so that
|
||||
* deriveStateFromDb() and getAllMilestones() produce consistent ordering.
|
||||
* - When tier/sequence data is absent, falls back to the legacy behavior:
|
||||
* customOrder takes absolute precedence; remaining IDs use milestoneIdSort.
|
||||
*
|
||||
* @param {string[]} ids - milestone IDs to sort
|
||||
* @param {string[]|null} customOrder - explicit operator override order
|
||||
* @param {Map<string,number>|null} tierMap - id → tier (1 = highest priority)
|
||||
* @param {Map<string,number>|null} sequenceMap - id → sequence within tier
|
||||
*/
|
||||
export function sortByQueueOrder(ids, customOrder) {
|
||||
if (!customOrder) return [...ids].sort(milestoneIdSort);
|
||||
export function sortByQueueOrder(ids, customOrder, tierMap, sequenceMap) {
|
||||
if (!customOrder || customOrder.length === 0) {
|
||||
if (tierMap && sequenceMap) {
|
||||
return [...ids].sort(tierSequenceSort(tierMap, sequenceMap));
|
||||
}
|
||||
return [...ids].sort(milestoneIdSort);
|
||||
}
|
||||
|
||||
const idSet = new Set(ids);
|
||||
const ordered = [];
|
||||
// First: IDs from customOrder that exist on disk
|
||||
for (const id of customOrder) {
|
||||
if (idSet.has(id)) {
|
||||
ordered.push(id);
|
||||
idSet.delete(id);
|
||||
const customPosition = new Map();
|
||||
for (let index = 0; index < customOrder.length; index++) {
|
||||
const id = customOrder[index];
|
||||
if (idSet.has(id) && !customPosition.has(id)) {
|
||||
customPosition.set(id, index);
|
||||
}
|
||||
}
|
||||
// Then: remaining IDs not in customOrder, in default sort order
|
||||
const remaining = [...idSet].sort(milestoneIdSort);
|
||||
return [...ordered, ...remaining];
|
||||
|
||||
const queued = [];
|
||||
const rest = [];
|
||||
for (const id of ids) {
|
||||
const pos = customPosition.get(id);
|
||||
if (pos !== undefined) {
|
||||
queued.push({ id, pos });
|
||||
} else {
|
||||
rest.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
queued.sort((a, b) => a.pos - b.pos);
|
||||
if (tierMap && sequenceMap) {
|
||||
rest.sort(tierSequenceSort(tierMap, sequenceMap));
|
||||
} else {
|
||||
rest.sort(milestoneIdSort);
|
||||
}
|
||||
return [...queued.map((x) => x.id), ...rest];
|
||||
}
|
||||
|
||||
function tierSequenceSort(tierMap, sequenceMap) {
|
||||
return (a, b) => {
|
||||
const ta = tierMap.get(a) ?? 5;
|
||||
const tb = tierMap.get(b) ?? 5;
|
||||
if (ta !== tb) return ta - tb;
|
||||
const sa = sequenceMap.get(a) ?? 0;
|
||||
const sb = sequenceMap.get(b) ?? 0;
|
||||
if (sa === 0 && sb > 0) return 1;
|
||||
if (sa > 0 && sb === 0) return -1;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
}
|
||||
|
||||
function tierSequenceSortFromRows(a, b) {
|
||||
const ta = a.tier ?? 5;
|
||||
const tb = b.tier ?? 5;
|
||||
if (ta !== tb) return ta - tb;
|
||||
const sa = a.sequence ?? 0;
|
||||
const sb = b.sequence ?? 0;
|
||||
if (sa === 0 && sb > 0) return 1;
|
||||
if (sa > 0 && sb === 0) return -1;
|
||||
return sa - sb || a.id.localeCompare(b.id);
|
||||
}
|
||||
// ─── Pruning ─────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -473,7 +473,8 @@ let _txDepth = 0;
|
|||
* Execute a callback within a database transaction (BEGIN...COMMIT or ROLLBACK).
|
||||
*/
|
||||
export function transaction(fn) {
|
||||
if (!_sf.currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
if (!_sf.currentDb)
|
||||
throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
// Re-entrant: if already inside a transaction, just run fn() without
|
||||
// starting a new one. SQLite does not support nested BEGIN/COMMIT.
|
||||
if (_txDepth > 0) {
|
||||
|
|
@ -508,7 +509,8 @@ export function transaction(fn) {
|
|||
* Execute a callback within a read-only database transaction.
|
||||
*/
|
||||
export function readTransaction(fn) {
|
||||
if (!_sf.currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
if (!_sf.currentDb)
|
||||
throw new SFError(SF_STALE_STATE, "sf-db: No database open");
|
||||
if (_txDepth > 0) {
|
||||
_txDepth++;
|
||||
try {
|
||||
|
|
@ -1003,6 +1005,7 @@ export function rowToMilestone(row) {
|
|||
vision_meeting: parseVisionMeeting(row["vision_meeting_json"]),
|
||||
product_research: parseProductResearch(row["product_research_json"]),
|
||||
sequence: row["sequence"] ?? 0,
|
||||
tier: row["tier"] ?? 5,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ export function insertMilestone(m) {
|
|||
id, title, status, depends_on, created_at,
|
||||
vision, success_criteria, key_risks, proof_strategy,
|
||||
verification_contract, verification_integration, verification_operational, verification_uat,
|
||||
definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json, sequence
|
||||
definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json, sequence, tier
|
||||
) VALUES (
|
||||
:id, :title, :status, :depends_on, :created_at,
|
||||
:vision, :success_criteria, :key_risks, :proof_strategy,
|
||||
:verification_contract, :verification_integration, :verification_operational, :verification_uat,
|
||||
:definition_of_done, :requirement_coverage, :boundary_map_markdown, :vision_meeting_json, :product_research_json, :sequence
|
||||
:definition_of_done, :requirement_coverage, :boundary_map_markdown, :vision_meeting_json, :product_research_json, :sequence, :tier
|
||||
)`)
|
||||
.run({
|
||||
":id": m.id,
|
||||
|
|
@ -54,6 +54,7 @@ export function insertMilestone(m) {
|
|||
? JSON.stringify(m.planning.productResearch)
|
||||
: "",
|
||||
":sequence": m.sequence ?? 0,
|
||||
":tier": m.tier ?? 5,
|
||||
});
|
||||
if (hasPlanningPayload(m.planning)) {
|
||||
insertMilestoneSpecIfAbsent(m.id, m.planning ?? {});
|
||||
|
|
@ -185,7 +186,7 @@ export function getActiveMilestoneIdFromDb() {
|
|||
if (!currentDb) return null;
|
||||
const row = currentDb
|
||||
.prepare(
|
||||
"SELECT id, status FROM milestones WHERE status NOT IN ('complete', 'parked') ORDER BY id LIMIT 1",
|
||||
"SELECT id, status FROM milestones WHERE status NOT IN ('complete', 'parked') ORDER BY tier ASC, CASE WHEN sequence > 0 THEN 0 ELSE 1 END, sequence, id LIMIT 1",
|
||||
)
|
||||
.get();
|
||||
if (!row) return null;
|
||||
|
|
|
|||
|
|
@ -718,10 +718,16 @@ export async function deriveStateFromDb(basePath) {
|
|||
await loadFile(resolveSfRootFile(basePath, "REQUIREMENTS")),
|
||||
);
|
||||
const allMilestones = reconcileDiskToDb(basePath);
|
||||
const tierMap = new Map(allMilestones.map((m) => [m.id, m.tier ?? 5]));
|
||||
const sequenceMap = new Map(
|
||||
allMilestones.map((m) => [m.id, m.sequence ?? 0]),
|
||||
);
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
const sortedIds = sortByQueueOrder(
|
||||
allMilestones.map((m) => m.id),
|
||||
customOrder,
|
||||
tierMap,
|
||||
sequenceMap,
|
||||
);
|
||||
const byId = new Map(allMilestones.map((m) => [m.id, m]));
|
||||
allMilestones.length = 0;
|
||||
|
|
|
|||
|
|
@ -129,12 +129,18 @@ export async function getActiveMilestoneId(basePath) {
|
|||
const allMilestones = getAllMilestones();
|
||||
if (allMilestones.length > 0) {
|
||||
// Respect queue-order.json so /queue reordering is honored (#2556).
|
||||
// Without this, the DB path uses lexicographic sort while the dispatch
|
||||
// guard uses queue order — causing a deadlock.
|
||||
// Without this, the DB path can choose a different active milestone than
|
||||
// the dispatch guard, causing a queue-selection deadlock.
|
||||
const customOrder = loadQueueOrder(basePath);
|
||||
const tierMap = new Map(allMilestones.map((m) => [m.id, m.tier ?? 5]));
|
||||
const sequenceMap = new Map(
|
||||
allMilestones.map((m) => [m.id, m.sequence ?? 0]),
|
||||
);
|
||||
const sortedIds = sortByQueueOrder(
|
||||
allMilestones.map((m) => m.id),
|
||||
customOrder,
|
||||
tierMap,
|
||||
sequenceMap,
|
||||
);
|
||||
const byId = new Map(allMilestones.map((m) => [m.id, m]));
|
||||
for (const id of sortedIds) {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,14 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, test } from "vitest";
|
||||
import { loadQueueOrder, saveQueueOrder } from "../queue-order.js";
|
||||
import {
|
||||
loadQueueOrder,
|
||||
saveQueueOrder,
|
||||
sortByQueueOrder,
|
||||
} from "../queue-order.js";
|
||||
import {
|
||||
closeDatabase,
|
||||
getActiveMilestoneIdFromDb,
|
||||
getAllMilestones,
|
||||
insertMilestone,
|
||||
openDatabase,
|
||||
|
|
@ -54,3 +59,81 @@ test("saveQueueOrder_when_db_available_persists_order_to_milestone_sequence", ()
|
|||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("sortByQueueOrder_when_no_custom_order_uses_tier_then_sequence", () => {
|
||||
const tierMap = new Map([
|
||||
["M005", 5],
|
||||
["M001", 1],
|
||||
["M003", 1],
|
||||
]);
|
||||
const sequenceMap = new Map([
|
||||
["M005", 1],
|
||||
["M001", 2],
|
||||
["M003", 1],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
sortByQueueOrder(["M005", "M001", "M003"], null, tierMap, sequenceMap),
|
||||
["M003", "M001", "M005"],
|
||||
);
|
||||
});
|
||||
|
||||
test("sortByQueueOrder_when_custom_order_exists_keeps_override_first_then_tier_order", () => {
|
||||
const tierMap = new Map([
|
||||
["M005", 5],
|
||||
["M001", 1],
|
||||
["M003", 1],
|
||||
]);
|
||||
const sequenceMap = new Map([
|
||||
["M005", 1],
|
||||
["M001", 2],
|
||||
["M003", 1],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
sortByQueueOrder(["M005", "M001", "M003"], ["M005"], tierMap, sequenceMap),
|
||||
["M005", "M003", "M001"],
|
||||
);
|
||||
});
|
||||
|
||||
test("milestones_when_inserted_with_tiers_select_active_by_tier_then_sequence", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "sf-queue-order-db-"));
|
||||
tmpDirs.push(project);
|
||||
mkdirSync(join(project, ".sf"), { recursive: true });
|
||||
openDatabase(join(project, ".sf", "sf.db"));
|
||||
|
||||
insertMilestone({
|
||||
id: "M005",
|
||||
title: "Later",
|
||||
status: "queued",
|
||||
tier: 5,
|
||||
sequence: 1,
|
||||
});
|
||||
insertMilestone({
|
||||
id: "M001",
|
||||
title: "Second urgent",
|
||||
status: "queued",
|
||||
tier: 1,
|
||||
sequence: 2,
|
||||
});
|
||||
insertMilestone({
|
||||
id: "M003",
|
||||
title: "First urgent",
|
||||
status: "queued",
|
||||
tier: 1,
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
getAllMilestones().map((m) => [m.id, m.tier, m.sequence]),
|
||||
[
|
||||
["M003", 1, 1],
|
||||
["M001", 1, 2],
|
||||
["M005", 5, 1],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(getActiveMilestoneIdFromDb(), {
|
||||
id: "M003",
|
||||
status: "queued",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -810,6 +810,16 @@ test("workflow action surfaces route new-milestone CTAs through the shared comma
|
|||
/buildPromptCommand\(workflowAction\.primary\.command, bridge\)/,
|
||||
"chat-mode.tsx must send the new-milestone CTA through the same command path as other chat CTAs",
|
||||
);
|
||||
assert.match(
|
||||
dashboardSource,
|
||||
/executeWorkflowActionInPowerMode\(\{\s*command,/s,
|
||||
"dashboard.tsx must pass the command so /discuss can stay in chat mode",
|
||||
);
|
||||
assert.match(
|
||||
sidebarSource,
|
||||
/executeWorkflowActionInPowerMode\(\{\s*command,/s,
|
||||
"sidebar.tsx must pass the command so /discuss can stay in chat mode",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(
|
||||
dashboardSource,
|
||||
|
|
@ -833,6 +843,31 @@ test("workflow action surfaces route new-milestone CTAs through the shared comma
|
|||
);
|
||||
});
|
||||
|
||||
test("chat view owns blocking discussion requests inline instead of the global side panel", () => {
|
||||
const appShellPath = resolve(
|
||||
import.meta.dirname,
|
||||
"../../../web/components/sf/app-shell.tsx",
|
||||
);
|
||||
const focusedPanelPath = resolve(
|
||||
import.meta.dirname,
|
||||
"../../../web/components/sf/focused-panel.tsx",
|
||||
);
|
||||
|
||||
const appShellSource = readFileSync(appShellPath, "utf-8");
|
||||
const focusedPanelSource = readFileSync(focusedPanelPath, "utf-8");
|
||||
|
||||
assert.match(
|
||||
focusedPanelSource,
|
||||
/export function FocusedPanel\(\{ enabled = true \}: \{ enabled\?: boolean \}\)/,
|
||||
"FocusedPanel must expose an enable gate so chat can render UI requests inline",
|
||||
);
|
||||
assert.match(
|
||||
appShellSource,
|
||||
/<FocusedPanel enabled=\{activeView !== "chat"\} \/>/,
|
||||
"app-shell.tsx must suppress the global side panel in chat mode",
|
||||
);
|
||||
});
|
||||
|
||||
test("sidebar Git affordance opens a real git-summary surface with visible repo/not-repo/error states", () => {
|
||||
const contractPath = resolve(
|
||||
import.meta.dirname,
|
||||
|
|
|
|||
|
|
@ -79,3 +79,37 @@ test("executeWorkflowActionInPowerMode calls dispatch and navigates to the appro
|
|||
assert.equal(dispatchCalled, true, "dispatch should have been called");
|
||||
assert.ok(seenViews.length > 0, "should navigate to a view");
|
||||
});
|
||||
|
||||
test("executeWorkflowActionInPowerMode keeps discuss actions in chat mode", async (_t) => {
|
||||
const originalWindow = (globalThis as { window?: EventTarget }).window;
|
||||
const originalLocalStorage = (globalThis as any).localStorage;
|
||||
const fakeWindow = new EventTarget();
|
||||
const seenViews: string[] = [];
|
||||
let dispatchCalled = false;
|
||||
|
||||
fakeWindow.addEventListener("sf:navigate-view", (event: Event) => {
|
||||
seenViews.push((event as CustomEvent<{ view: string }>).detail.view);
|
||||
});
|
||||
|
||||
(globalThis as { window?: EventTarget }).window = fakeWindow;
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: () => "power",
|
||||
setItem: () => {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as { window?: EventTarget }).window = originalWindow;
|
||||
(globalThis as any).localStorage = originalLocalStorage;
|
||||
});
|
||||
|
||||
executeWorkflowActionInPowerMode({
|
||||
command: "/discuss",
|
||||
dispatch: async () => {
|
||||
dispatchCalled = true;
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.equal(dispatchCalled, true, "dispatch should still run");
|
||||
assert.deepEqual(seenViews, ["chat"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@
|
|||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-geist-sans), "Geist", "Geist Fallback";
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback";
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,8 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SF",
|
||||
description:
|
||||
|
|
@ -51,9 +40,7 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<body className="font-sans antialiased">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
{children}
|
||||
<Toaster position="bottom-right" />
|
||||
|
|
|
|||
|
|
@ -695,7 +695,7 @@ function WorkspaceChrome() {
|
|||
onOpenChange={setProjectsPanelOpen}
|
||||
/>
|
||||
<CommandSurface />
|
||||
<FocusedPanel />
|
||||
<FocusedPanel enabled={activeView !== "chat"} />
|
||||
<OnboardingGate />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export function Dashboard({ onSwitchView }: DashboardProps = {}) {
|
|||
|
||||
const handleWorkflowAction = (command: string) => {
|
||||
executeWorkflowActionInPowerMode({
|
||||
command,
|
||||
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -315,12 +315,12 @@ function RequestBody({
|
|||
}
|
||||
}
|
||||
|
||||
export function FocusedPanel() {
|
||||
export function FocusedPanel({ enabled = true }: { enabled?: boolean }) {
|
||||
const workspace = useSFWorkspaceState();
|
||||
const { respondToUiRequest, dismissUiRequest } = useSFWorkspaceActions();
|
||||
|
||||
const pending = workspace.pendingUiRequests;
|
||||
const isOpen = pending.length > 0;
|
||||
const isOpen = enabled && pending.length > 0;
|
||||
const current = pending[0] ?? null;
|
||||
const isSubmitting = workspace.commandInFlight === "extension_ui_response";
|
||||
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ export function MilestoneExplorer({
|
|||
|
||||
const handleCommand = (command: string) => {
|
||||
executeWorkflowActionInPowerMode({
|
||||
command,
|
||||
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
|
||||
});
|
||||
};
|
||||
|
|
@ -781,6 +782,7 @@ export function CollapsedMilestoneSidebar({
|
|||
|
||||
const handleCommand = (command: string) => {
|
||||
executeWorkflowActionInPowerMode({
|
||||
command,
|
||||
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -84,4 +84,34 @@ describe("readSwarmDashboardRows", () => {
|
|||
assert.equal(rows[0]?.status, "missing");
|
||||
assert.match(rows[0]?.error ?? "", /projection missing/);
|
||||
});
|
||||
|
||||
test("empty_registry_discovers_sf_repos_from_workspace_root", () => {
|
||||
const root = makeRoot();
|
||||
const repo = join(root, "group", "repo");
|
||||
mkdirSync(join(repo, ".sf"), { recursive: true });
|
||||
const registry = join(root, "swarms.json");
|
||||
writeFileSync(
|
||||
registry,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
updatedAt: "2026-05-17T00:00:00.000Z",
|
||||
swarms: [],
|
||||
}),
|
||||
);
|
||||
const previous = process.env["SF_WORKSPACES_DIR"];
|
||||
process.env["SF_WORKSPACES_DIR"] = root;
|
||||
try {
|
||||
const rows = readSwarmDashboardRows(registry);
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0]?.repoPath, repo);
|
||||
assert.equal(rows[0]?.status, "missing");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env["SF_WORKSPACES_DIR"];
|
||||
} else {
|
||||
process.env["SF_WORKSPACES_DIR"] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { delimiter, dirname, join, resolve } from "node:path";
|
||||
|
||||
export const SWARMS_REGISTRY_PATH = join(homedir(), ".sf", "swarms.json");
|
||||
export const STATUS_PROJECTION_FILE = "status.projection.json";
|
||||
|
|
@ -76,9 +76,76 @@ function isRegistryEntry(value: unknown): value is SwarmRegistryEntry {
|
|||
function readRegistry(
|
||||
registryPath = SWARMS_REGISTRY_PATH,
|
||||
): SwarmRegistryEntry[] {
|
||||
if (!existsSync(registryPath)) return [];
|
||||
if (!existsSync(registryPath)) return discoverFallbackRegistryEntries();
|
||||
const parsed = JSON.parse(readFileSync(registryPath, "utf-8"));
|
||||
return normalizeRegistry(parsed);
|
||||
const entries = normalizeRegistry(parsed);
|
||||
return entries.length > 0 ? entries : discoverFallbackRegistryEntries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover SF repos when the daemon registry has not been populated yet.
|
||||
*
|
||||
* Purpose: keep the shared server useful after fresh k3s starts by deriving a
|
||||
* bounded read model from mounted workspace roots instead of showing an empty
|
||||
* "No repos registered" page until an operator edits swarms.json.
|
||||
*
|
||||
* Consumer: readRegistry() fallback path for /api/swarms.
|
||||
*/
|
||||
function discoverFallbackRegistryEntries(): SwarmRegistryEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const rows: SwarmRegistryEntry[] = [];
|
||||
for (const root of fallbackScanRoots()) {
|
||||
for (const repoPath of scanForSfRepos(root, 3)) {
|
||||
if (seen.has(repoPath)) continue;
|
||||
seen.add(repoPath);
|
||||
rows.push({
|
||||
name: repoPath.split(/[\\/]/).filter(Boolean).at(-1) ?? repoPath,
|
||||
path: repoPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) =>
|
||||
String(a.name ?? a.path).localeCompare(String(b.name ?? b.path)),
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackScanRoots(): string[] {
|
||||
const roots = new Set<string>();
|
||||
for (const raw of (process.env["SF_WORKSPACES_DIR"] ?? "").split(delimiter)) {
|
||||
if (raw.trim()) roots.add(resolve(raw.trim()));
|
||||
}
|
||||
const projectCwd = process.env["SF_WEB_PROJECT_CWD"];
|
||||
if (projectCwd) {
|
||||
roots.add(resolve(projectCwd));
|
||||
roots.add(dirname(resolve(projectCwd)));
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function scanForSfRepos(root: string, maxDepth: number): string[] {
|
||||
const resolvedRoot = resolve(root);
|
||||
const repos: string[] = [];
|
||||
function visit(dir: string, depth: number): void {
|
||||
if (existsSync(join(dir, ".sf"))) {
|
||||
repos.push(dir);
|
||||
return;
|
||||
}
|
||||
if (depth >= maxDepth) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === "node_modules") continue;
|
||||
if (entry.name.startsWith(".") && entry.name !== ".sf") continue;
|
||||
visit(join(dir, entry.name), depth + 1);
|
||||
}
|
||||
}
|
||||
visit(resolvedRoot, 0);
|
||||
return repos;
|
||||
}
|
||||
|
||||
function validateProjection(value: unknown): SwarmStatusProjection {
|
||||
|
|
|
|||
|
|
@ -27,13 +27,19 @@ export function navigateToSFView(view: SFViewName): void {
|
|||
* keystrokes.
|
||||
*/
|
||||
export function executeWorkflowActionInPowerMode({
|
||||
command,
|
||||
dispatch,
|
||||
}: {
|
||||
command?: string;
|
||||
dispatch: () => Promise<unknown>;
|
||||
}): void {
|
||||
dispatch().catch((error) => {
|
||||
console.error("[workflow-action] dispatch failed:", error);
|
||||
});
|
||||
if (command === "/discuss") {
|
||||
navigateToSFView("chat");
|
||||
return;
|
||||
}
|
||||
const mode = getUserMode();
|
||||
navigateToSFView(mode === "vibe-coder" ? "chat" : "power");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@
|
|||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist", "Geist Fallback";
|
||||
--font-mono: "Geist Mono", "Geist Mono Fallback";
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue