feat: add circular dep detection tool + fix duplicate milestone dirs + fix metrics NULL

- Add scripts/check-circular-deps.mjs using madge; npm run check:circular
  and check:circular:ext scan src/ and the SF extension respectively
- findMilestoneIds() is now DB-first: reads from milestones table when DB is
  open so stale/duplicate filesystem dirs (M001/ and M001-6377a4/) are never
  returned; falls back to fs scan only during early bootstrap
- milestone-id-utils.js was a stale duplicate; replaced with re-exports from
  canonical milestone-ids.js
- metrics-central.js: guard null/undefined counter/gauge/histogram values
  with ?? 0 to prevent NOT NULL constraint failure on metrics.value

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-10 01:56:08 +02:00
parent 15185c2e7d
commit ea360f6ad2
6 changed files with 1701 additions and 225 deletions

1419
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,190 +1,193 @@
{
"name": "singularity-forge",
"version": "2.75.3",
"description": "Singularity Forge runtime core",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/singularity-ng/singularity-forge.git"
},
"homepage": "https://github.com/singularity-ng/singularity-forge#readme",
"bugs": {
"url": "https://github.com/singularity-ng/singularity-forge/issues"
},
"type": "module",
"workspaces": [
"packages/*"
],
"bin": {
"sf": "dist/loader.js",
"sf-cli": "dist/loader.js",
"sf-daemon": "packages/daemon/dist/cli.js",
"sf-server": "packages/daemon/dist/cli.js"
},
"files": [
"dist",
"dist/web",
"packages",
"pkg",
"src/resources",
"scripts/postinstall.js",
"scripts/link-workspace-packages.cjs",
"scripts/ensure-workspace-builds.cjs",
"package.json",
"README.md"
],
"piConfig": {
"name": "sf",
"configDir": ".sf"
},
"engines": {
"node": ">=26.1.0"
},
"packageManager": "npm@11.13.0",
"scripts": {
"build:pi-tui": "npm --workspace @singularity-forge/pi-tui run build",
"build:pi-ai": "npm --workspace @singularity-forge/pi-ai run build",
"build:pi-agent-core": "npm --workspace @singularity-forge/pi-agent-core run build",
"build:pi-coding-agent": "npm --workspace @singularity-forge/pi-coding-agent run build",
"build:native-pkg": "npm --workspace @singularity-forge/native run build",
"build:rpc-client": "npm --workspace @singularity-forge/rpc-client run build",
"build:google-gemini-cli-provider": "npm --workspace @singularity-forge/google-gemini-cli-provider run build",
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:google-gemini-cli-provider && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
"build:daemon": "npm --workspace @singularity-forge/daemon run build",
"build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
"build": "npm run build:core && node scripts/build-web-if-stale.cjs",
"stage:web-host": "node scripts/stage-web-standalone.cjs",
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
"docs:features": "node scripts/generate-features-inventory.mjs",
"copy-resources": "node scripts/copy-resources.cjs",
"copy-themes": "node scripts/copy-themes.cjs",
"copy-export-html": "node scripts/copy-export-html.cjs",
"test:unit": "npx vitest run --config vitest.config.ts",
"test:packages": "node --test packages/pi-coding-agent/dist/core/*.test.js packages/pi-coding-agent/dist/core/tools/spawn-shell-windows.test.js",
"test:marketplace": "npx vitest run src/resources/extensions/sf/tests/claude-import-tui.test.ts src/tests/marketplace-discovery.test.ts --config vitest.config.ts",
"test:sf-light": "npx vitest run src/resources/extensions/sf/tests --config vitest.config.ts",
"test:coverage": "npx vitest run --config vitest.config.ts --coverage",
"test:integration": "npx vitest run src/tests/integration src/resources/extensions/sf/tests/integration src/resources/extensions/async-jobs src/resources/extensions/browser-tools/tests --config vitest.config.ts",
"pretest": "npm run typecheck:extensions",
"test": "npm run test:unit && npm run test:integration",
"test:smoke": "node --experimental-strip-types tests/smoke/run.ts",
"test:fixtures": "node --experimental-strip-types tests/fixtures/run.ts",
"test:fixtures:record": "node scripts/with-env.mjs SF_FIXTURE_MODE=record -- node --experimental-strip-types tests/fixtures/record.ts",
"test:live": "node scripts/with-env.mjs SF_LIVE_TESTS=1 -- node --experimental-strip-types tests/live/run.ts",
"test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs",
"test:native": "node --test packages/rust-engine/src/__tests__/grep.test.mjs",
"test:secret-scan": "node --import ./src/resources/extensions/sf/tests/resolve-ts.mjs --experimental-strip-types --test src/tests/secret-scan.test.ts",
"secret-scan": "node scripts/secret-scan.mjs",
"secret-scan:install-hook": "node scripts/install-hooks.mjs",
"build:native": "node rust-engine/scripts/build.js",
"build:native:dev": "node rust-engine/scripts/build.js --dev",
"dev": "node scripts/dev.js",
"sf": "node scripts/dev-cli.js",
"sf-dev": "node scripts/dev-server.js --verbose --start .",
"sf:dev": "npm run sf-dev",
"sf:server": "node scripts/dev-server.js",
"sf:server:dist": "node packages/daemon/dist/cli.js",
"sf:web": "npm run build:pi && npm run copy-resources && node scripts/build-web-if-stale.cjs && node scripts/dev-cli.js --web",
"sf:web:stop": "node scripts/dev-cli.js web stop",
"sf:web:stop:all": "node scripts/dev-cli.js web stop all",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/ensure-workspace-builds.cjs && node scripts/postinstall.js",
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
"sync-platform-versions": "node rust-engine/scripts/sync-platform-versions.cjs",
"validate-pack": "node scripts/validate-pack.js",
"typecheck": "npm run build:pi && tsc --noEmit",
"typecheck:extensions": "npm run check:versioned-json && tsc --noEmit --project tsconfig.extensions.json",
"check:sf-inventory": "node scripts/check-sf-extension-inventory.mjs",
"check:protected-deletions": "node scripts/check-protected-deletions.mjs",
"check:versioned-json": "node scripts/check-protected-deletions.mjs && node scripts/check-versioned-json.mjs && npm run check:sf-inventory",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "npm run check:versioned-json && biome check .",
"lint:fix": "npm run check:versioned-json && biome check --write .",
"pipeline:version-stamp": "node scripts/version-stamp.mjs",
"release:changelog": "node scripts/generate-changelog.mjs",
"release:bump": "node scripts/bump-version.mjs",
"release:update-changelog": "node scripts/update-changelog.mjs",
"docker:build-runtime": "docker build --target runtime -t ghcr.io/singularity-ng/singularity-forge .",
"docker:build-builder": "docker build --target builder -t ghcr.io/singularity-forge/sf-ci-builder .",
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.1",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
"@clack/prompts": "^1.3.0",
"@google/gemini-cli-core": "^0.41.2",
"@google/genai": "^2.0.0",
"@logtape/file": "^2.0.7",
"@logtape/logtape": "^2.0.7",
"@logtape/pretty": "^2.0.7",
"@logtape/redaction": "^2.0.7",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.7.0",
"@types/mime-types": "^2.1.4",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"diff": "^9.0.0",
"discord.js": "^14.26.4",
"extract-zip": "^2.0.1",
"fast-check": "^4.7.0",
"file-type": "^21.1.1",
"get-east-asian-width": "^1.6.0",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jsonrepair": "^3.14.0",
"markdownlint": "^0.40.0",
"marked": "^18.0.3",
"mime-types": "^3.0.1",
"minimatch": "^10.2.5",
"openai": "^6.37.0",
"picomatch": "^4.0.3",
"playwright": "^1.59.1",
"proper-lockfile": "^4.1.2",
"proxy-agent": "^8.0.1",
"remark-parse": "^11.0.0",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"strip-ansi": "^7.1.0",
"undici": "^8.2.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.2",
"@types/picomatch": "^4.0.3",
"@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^4.1.5",
"esbuild": "^0.27.7",
"jiti": "^2.7.0",
"jscpd": "^4.0.9",
"typescript": "^6.0.3",
"typescript-language-server": "^5.1.3",
"vitest": "^4.1.5"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.137",
"@singularity-forge/engine-darwin-arm64": ">=2.10.2",
"@singularity-forge/engine-darwin-x64": ">=2.10.2",
"@singularity-forge/engine-linux-arm64-gnu": ">=2.10.2",
"@singularity-forge/engine-linux-x64-gnu": ">=2.10.2",
"@singularity-forge/engine-win32-x64-msvc": ">=2.10.2",
"fsevents": "~2.3.3",
"koffi": "^2.16.2",
"vectordrive": "^0.1.35"
}
"name": "singularity-forge",
"version": "2.75.3",
"description": "Singularity Forge runtime core",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/singularity-ng/singularity-forge.git"
},
"homepage": "https://github.com/singularity-ng/singularity-forge#readme",
"bugs": {
"url": "https://github.com/singularity-ng/singularity-forge/issues"
},
"type": "module",
"workspaces": [
"packages/*"
],
"bin": {
"sf": "dist/loader.js",
"sf-cli": "dist/loader.js",
"sf-daemon": "packages/daemon/dist/cli.js",
"sf-server": "packages/daemon/dist/cli.js"
},
"files": [
"dist",
"dist/web",
"packages",
"pkg",
"src/resources",
"scripts/postinstall.js",
"scripts/link-workspace-packages.cjs",
"scripts/ensure-workspace-builds.cjs",
"package.json",
"README.md"
],
"piConfig": {
"name": "sf",
"configDir": ".sf"
},
"engines": {
"node": ">=26.1.0"
},
"packageManager": "npm@11.13.0",
"scripts": {
"build:pi-tui": "npm --workspace @singularity-forge/pi-tui run build",
"build:pi-ai": "npm --workspace @singularity-forge/pi-ai run build",
"build:pi-agent-core": "npm --workspace @singularity-forge/pi-agent-core run build",
"build:pi-coding-agent": "npm --workspace @singularity-forge/pi-coding-agent run build",
"build:native-pkg": "npm --workspace @singularity-forge/native run build",
"build:rpc-client": "npm --workspace @singularity-forge/rpc-client run build",
"build:google-gemini-cli-provider": "npm --workspace @singularity-forge/google-gemini-cli-provider run build",
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:google-gemini-cli-provider && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
"build:daemon": "npm --workspace @singularity-forge/daemon run build",
"build:core": "npm run build:pi && npm run build:rpc-client && npm run build:daemon && npm run check:versioned-json && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html",
"build": "npm run build:core && node scripts/build-web-if-stale.cjs",
"stage:web-host": "node scripts/stage-web-standalone.cjs",
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
"docs:features": "node scripts/generate-features-inventory.mjs",
"copy-resources": "node scripts/copy-resources.cjs",
"copy-themes": "node scripts/copy-themes.cjs",
"copy-export-html": "node scripts/copy-export-html.cjs",
"test:unit": "npx vitest run --config vitest.config.ts",
"test:packages": "node --test packages/pi-coding-agent/dist/core/*.test.js packages/pi-coding-agent/dist/core/tools/spawn-shell-windows.test.js",
"test:marketplace": "npx vitest run src/resources/extensions/sf/tests/claude-import-tui.test.ts src/tests/marketplace-discovery.test.ts --config vitest.config.ts",
"test:sf-light": "npx vitest run src/resources/extensions/sf/tests --config vitest.config.ts",
"test:coverage": "npx vitest run --config vitest.config.ts --coverage",
"test:integration": "npx vitest run src/tests/integration src/resources/extensions/sf/tests/integration src/resources/extensions/async-jobs src/resources/extensions/browser-tools/tests --config vitest.config.ts",
"pretest": "npm run typecheck:extensions",
"test": "npm run test:unit && npm run test:integration",
"test:smoke": "node --experimental-strip-types tests/smoke/run.ts",
"test:fixtures": "node --experimental-strip-types tests/fixtures/run.ts",
"test:fixtures:record": "node scripts/with-env.mjs SF_FIXTURE_MODE=record -- node --experimental-strip-types tests/fixtures/record.ts",
"test:live": "node scripts/with-env.mjs SF_LIVE_TESTS=1 -- node --experimental-strip-types tests/live/run.ts",
"test:browser-tools": "node --test src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs src/resources/extensions/browser-tools/tests/browser-tools-integration.test.mjs",
"test:native": "node --test packages/rust-engine/src/__tests__/grep.test.mjs",
"test:secret-scan": "node --import ./src/resources/extensions/sf/tests/resolve-ts.mjs --experimental-strip-types --test src/tests/secret-scan.test.ts",
"secret-scan": "node scripts/secret-scan.mjs",
"secret-scan:install-hook": "node scripts/install-hooks.mjs",
"build:native": "node rust-engine/scripts/build.js",
"build:native:dev": "node rust-engine/scripts/build.js --dev",
"dev": "node scripts/dev.js",
"sf": "node scripts/dev-cli.js",
"sf-dev": "node scripts/dev-server.js --verbose --start .",
"sf:dev": "npm run sf-dev",
"sf:server": "node scripts/dev-server.js",
"sf:server:dist": "node packages/daemon/dist/cli.js",
"sf:web": "npm run build:pi && npm run copy-resources && node scripts/build-web-if-stale.cjs && node scripts/dev-cli.js --web",
"sf:web:stop": "node scripts/dev-cli.js web stop",
"sf:web:stop:all": "node scripts/dev-cli.js web stop all",
"postinstall": "node scripts/link-workspace-packages.cjs && node scripts/ensure-workspace-builds.cjs && node scripts/postinstall.js",
"pi:install-global": "node scripts/install-pi-global.js",
"pi:uninstall-global": "node scripts/uninstall-pi-global.js",
"sync-pkg-version": "node scripts/sync-pkg-version.cjs",
"sync-platform-versions": "node rust-engine/scripts/sync-platform-versions.cjs",
"validate-pack": "node scripts/validate-pack.js",
"typecheck": "npm run build:pi && tsc --noEmit",
"typecheck:extensions": "npm run check:versioned-json && tsc --noEmit --project tsconfig.extensions.json",
"check:sf-inventory": "node scripts/check-sf-extension-inventory.mjs",
"check:protected-deletions": "node scripts/check-protected-deletions.mjs",
"check:versioned-json": "node scripts/check-protected-deletions.mjs && node scripts/check-versioned-json.mjs && npm run check:sf-inventory",
"format": "biome format --write .",
"format:check": "biome format .",
"lint": "npm run check:versioned-json && biome check .",
"lint:fix": "npm run check:versioned-json && biome check --write .",
"pipeline:version-stamp": "node scripts/version-stamp.mjs",
"release:changelog": "node scripts/generate-changelog.mjs",
"release:bump": "node scripts/bump-version.mjs",
"release:update-changelog": "node scripts/update-changelog.mjs",
"docker:build-runtime": "docker build --target runtime -t ghcr.io/singularity-ng/singularity-forge .",
"docker:build-builder": "docker build --target builder -t ghcr.io/singularity-forge/sf-ci-builder .",
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts",
"check:circular": "node scripts/check-circular-deps.mjs",
"check:circular:ext": "node scripts/check-circular-deps.mjs --ext"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.1",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
"@clack/prompts": "^1.3.0",
"@google/gemini-cli-core": "^0.41.2",
"@google/genai": "^2.0.0",
"@logtape/file": "^2.0.7",
"@logtape/logtape": "^2.0.7",
"@logtape/pretty": "^2.0.7",
"@logtape/redaction": "^2.0.7",
"@mariozechner/jiti": "^2.6.2",
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.7.0",
"@types/mime-types": "^2.1.4",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"diff": "^9.0.0",
"discord.js": "^14.26.4",
"extract-zip": "^2.0.1",
"fast-check": "^4.7.0",
"file-type": "^21.1.1",
"get-east-asian-width": "^1.6.0",
"hosted-git-info": "^9.0.2",
"ignore": "^7.0.5",
"jsonrepair": "^3.14.0",
"markdownlint": "^0.40.0",
"marked": "^18.0.3",
"mime-types": "^3.0.1",
"minimatch": "^10.2.5",
"openai": "^6.37.0",
"picomatch": "^4.0.3",
"playwright": "^1.59.1",
"proper-lockfile": "^4.1.2",
"proxy-agent": "^8.0.1",
"remark-parse": "^11.0.0",
"sharp": "^0.34.5",
"shell-quote": "^1.8.3",
"strip-ansi": "^7.1.0",
"undici": "^8.2.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.2",
"@types/picomatch": "^4.0.3",
"@types/shell-quote": "^1.7.5",
"@vitest/coverage-v8": "^4.1.5",
"esbuild": "^0.27.7",
"jiti": "^2.7.0",
"jscpd": "^4.0.9",
"madge": "^8.0.0",
"typescript": "^6.0.3",
"typescript-language-server": "^5.1.3",
"vitest": "^4.1.5"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.137",
"@singularity-forge/engine-darwin-arm64": ">=2.10.2",
"@singularity-forge/engine-darwin-x64": ">=2.10.2",
"@singularity-forge/engine-linux-arm64-gnu": ">=2.10.2",
"@singularity-forge/engine-linux-x64-gnu": ">=2.10.2",
"@singularity-forge/engine-win32-x64-msvc": ">=2.10.2",
"fsevents": "~2.3.3",
"koffi": "^2.16.2",
"vectordrive": "^0.1.35"
}
}

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* check-circular-deps.mjs detect circular imports across the SF codebase.
*
* Usage:
* npm run check:circular # scan src/ + packages/
* npm run check:circular -- --ext # scan extension source only
* node scripts/check-circular-deps.mjs [--ext] [--json]
*
* Exit 0 = no cycles found. Exit 1 = cycles detected (or scan error).
*/
import madge from "madge";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
const args = process.argv.slice(2);
const extOnly = args.includes("--ext");
const jsonOut = args.includes("--json");
const entries = extOnly
? [resolve(root, "src/resources/extensions/sf")]
: [resolve(root, "src"), resolve(root, "packages")];
console.error(`Scanning: ${entries.map((e) => e.replace(root + "/", "")).join(", ")}`);
let result;
try {
result = await madge(entries, {
fileExtensions: ["js", "mjs", "ts"],
excludeRegExp: [
/node_modules/,
/\.test\.(js|mjs|ts)$/,
/\/dist\//,
/\/tests?\//,
],
detectiveOptions: {
es6: { mixedImports: true },
},
});
} catch (err) {
console.error(`Scan failed: ${err.message}`);
process.exit(1);
}
const cycles = result.circular();
if (jsonOut) {
console.log(JSON.stringify({ cycles, count: cycles.length }, null, 2));
} else if (cycles.length === 0) {
console.log("✅ No circular dependencies found.");
} else {
console.log(`${cycles.length} circular dependency chain(s) found:\n`);
for (const [i, chain] of cycles.entries()) {
console.log(` ${i + 1}. ${chain.join(" → ")}${chain[0]}`);
}
}
process.exit(cycles.length > 0 ? 1 : 0);

