singularity-forge/src/web/settings-service.ts
Tom Boucher 2e04253c0b fix: resolve Node v24 web boot failure — ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING (#1864)
Node v24 forbids --experimental-strip-types for files under node_modules/.
When GSD is globally installed, all src/ files live under node_modules/gsd-pi/,
causing every subprocess worker to crash with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.

Bug 1: Extract resolveTypeStrippingFlag() into src/web/ts-subprocess-flags.ts.
When the package root is under node_modules/ and Node >= 22.7, the function
returns --experimental-transform-types (which handles node_modules paths).
All 15 service files and cli-entry.ts now call this function instead of
hardcoding --experimental-strip-types.

Bug 2: waitForBootReady() now tracks consecutive 5xx responses and aborts
after 3 in a row, including the response body in the error message.
Connection-level errors (transient during cold start) reset the counter.

Bug 3: The /api/boot route handler now wraps collectBootPayload() in
try/catch and returns { error: message } with status 500, matching the
error response pattern used by other API routes.

Fixes #1849

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:24:07 -06:00

150 lines
6.1 KiB
TypeScript

import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
import { pathToFileURL } from "node:url"
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts"
import { resolveTypeStrippingFlag } from "./ts-subprocess-flags.ts"
import type { SettingsData } from "../../web/lib/settings-types.ts"
const SETTINGS_MAX_BUFFER = 2 * 1024 * 1024
function resolveModulePath(packageRoot: string, moduleName: string): string {
return join(packageRoot, "src", "resources", "extensions", "gsd", moduleName)
}
function resolveTsLoaderPath(packageRoot: string): string {
return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")
}
/**
* Loads settings data via a child process. Calls upstream extension modules
* for preferences, routing config, budget allocation, routing history, and
* project totals, then combines results into a single SettingsData payload.
*
* Uses the same child-process pattern as forensics-service.ts — Turbopack
* cannot resolve the .js extension imports these upstream modules use, so
* execFile + resolve-ts.mjs is required.
*/
export async function collectSettingsData(projectCwdOverride?: string): Promise<SettingsData> {
const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride)
const { packageRoot, projectCwd } = config
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
const prefsPath = resolveModulePath(packageRoot, "preferences.ts")
const routerPath = resolveModulePath(packageRoot, "model-router.ts")
const budgetPath = resolveModulePath(packageRoot, "context-budget.ts")
const historyPath = resolveModulePath(packageRoot, "routing-history.ts")
const metricsPath = resolveModulePath(packageRoot, "metrics.ts")
const requiredPaths = [resolveTsLoader, prefsPath, routerPath, budgetPath, historyPath, metricsPath]
for (const p of requiredPaths) {
if (!existsSync(p)) {
throw new Error(`settings data provider not found; missing=${p}`)
}
}
// The child script loads all upstream modules, calls the 5 data functions,
// and writes a combined JSON payload to stdout.
const script = [
'const { pathToFileURL } = await import("node:url");',
'const prefsMod = await import(pathToFileURL(process.env.GSD_SETTINGS_PREFS_MODULE).href);',
'const routerMod = await import(pathToFileURL(process.env.GSD_SETTINGS_ROUTER_MODULE).href);',
'const budgetMod = await import(pathToFileURL(process.env.GSD_SETTINGS_BUDGET_MODULE).href);',
'const historyMod = await import(pathToFileURL(process.env.GSD_SETTINGS_HISTORY_MODULE).href);',
'const metricsMod = await import(pathToFileURL(process.env.GSD_SETTINGS_METRICS_MODULE).href);',
// 1. Effective preferences (may be null if no preferences files exist)
'const loaded = prefsMod.loadEffectiveGSDPreferences();',
'let preferences = null;',
'if (loaded) {',
' const p = loaded.preferences;',
' preferences = {',
' mode: p.mode,',
' budgetCeiling: p.budget_ceiling,',
' budgetEnforcement: p.budget_enforcement,',
' tokenProfile: p.token_profile,',
' dynamicRouting: p.dynamic_routing,',
' customInstructions: p.custom_instructions,',
' alwaysUseSkills: p.always_use_skills,',
' preferSkills: p.prefer_skills,',
' avoidSkills: p.avoid_skills,',
' autoSupervisor: p.auto_supervisor ? {',
' enabled: true,',
' softTimeoutMinutes: p.auto_supervisor.soft_timeout_minutes,',
' } : undefined,',
' uatDispatch: p.uat_dispatch,',
' autoVisualize: p.auto_visualize,',
' remoteQuestions: p.remote_questions ? {',
' channel: p.remote_questions.channel,',
' channelId: String(p.remote_questions.channel_id),',
' timeoutMinutes: p.remote_questions.timeout_minutes,',
' pollIntervalSeconds: p.remote_questions.poll_interval_seconds,',
' } : undefined,',
' scope: loaded.scope,',
' path: loaded.path,',
' warnings: loaded.warnings,',
' };',
'}',
// 2. Resolved dynamic routing config (always returns a config with defaults)
'const routingConfig = prefsMod.resolveDynamicRoutingConfig();',
// 3. Budget allocation (use 200K as default context window)
'const budgetAllocation = budgetMod.computeBudgets(200000);',
// 4. Routing history (must init before reading)
'historyMod.initRoutingHistory(process.env.GSD_SETTINGS_BASE);',
'const routingHistory = historyMod.getRoutingHistory();',
// 5. Project totals (null if no metrics ledger exists)
'const ledger = metricsMod.loadLedgerFromDisk(process.env.GSD_SETTINGS_BASE);',
'const projectTotals = ledger ? metricsMod.getProjectTotals(ledger.units) : null;',
// Write combined payload
'process.stdout.write(JSON.stringify({ preferences, routingConfig, budgetAllocation, routingHistory, projectTotals }));',
].join(" ")
return await new Promise<SettingsData>((resolveResult, reject) => {
execFile(
process.execPath,
[
"--import",
pathToFileURL(resolveTsLoader).href,
resolveTypeStrippingFlag(packageRoot),
"--input-type=module",
"--eval",
script,
],
{
cwd: packageRoot,
env: {
...process.env,
GSD_SETTINGS_PREFS_MODULE: prefsPath,
GSD_SETTINGS_ROUTER_MODULE: routerPath,
GSD_SETTINGS_BUDGET_MODULE: budgetPath,
GSD_SETTINGS_HISTORY_MODULE: historyPath,
GSD_SETTINGS_METRICS_MODULE: metricsPath,
GSD_SETTINGS_BASE: projectCwd,
},
maxBuffer: SETTINGS_MAX_BUFFER,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(`settings data subprocess failed: ${stderr || error.message}`))
return
}
try {
resolveResult(JSON.parse(stdout) as SettingsData)
} catch (parseError) {
reject(
new Error(
`settings data subprocess returned invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
),
)
}
},
)
})
}