singularity-forge/src/web/visualizer-service.ts
ace-pm d501ca7d6d fix: clean up git state after directory restoration
- Accept deletion of gsd-phase-state.ts (renamed to forge-phase-state.ts earlier)
- Accept deletion of create-gsd-extension/ (renamed to create-forge-extension/ earlier)
- These renames were part of the rebrand and are preserved in commit history

Stabilize git state after restoration operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:34:53 +02:00

121 lines
4.3 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, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts"
const VISUALIZER_MAX_BUFFER = 2 * 1024 * 1024
const VISUALIZER_MODULE_ENV = "GSD_VISUALIZER_MODULE"
/**
* Browser-safe version of VisualizerData where Map fields are converted to
* plain Records so JSON.stringify serializes them correctly.
*
* Without this conversion, `JSON.stringify(new Map([["M001", 0]]))` produces
* `"{}"` — silently losing all critical-path slack data.
*/
export interface SerializedVisualizerData {
milestones: unknown[]
phase: string
totals: unknown | null
byPhase: unknown[]
bySlice: unknown[]
byModel: unknown[]
units: unknown[]
criticalPath: {
milestonePath: string[]
slicePath: string[]
milestoneSlack: Record<string, number>
sliceSlack: Record<string, number>
}
remainingSliceCount: number
agentActivity: unknown | null
changelog: unknown
}
function resolveTsLoaderPath(packageRoot: string): string {
return join(packageRoot, "src", "resources", "extensions", "gsd", "tests", "resolve-ts.mjs")
}
/**
* Loads visualizer data from the current project's filesystem via a child
* process (required because upstream .ts files use Node ESM .js import
* extensions that Turbopack cannot resolve). Converts Map fields to Records
* for safe JSON serialization.
*/
export async function collectVisualizerData(projectCwdOverride?: string): Promise<SerializedVisualizerData> {
const config = resolveBridgeRuntimeConfig(undefined, projectCwdOverride)
const { packageRoot, projectCwd } = config
const resolveTsLoader = resolveTsLoaderPath(packageRoot)
const moduleResolution = resolveSubprocessModule(packageRoot, "resources/extensions/sf/visualizer-data.ts")
const visualizerModulePath = moduleResolution.modulePath
if (!moduleResolution.useCompiledJs && (!existsSync(resolveTsLoader) || !existsSync(visualizerModulePath))) {
throw new Error(
`visualizer data provider not found; checked=${resolveTsLoader},${visualizerModulePath}`,
)
}
if (moduleResolution.useCompiledJs && !existsSync(visualizerModulePath)) {
throw new Error(`visualizer data provider not found; checked=${visualizerModulePath}`)
}
// The child script loads the upstream module, calls loadVisualizerData(),
// converts Map fields to Records, and writes JSON to stdout.
const script = [
'const { pathToFileURL } = await import("node:url");',
`const mod = await import(pathToFileURL(process.env.${VISUALIZER_MODULE_ENV}).href);`,
`const data = await mod.loadVisualizerData(process.env.GSD_VISUALIZER_BASE);`,
'const result = {',
' ...data,',
' criticalPath: {',
' milestonePath: data.criticalPath.milestonePath,',
' slicePath: data.criticalPath.slicePath,',
' milestoneSlack: Object.fromEntries(data.criticalPath.milestoneSlack),',
' sliceSlack: Object.fromEntries(data.criticalPath.sliceSlack),',
' },',
'};',
'process.stdout.write(JSON.stringify(result));',
].join(" ")
const prefixArgs = buildSubprocessPrefixArgs(packageRoot, moduleResolution, pathToFileURL(resolveTsLoader).href)
return await new Promise<SerializedVisualizerData>((resolveResult, reject) => {
execFile(
process.execPath,
[
...prefixArgs,
"--eval",
script,
],
{
cwd: packageRoot,
env: {
...process.env,
[VISUALIZER_MODULE_ENV]: visualizerModulePath,
GSD_VISUALIZER_BASE: projectCwd,
},
maxBuffer: VISUALIZER_MAX_BUFFER,
windowsHide: true,
},
(error, stdout, stderr) => {
if (error) {
reject(new Error(`visualizer data subprocess failed: ${stderr || error.message}`))
return
}
try {
resolveResult(JSON.parse(stdout) as SerializedVisualizerData)
} catch (parseError) {
reject(
new Error(
`visualizer data subprocess returned invalid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
),
)
}
},
)
})
}