View file

@ -469,7 +469,7 @@ function persistMetricsToDb(registry, sessionId, _ignored) {
c.name,
"counter",
JSON.stringify(labels),
value,
value ?? 0,
ts,
sessionId,
);
@ -482,7 +482,7 @@ function persistMetricsToDb(registry, sessionId, _ignored) {
g.name,
"gauge",
JSON.stringify(labels),
value,
value ?? 0,
ts,
sessionId,
);
@ -493,7 +493,7 @@ function persistMetricsToDb(registry, sessionId, _ignored) {
h.name,
"histogram",
JSON.stringify({ count: h.count, sum: h.sum }),
h.sum,
h.sum ?? 0,
ts,
sessionId,
);

View file

@ -1,27 +1,10 @@
import { readdirSync } from "node:fs";
import { milestonesDir } from "./paths.js";
/** Matches both classic `M001` and unique `M001-abc123` formats (anchored). */
export const MILESTONE_ID_RE = /^M\d{3}(?:-[a-z0-9]{6})?$/;
/** Extract the trailing sequential number from a milestone ID. Returns 0 for non-matches. */
export function extractMilestoneSeq(id) {
const match = id.match(/^M(\d{3})(?:-[a-z0-9]{6})?$/);
return match ? parseInt(match[1], 10) : 0;
}
/** Comparator for sorting milestone IDs by sequential number. */
export function milestoneIdSort(a, b) {
return extractMilestoneSeq(a) - extractMilestoneSeq(b);
}
export function findMilestoneIds(basePath) {
const dir = milestonesDir(basePath);
try {
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const match = entry.name.match(/^(M\d+(?:-[a-z0-9]{6})?)/);
return match ? match[1] : entry.name;
})
.sort(milestoneIdSort);
} catch {
return [];
}
}
/**
* Re-exports from the canonical milestone-ids.js.
* This file exists for backwards compatibility with older import paths.
*/
export {
MILESTONE_ID_RE,
extractMilestoneSeq,
milestoneIdSort,
findMilestoneIds,
} from "./milestone-ids.js";

