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>
292 lines
7.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|