singularity-forge/src/headless-query.ts
TÂCHES d80927f50d fix: guard activeMilestone.id access in discuss and headless paths (#2776)
* fix: guard activeMilestone.id access in discuss and headless paths

When upstream state corruption (#2772, #2770) produces an activeMilestone
object with undefined id, the existing `!state.activeMilestone` guard
passes (truthy object), and the undefined id propagates to SQLite where
better-sqlite3 throws "Missing named parameter 'mid'".

Strengthen guards at three call sites to check `!state.activeMilestone?.id`
so corrupted state falls through to the no-milestone recovery path.

Closes #2773

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add regression tests for activeMilestone.id guard

Covers the #2773 fix where a malformed activeMilestone object with
id: undefined bypassed the old truthiness check and caused a crash
in discuss and headless paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:05:19 -06:00

114 lines
4.1 KiB
TypeScript

/**
* Headless Query — `gsd headless query`
*
* Single read-only command that returns the full project snapshot as JSON
* to stdout, without spawning an LLM session. Instant (~50ms).
*
* Output: { state, next, cost }
* state — deriveState() output (phase, milestones, progress, blockers)
* next — dry-run dispatch preview (what auto-mode would do next)
* cost — aggregated parallel worker costs
*
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
* We use createJiti() here because this module is imported directly from cli.ts,
* bypassing the extension loader's jiti setup (#1137).
*/
import { createJiti } from '@mariozechner/jiti'
import { fileURLToPath } from 'node:url'
import type { GSDState } from './resources/extensions/gsd/types.js'
import { resolveBundledSourceResource } from './bundled-resource-path.js'
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
const gsdExtensionPath = (...segments: string[]) =>
resolveBundledSourceResource(import.meta.url, 'extensions', 'gsd', ...segments)
async function loadExtensionModules() {
const stateModule = await jiti.import(gsdExtensionPath('state.ts'), {}) as any
const dispatchModule = await jiti.import(gsdExtensionPath('auto-dispatch.ts'), {}) as any
const sessionModule = await jiti.import(gsdExtensionPath('session-status-io.ts'), {}) as any
const prefsModule = await jiti.import(gsdExtensionPath('preferences.ts'), {}) as any
return {
deriveState: stateModule.deriveState as (basePath: string) => Promise<GSDState>,
resolveDispatch: dispatchModule.resolveDispatch as (opts: any) => Promise<any>,
readAllSessionStatuses: sessionModule.readAllSessionStatuses as (basePath: string) => any[],
loadEffectiveGSDPreferences: prefsModule.loadEffectiveGSDPreferences as () => any,
}
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface QuerySnapshot {
state: GSDState
next: {
action: 'dispatch' | 'stop' | 'skip'
unitType?: string
unitId?: string
reason?: string
}
cost: {
workers: Array<{
milestoneId: string
pid: number
state: string
cost: number
lastHeartbeat: number
}>
total: number
}
}
export interface QueryResult {
exitCode: number
data?: QuerySnapshot
}
// ─── Implementation ─────────────────────────────────────────────────────────
export async function handleQuery(basePath: string): Promise<QueryResult> {
const { deriveState, resolveDispatch, readAllSessionStatuses, loadEffectiveGSDPreferences } = await loadExtensionModules()
const state = await deriveState(basePath)
// Derive next dispatch action
let next: QuerySnapshot['next']
if (!state.activeMilestone?.id) {
next = {
action: 'stop',
reason: state.phase === 'complete' ? 'All milestones complete.' : state.nextAction,
}
} else {
const loaded = loadEffectiveGSDPreferences()
const dispatch = await resolveDispatch({
basePath,
mid: state.activeMilestone.id,
midTitle: state.activeMilestone.title,
state,
prefs: loaded?.preferences,
})
next = {
action: dispatch.action,
unitType: dispatch.action === 'dispatch' ? dispatch.unitType : undefined,
unitId: dispatch.action === 'dispatch' ? dispatch.unitId : undefined,
reason: dispatch.action === 'stop' ? dispatch.reason : undefined,
}
}
// Aggregate parallel worker costs
const statuses = readAllSessionStatuses(basePath)
const workers = statuses.map((s) => ({
milestoneId: s.milestoneId,
pid: s.pid,
state: s.state,
cost: s.cost,
lastHeartbeat: s.lastHeartbeat,
}))
const snapshot: QuerySnapshot = {
state,
next,
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
}
process.stdout.write(JSON.stringify(snapshot) + '\n')
return { exitCode: 0, data: snapshot }
}