View file

@ -10,6 +10,7 @@ import { getErrorMessage } from "./error-utils.js";
import { milestonesDir } from "./paths.js";
import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js";
import { logWarning } from "./workflow-logger.js";
import { getAllMilestones } from "./sf-db.js";
// ─── Regex ──────────────────────────────────────────────────────────────────
/** Matches both classic `M001` and unique `M001-abc123` formats (anchored). */
export const MILESTONE_ID_RE = /^M\d{3}(?:-[a-z0-9]{6})?$/;
@ -91,8 +92,28 @@ export function clearReservedMilestoneIds() {
reservedMilestoneIds.clear();
}
// ─── Discovery ──────────────────────────────────────────────────────────────
/** Scan the milestones directory and return IDs sorted by queue order (or numeric fallback). */
/**
* Return milestone IDs, DB-first.
*
* When the DB is open, reads from the `milestones` table the canonical
* source of truth so stale or duplicated filesystem dirs (e.g. both
* `M001/` and `M001-6377a4/`) are never returned.
* Falls back to a filesystem scan only when the DB is not yet open (early
* bootstrap or first-init before `ensureDbOpen`).
*/
export function findMilestoneIds(basePath) {
// DB-first: avoids returning duplicate/legacy dirs from filesystem
try {
const dbRows = getAllMilestones();
if (dbRows.length > 0) {
const ids = dbRows.map((m) => m.id);
const customOrder = loadQueueOrder(basePath);
return sortByQueueOrder(ids, customOrder);
}
} catch {
// DB not open yet — fall through to filesystem scan
}
// Filesystem fallback (early bootstrap / first-init)
const dir = milestonesDir(basePath);
try {
const ids = readdirSync(dir, { withFileTypes: true })