feat: harden sf server build and routing
Some checks failed
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions
sf self-deploy / build, test, and publish server image (push) Has been cancelled

This commit is contained in:
Mikael Hugo 2026-05-18 02:33:28 +02:00
parent 3d5ce1a4bb
commit 0acb0f9be0
24 changed files with 796 additions and 94 deletions

251
flake.nix
View file

@ -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 = ''

View file

@ -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"),

View file

@ -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,
};

View file

@ -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));
}

View file

@ -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.
}
}

View file

@ -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

View file

@ -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 ─────────────────────────────────────────────────────────────────
/**

View file

@ -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,
};
}

View file

@ -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;

View file

@ -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;

View file

@ -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) {

View file

@ -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",
});
});

View file

@ -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,

View file

@ -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"]);
});

View file

@ -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);

View file

@ -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" />

View file

@ -695,7 +695,7 @@ function WorkspaceChrome() {
onOpenChange={setProjectsPanelOpen}
/>
<CommandSurface />
<FocusedPanel />
<FocusedPanel enabled={activeView !== "chat"} />
<OnboardingGate />
</div>
);

View file

@ -194,6 +194,7 @@ export function Dashboard({ onSwitchView }: DashboardProps = {}) {
const handleWorkflowAction = (command: string) => {
executeWorkflowActionInPowerMode({
command,
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
});
};

View file

@ -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";

View file

@ -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)),
});
};

View file

@ -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;
}
}
});
});

View file

@ -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 {

View file

@ -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");
}

View file

@ -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);