diff --git a/.agents/session-log.md b/.agents/session-log.md index e5d229b..a062859 100644 --- a/.agents/session-log.md +++ b/.agents/session-log.md @@ -1,5 +1,20 @@ # Session Log - QuantLab +## 2026-04-10 — Real-Path Desktop Validation and Smoke Semantics (Issue #275) +- **Session Focus**: Restore an honest distinction between fallback desktop smoke and real-path desktop validation. +- **Tasks Completed**: + - Updated `desktop/scripts/smoke.js` to parse an explicit `--mode` and pass the selected smoke mode into Electron. + - Kept fallback smoke on the local-shell path by disabling server boot only in fallback mode. + - Hardened `desktop/main.js` so real-path smoke only succeeds when `research_ui` becomes reachable instead of accepting local-runs fallback. + - Updated GitHub Actions to run the explicit `smoke:real-path` path under the dedicated `desktop-real-path` job name. +- **Key Decisions**: + - `smoke:fallback` remains useful for shell/bootstrap coverage, but its semantics stay intentionally tolerant. + - `smoke:real-path` now means real `research_ui` reachability; local runs no longer count as success for that mode. + - CI naming now reflects the stronger guarantee instead of implying generic smoke coverage. +- **Validation Notes**: + - Validate locally with both `npm run smoke:fallback` and `npm run smoke:real-path` from `desktop/`. + - Confirm workflow syntax and behavior with the desktop smoke commands before opening the PR. + ## 2026-03-24 — Canonical Run Machine Contract (Issue #62) - **Session Focus**: Reduce the remaining contract asymmetry between plain `run` and `sweep` inside canonical `report.json`. - **Tasks Completed**: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bd1fdf..e4bb11e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: | python -m pytest -q - desktop-smoke: + desktop-real-path: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -53,7 +53,7 @@ jobs: run: | npm ci - - name: Run desktop smoke + - name: Run desktop real-path smoke working-directory: desktop run: | - xvfb-run -a npm run smoke + xvfb-run -a npm run smoke:real-path diff --git a/desktop/main.js b/desktop/main.js index d29660e..173e010 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -30,6 +30,7 @@ const RESEARCH_UI_HEALTH_PATH = "/api/paper-sessions-health"; const RESEARCH_UI_STARTUP_TIMEOUT_MS = 25000; const ELECTRON_STATE_ROOT = path.join(DESKTOP_OUTPUTS_ROOT, "electron"); const IS_SMOKE_RUN = process.env.QUANTLAB_DESKTOP_SMOKE === "1"; +const SMOKE_MODE = process.env.QUANTLAB_DESKTOP_SMOKE_MODE === "real-path" ? "real-path" : "fallback"; const SMOKE_OUTPUT_PATH = process.env.QUANTLAB_DESKTOP_SMOKE_OUTPUT || ""; const SKIP_RESEARCH_UI_BOOT = process.env.QUANTLAB_DESKTOP_DISABLE_SERVER_BOOT === "1"; let smokeResultPersisted = false; @@ -364,6 +365,19 @@ async function readProjectJson(targetPath) { } } +async function readJsonFile(targetPath) { + const raw = await fsp.readFile(targetPath, "utf8"); + try { + return JSON.parse(raw); + } catch (_error) { + const sanitized = raw + .replace(/\bNaN\b/g, "null") + .replace(/\b-Infinity\b/g, "null") + .replace(/\bInfinity\b/g, "null"); + return JSON.parse(sanitized); + } +} + function parseYamlSectionValue(raw, sectionName, keyName) { const sectionPattern = new RegExp(`^${sectionName}:\\s*\\r?\\n([\\s\\S]*?)(?=^\\S|\\Z)`, "m"); const sectionMatch = String(raw || "").match(sectionPattern); @@ -905,16 +919,26 @@ async function runDesktopSmoke() { await new Promise((resolve) => setTimeout(resolve, 250)); } try { - const localRuns = await readProjectJson("outputs/runs/runs_index.json"); + const localRuns = IS_SMOKE_RUN + ? await readJsonFile(path.join(OUTPUTS_ROOT, "runs", "runs_index.json")) + : await readProjectJson("outputs/runs/runs_index.json"); result.localRunsReady = Array.isArray(localRuns?.runs); } catch (_error) { result.localRunsReady = false; } - result.shellReady = result.bridgeReady && (result.serverReady || result.localRunsReady); + result.shellReady = result.bridgeReady && ( + SMOKE_MODE === "real-path" + ? result.serverReady + : (result.serverReady || result.localRunsReady) + ); if (!result.serverReady) { - result.error = workspaceState.error || (result.localRunsReady - ? "research_ui did not become reachable, but the shell loaded via the local runs index." - : "research_ui did not become reachable during smoke run."); + result.error = workspaceState.error || ( + SMOKE_MODE === "real-path" + ? "research_ui did not become reachable during real-path smoke run." + : (result.localRunsReady + ? "research_ui did not become reachable, but the shell loaded via the local runs index." + : "research_ui did not become reachable during smoke run.") + ); } } catch (error) { result.error = error.message; diff --git a/desktop/scripts/smoke.js b/desktop/scripts/smoke.js index cdf5db4..4491b4d 100644 --- a/desktop/scripts/smoke.js +++ b/desktop/scripts/smoke.js @@ -6,7 +6,19 @@ const { spawn } = require("child_process"); /** @typedef {import("../shared/models/smoke").SmokeMode} SmokeMode */ /** @typedef {import("../shared/models/smoke").SmokeResult} SmokeResult */ +function parseSmokeMode(argv) { + const rawMode = argv + .map((entry) => String(entry || "").trim()) + .find((entry) => entry.startsWith("--mode=")) + ?.slice("--mode=".length); + if (!rawMode || rawMode === "fallback" || rawMode === "real-path") { + return /** @type {SmokeMode} */ (rawMode || "fallback"); + } + throw new Error(`Unsupported desktop smoke mode: ${rawMode}`); +} + async function main() { + const mode = parseSmokeMode(process.argv.slice(2)); const desktopRoot = path.resolve(__dirname, ".."); const projectRoot = path.resolve(desktopRoot, ".."); const electronBinary = require("electron"); @@ -15,8 +27,8 @@ async function main() { const outputPath = path.join(tempRoot, "result.json"); const desktopOutputsRoot = path.join(tempRoot, "outputs"); const workspaceStatePath = path.join(desktopOutputsRoot, "desktop", "workspace_state.json"); - const projectRunsDir = path.join(projectRoot, "outputs", "runs"); - const projectRunsIndexPath = path.join(projectRunsDir, "runs_index.json"); + const smokeRunsDir = path.join(desktopOutputsRoot, "runs"); + const smokeRunsIndexPath = path.join(smokeRunsDir, "runs_index.json"); let seededRunsIndex = false; await fs.mkdir(path.dirname(workspaceStatePath), { recursive: true }); @@ -51,11 +63,11 @@ async function main() { ); try { - await fs.access(projectRunsIndexPath); + await fs.access(smokeRunsIndexPath); } catch (_error) { - await fs.mkdir(projectRunsDir, { recursive: true }); + await fs.mkdir(smokeRunsDir, { recursive: true }); await fs.writeFile( - projectRunsIndexPath, + smokeRunsIndexPath, `${JSON.stringify({ updated_at: new Date().toISOString(), runs: [ @@ -86,9 +98,10 @@ async function main() { env: { ...process.env, QUANTLAB_DESKTOP_SMOKE: "1", + QUANTLAB_DESKTOP_SMOKE_MODE: mode, QUANTLAB_DESKTOP_SMOKE_OUTPUT: outputPath, QUANTLAB_DESKTOP_OUTPUTS_ROOT: desktopOutputsRoot, - QUANTLAB_DESKTOP_DISABLE_SERVER_BOOT: "1", + ...(mode === "fallback" ? { QUANTLAB_DESKTOP_DISABLE_SERVER_BOOT: "1" } : {}), }, }); let stdout = ""; @@ -129,10 +142,15 @@ async function main() { ); } - if (exitCode !== 0 || !result.bridgeReady || !result.shellReady) { + const passed = + mode === "real-path" + ? exitCode === 0 && result.bridgeReady && result.serverReady && result.apiReady + : exitCode === 0 && result.bridgeReady && result.shellReady; + + if (!passed) { if (stdout.trim()) console.error(stdout.trim()); if (stderr.trim()) console.error(stderr.trim()); - throw new Error(`Desktop smoke failed: ${JSON.stringify(result)}`); + throw new Error(`Desktop smoke (${mode}) failed: ${JSON.stringify(result)}`); } const persistedWorkspaceRaw = await fs.readFile(workspaceStatePath, "utf8"); @@ -144,13 +162,15 @@ async function main() { } console.log( - result.serverReady - ? `Desktop smoke passed via ${result.serverUrl}` - : "Desktop smoke passed via local runs fallback", + mode === "real-path" + ? `Desktop smoke real-path passed via ${result.serverUrl}` + : (result.serverReady + ? `Desktop smoke fallback passed via ${result.serverUrl}` + : "Desktop smoke fallback passed via local runs fallback"), ); } finally { if (seededRunsIndex) { - await fs.rm(projectRunsIndexPath, { force: true }).catch(() => {}); + await fs.rm(smokeRunsIndexPath, { force: true }).catch(() => {}); } } }