singularity-forge/web/pages/api/projects.ts
Mikael Hugo 8c945550fa
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions
feat: operational glue for upgrade-safety chain
Bundles the working-tree state into one coherent commit covering the
upgrade-safety glue that complements today's earlier landings
(orphan-recovery, sf-db single-connection, drain-timer-not-unref'd,
forceShutdown drain, shutdown-state.ts, instrumentation.ts,
shutdown-signal.js, gate-deadlock-classifier).

Modified:
  docker/Dockerfile.source-server — image build tweaks for the source-
    server variant used by the in-container upgrader.
  docker/docker-compose.vega.yaml — env passthroughs for host-side dirs
    (SF_SOURCE_HOST_ROOT, SF_WORKSPACE_HOST_DIR, SF_WORKSPACES_HOST_DIR,
    SF_HOME_HOST_DIR), docker socket mount, group_add for docker GID,
    and SF_RPC_SHUTDOWN_GRACE_MS=600000 matching the 10-min drain.
  scripts/run-vega-source-server.mjs — substantial rework supporting
    the in-container upgrade flow.
  scripts/upgrade-vega-source-server.mjs — buildEnv() + dockerBuildEnv()
    helpers, probeBind via SF_VEGA_PROBE_HOST, containerExists()
    pre-check before drainContainer, stop timeout now matches the
    10-min RPC grace via SF_VEGA_DRAIN_STOP_TIME (default 610s).
  src/web/project-discovery-service.ts — calls
    recoverProjectRuntimeQueues() on each of the 3 discovery paths
    (root monorepo, per-entry, nested SF projects). Closes the
    cloud-volume mtime-lag window codex flagged.
  web/app/api/ready/route.ts — calls recoverProjectRuntimeQueues() on
    every readiness probe, and now also reads shutdown-state so the
    probe returns 503 while draining.
  web/components/sf/projects-view.tsx — UI wiring for the upgrade
    trigger.
  web/pages/api/projects.ts — backend API addition for the project
    enumeration that feeds projects-view.
  docs/specs/sf-self-deploy.md — docs update for the new flow.
  package.json — script alias.

Added:
  scripts/build-web-host.mjs — new build helper for the standalone web
    host artifact consumed by the upgrade flow.
  src/resources/extensions/sf/tests/auto-shutdown-signal.test.mjs —
    unit test for the cooperative-shutdown signal module (registers /
    requests / snapshot).
  src/web/project-runtime-recovery.ts — thin wrapper around
    recoverOrphanedFeedbackDrains for per-project use from web routes.
  web/app/api/drain/route.ts — explicit drain endpoint for operator-
    triggered queue flush.
  web/app/api/server-upgrade/route.ts — auth-gated endpoint that
    spawns the in-container upgrader via docker socket; passes through
    host-dir env so the upgrader knows real bind-mount paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:57:26 +02:00

292 lines
7.9 KiB
TypeScript

