diff --git a/flake.nix b/flake.nix index 43682a12d..9d16c44d9 100644 --- a/flake.nix +++ b/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 = '' diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index bc638376d..c3972e81d 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -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(); + 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"), diff --git a/scripts/ensure-workspace-builds.cjs b/scripts/ensure-workspace-builds.cjs index 0b9e86816..b15d46517 100644 --- a/scripts/ensure-workspace-builds.cjs +++ b/scripts/ensure-workspace-builds.cjs @@ -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, +}; diff --git a/scripts/link-workspace-packages.cjs b/scripts/link-workspace-packages.cjs index 3cb975dde..7c32d271e 100644 --- a/scripts/link-workspace-packages.cjs +++ b/scripts/link-workspace-packages.cjs @@ -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/ -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)); +} diff --git a/scripts/stage-web-standalone.cjs b/scripts/stage-web-standalone.cjs index 585c8ec9e..3ef6f6ee3 100644 --- a/scripts/stage-web-standalone.cjs +++ b/scripts/stage-web-standalone.cjs @@ -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. + } +} diff --git a/src/resources/extensions/sf/milestone-ids.js b/src/resources/extensions/sf/milestone-ids.js index a68573d87..6e6ce8a1f 100644 --- a/src/resources/extensions/sf/milestone-ids.js +++ b/src/resources/extensions/sf/milestone-ids.js @@ -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 diff --git a/src/resources/extensions/sf/queue-order.js b/src/resources/extensions/sf/queue-order.js index d126e1c9e..016eb8833 100644 --- a/src/resources/extensions/sf/queue-order.js +++ b/src/resources/extensions/sf/queue-order.js @@ -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|null} tierMap - id → tier (1 = highest priority) + * @param {Map|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 ───────────────────────────────────────────────────────────────── /** diff --git a/src/resources/extensions/sf/sf-db/sf-db-core.js b/src/resources/extensions/sf/sf-db/sf-db-core.js index d8606f98e..56bd2453b 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-core.js +++ b/src/resources/extensions/sf/sf-db/sf-db-core.js @@ -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, }; } diff --git a/src/resources/extensions/sf/sf-db/sf-db-milestones.js b/src/resources/extensions/sf/sf-db/sf-db-milestones.js index 355898392..0407361a9 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-milestones.js +++ b/src/resources/extensions/sf/sf-db/sf-db-milestones.js @@ -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; diff --git a/src/resources/extensions/sf/state-db.js b/src/resources/extensions/sf/state-db.js index 5a06fd88a..b3576ada5 100644 --- a/src/resources/extensions/sf/state-db.js +++ b/src/resources/extensions/sf/state-db.js @@ -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; diff --git a/src/resources/extensions/sf/state.js b/src/resources/extensions/sf/state.js index 500e41022..c1edfbee6 100644 --- a/src/resources/extensions/sf/state.js +++ b/src/resources/extensions/sf/state.js @@ -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) { diff --git a/src/resources/extensions/sf/tests/queue-order-db.test.mjs b/src/resources/extensions/sf/tests/queue-order-db.test.mjs index e785af835..174ef7cfb 100644 --- a/src/resources/extensions/sf/tests/queue-order-db.test.mjs +++ b/src/resources/extensions/sf/tests/queue-order-db.test.mjs @@ -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", + }); +}); diff --git a/src/tests/integration/web-state-surfaces-contract.test.ts b/src/tests/integration/web-state-surfaces-contract.test.ts index cede6abdc..2854679e6 100644 --- a/src/tests/integration/web-state-surfaces-contract.test.ts +++ b/src/tests/integration/web-state-surfaces-contract.test.ts @@ -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, + //, + "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, diff --git a/src/tests/integration/web-workflow-action-execution.test.ts b/src/tests/integration/web-workflow-action-execution.test.ts index 2b95e38e4..a5fadccab 100644 --- a/src/tests/integration/web-workflow-action-execution.test.ts +++ b/src/tests/integration/web-workflow-action-execution.test.ts @@ -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"]); +}); diff --git a/web/app/globals.css b/web/app/globals.css index 0b14e2038..c49914644 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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); diff --git a/web/app/layout.tsx b/web/app/layout.tsx index d3167e18a..92224de18 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -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 ( - + {children} diff --git a/web/components/sf/app-shell.tsx b/web/components/sf/app-shell.tsx index afcfde6fb..4844fb2dd 100644 --- a/web/components/sf/app-shell.tsx +++ b/web/components/sf/app-shell.tsx @@ -695,7 +695,7 @@ function WorkspaceChrome() { onOpenChange={setProjectsPanelOpen} /> - + ); diff --git a/web/components/sf/dashboard.tsx b/web/components/sf/dashboard.tsx index 889e68d74..381da39f2 100644 --- a/web/components/sf/dashboard.tsx +++ b/web/components/sf/dashboard.tsx @@ -194,6 +194,7 @@ export function Dashboard({ onSwitchView }: DashboardProps = {}) { const handleWorkflowAction = (command: string) => { executeWorkflowActionInPowerMode({ + command, dispatch: () => sendCommand(buildPromptCommand(command, bridge)), }); }; diff --git a/web/components/sf/focused-panel.tsx b/web/components/sf/focused-panel.tsx index 6d9d1b55f..ea8b0c7c6 100644 --- a/web/components/sf/focused-panel.tsx +++ b/web/components/sf/focused-panel.tsx @@ -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"; diff --git a/web/components/sf/sidebar.tsx b/web/components/sf/sidebar.tsx index faff1f134..51b665816 100644 --- a/web/components/sf/sidebar.tsx +++ b/web/components/sf/sidebar.tsx @@ -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)), }); }; diff --git a/web/lib/__tests__/swarm-status.test.ts b/web/lib/__tests__/swarm-status.test.ts index 1ea31ff3b..9b348d9ba 100644 --- a/web/lib/__tests__/swarm-status.test.ts +++ b/web/lib/__tests__/swarm-status.test.ts @@ -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; + } + } + }); }); diff --git a/web/lib/swarm-status.ts b/web/lib/swarm-status.ts index 0b5c0a486..fc1add2aa 100644 --- a/web/lib/swarm-status.ts +++ b/web/lib/swarm-status.ts @@ -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(); + 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(); + 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 { diff --git a/web/lib/workflow-action-execution.ts b/web/lib/workflow-action-execution.ts index ecbf8de8a..cee490e29 100644 --- a/web/lib/workflow-action-execution.ts +++ b/web/lib/workflow-action-execution.ts @@ -27,13 +27,19 @@ export function navigateToSFView(view: SFViewName): void { * keystrokes. */ export function executeWorkflowActionInPowerMode({ + command, dispatch, }: { + command?: string; dispatch: () => Promise; }): 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"); } diff --git a/web/styles/globals.css b/web/styles/globals.css index 1a2164d60..a6a792acf 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -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);