import {
appendFileSync,
existsSync,
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import type { NextApiRequest, NextApiResponse } from "next";
type ProjectMetadata = {
name: string;
path: string;
kind: "active-sf" | "empty-sf" | "brownfield" | "blank";
signals: {
hasSfFolder: boolean;
hasPlanningFolder: boolean;
hasGitRepo: boolean;
hasPackageJson: boolean;
fileCount: number;
hasMilestones?: boolean;
hasCargo?: boolean;
hasGoMod?: boolean;
hasPyproject?: boolean;
isMonorepo: boolean;
};
lastModified: number;
progress?: {
activeMilestone: string | null;
activeSlice: string | null;
phase: string | null;
milestonesCompleted: number;
milestonesTotal: number;
} | null;
};
type WebPreferences = {
projectPaths?: string[];
};
const EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
const MAX_NESTED_SF_DEPTH = 3;
const SF_FEEDBACK_QUEUE_FILE = "sf-feedback-queue.jsonl";
const webPreferencesPath = join(
process.env.SF_HOME || join(homedir(), ".sf"),
"web-preferences.json",
);
function detectProject(path: string): ProjectMetadata["signals"] {
const hasGitRepo = existsSync(join(path, ".git"));
const hasSfFolder = existsSync(join(path, ".sf"));
const hasPlanningFolder = existsSync(join(path, "planning"));
const hasPackageJson = existsSync(join(path, "package.json"));
const hasCargo = existsSync(join(path, "Cargo.toml"));
const hasGoMod = existsSync(join(path, "go.mod"));
const hasPyproject = existsSync(join(path, "pyproject.toml"));
const hasMilestones = existsSync(join(path, ".sf", "milestones"));
const fileCount = readdirSync(path).filter(
(entry) => entry !== ".git",
).length;
const isMonorepo =
existsSync(join(path, "pnpm-workspace.yaml")) ||
existsSync(join(path, "lerna.json")) ||
existsSync(join(path, "package.json"));
return {
hasSfFolder,
hasPlanningFolder,
hasGitRepo,
hasPackageJson,
fileCount,
hasMilestones,
hasCargo,
hasGoMod,
hasPyproject,
isMonorepo,
};
}
function readProgress(path: string): ProjectMetadata["progress"] {
try {
const content = readFileSync(join(path, ".sf", "STATE.md"), "utf8");
let activeMilestone: string | null = null;
let activeSlice: string | null = null;
let phase: string | null = null;
let milestonesCompleted = 0;
let milestonesTotal = 0;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith("**Active Milestone:**")) {
activeMilestone =
trimmed.replace("**Active Milestone:**", "").trim() || null;
} else if (trimmed.startsWith("**Active Slice:**")) {
activeSlice = trimmed.replace("**Active Slice:**", "").trim() || null;
} else if (trimmed.startsWith("**Phase:**")) {
phase = trimmed.replace("**Phase:**", "").trim() || null;
} else if (trimmed.startsWith("- \u2705")) {
milestonesCompleted++;
milestonesTotal++;
} else if (trimmed.startsWith("- \u{1f504}")) {
milestonesTotal++;
}
}
return {
activeMilestone,
activeSlice,
phase,
milestonesCompleted,
milestonesTotal,
};
} catch {
return null;
}
}
function projectMetadata(
path: string,
includeProgress: boolean,
): ProjectMetadata {
recoverProjectRuntimeQueues(path);
const stat = statSync(path);
const signals = detectProject(path);
const kind = signals.hasSfFolder
? signals.hasMilestones
? "active-sf"
: "empty-sf"
: signals.hasGitRepo || signals.hasPackageJson
? "brownfield"
: "blank";
return {
name: basename(path),
path,
kind,
signals,
lastModified: stat.mtimeMs,
...(includeProgress ? { progress: readProgress(path) } : {}),
};
}
function recoverProjectRuntimeQueues(projectPath: string): void {
const runtimeDir = join(projectPath, ".sf", "runtime");
if (!existsSync(runtimeDir)) return;
let entries;
try {
entries = readdirSync(runtimeDir);
} catch {
return;
}
const orphanRe = new RegExp(
`^${SF_FEEDBACK_QUEUE_FILE.replace(/\./g, "\\.")}\\.(\\d+)\\.[^.]+\\.draining$`,
);
const queuePath = join(runtimeDir, SF_FEEDBACK_QUEUE_FILE);
for (const name of entries) {
const match = name.match(orphanRe);
if (!match) continue;
const orphanPid = Number(match[1]);
if (!Number.isFinite(orphanPid) || orphanPid <= 0) continue;
if (isPidAlive(orphanPid)) continue;
const orphanPath = join(runtimeDir, name);
try {
if (existsSync(queuePath)) {
appendFileSync(queuePath, readFileSync(orphanPath, "utf-8"), "utf-8");
unlinkSync(orphanPath);
} else {
renameSync(orphanPath, queuePath);
}
} catch {
// Best effort only; a later RPC/web probe can retry recovery.
}
}
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "EPERM"
);
}
}
function discoverProjects(root: string, includeProgress: boolean) {
const explicitProjects = readExplicitProjectPaths();
if (explicitProjects.length > 0) {
return explicitProjects
.filter((path) => existsSync(path))
.filter((path) => {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
})
.map((path) => projectMetadata(path, includeProgress))
.sort((a, b) => a.name.localeCompare(b.name));
}
const rootSignals = detectProject(root);
if (rootSignals.hasSfFolder || rootSignals.isMonorepo) {
return [projectMetadata(root, includeProgress)];
}
const seen = new Set<string>();
const projects = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.filter((entry) => !entry.name.startsWith("."))
.filter((entry) => !EXCLUDED_DIRS.has(entry.name))
.map((entry) => join(root, entry.name))
.filter((path) => {
const signals = detectProject(path);
return (
signals.hasSfFolder || signals.hasGitRepo || signals.hasPackageJson
);
})
.map((path) => projectMetadata(path, includeProgress))
.map((project) => {
seen.add(project.path);
return project;
});
for (const nestedSfProject of findNestedSfProjects(root)) {
if (seen.has(nestedSfProject)) continue;
const project = projectMetadata(nestedSfProject, includeProgress);
projects.push({
...project,
name: nestedSfProject.slice(root.length + 1),
});
seen.add(nestedSfProject);
}
return projects.sort((a, b) => a.name.localeCompare(b.name));
}
function readExplicitProjectPaths(): string[] {
try {
const prefs = JSON.parse(
readFileSync(webPreferencesPath, "utf8"),
) as WebPreferences;
if (!Array.isArray(prefs.projectPaths)) return [];
return prefs.projectPaths
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => resolve(value));
} catch {
return [];
}
}
function findNestedSfProjects(root: string): string[] {
const projects: string[] = [];
const walk = (dir: string, depth: number) => {
if (depth > MAX_NESTED_SF_DEPTH) return;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
if (EXCLUDED_DIRS.has(entry.name)) continue;
const fullPath = join(dir, entry.name);
if (detectProject(fullPath).hasSfFolder) projects.push(fullPath);
walk(fullPath, depth + 1);
}
};
walk(root, 1);
return projects;
}
// Returns discovered projects under a configured development root.
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const rootParam = req.query.root ?? req.query.devRoot;
const devRoot = Array.isArray(rootParam) ? rootParam[0] : rootParam;
if (!devRoot) return res.status(400).json({ error: "Missing devRoot" });
try {
const includeProgress = req.query.detail === "true";
res.status(200).json(discoverProjects(devRoot, includeProgress));
} catch (e) {
res.status(500).json({ error: (e as Error).message });
}
}