Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/session-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,4 +385,5 @@
- 2026-04-08: Updated `.agents/workflow.md` so chat ownership is now a constant repo rule. The workflow file now defines stable Engine versus Desktop/UI ownership, cross-boundary stop rules, branch and dirty-tree preflight checks, and a requirement to record the validation actually run.
- 2026-04-08: Started issue #214 renderer tab binding registry hardening. This slice is limited to `desktop/renderer/app.js` plus `.agents` continuity, and replaces the long `bindTabContentEvents` conditional chain with a local handler registry without changing renderer behavior.
- 2026-04-08: Completed issue #214 renderer tab binding registry hardening. `bindTabContentEvents` now dispatches through a local `TAB_CONTENT_EVENT_BINDERS` registry in `desktop/renderer/app.js`, and `npm run smoke` still passes via local runs fallback.
- 2026-04-10: Started issue #346 to harden `desktop-smoke` result persistence after CI repeatedly failed with raw `ENOENT` on missing `result.json` in a planning-only PR. The slice is limited to `desktop/main.js`, `desktop/scripts/smoke.js`, and `.agents` continuity so smoke emits structured failures instead of crashing when Electron exits too early.
- 2026-04-10: Added the desktop layout regression remediation block after reviewing the post-merge desktop state in real screenshots. Opened issues #342, #343, and #344 to target empty-pane collapse, stronger active-surface focus and context containment, and better runs-family density plus right-rail space budgeting without reopening core or `research_ui` scope.
44 changes: 44 additions & 0 deletions .agents/tasks/issue-346-desktop-smoke-result-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Issue #346 — Desktop smoke result persistence in CI

## Goal
Prevent `desktop-smoke` from failing with a raw missing-file error before the smoke harness can read or emit a structured result.

---

## Why this matters
Planning-only Desktop/UI PRs are being blocked by a smoke harness failure that occurs before `result.json` exists. The harness needs a reliable failure path even when Electron exits before the normal smoke callback completes.

---

## Scope

### In scope
- smoke result persistence
- early-exit and renderer-failure handling for smoke runs
- minimal harness hardening in `desktop/scripts/smoke.js`

### Out of scope
- renderer UI changes
- `research_ui` changes
- core, broker, CLI, or CI workflow edits

---

## Relevant files

- `desktop/main.js`
- `desktop/scripts/smoke.js`

---

## Expected deliverable

A smoke harness that always produces a structured result or a clear structured failure message instead of crashing with `ENOENT` on missing `result.json`.

---

## Done when

- CI no longer fails with raw `ENOENT` for missing smoke output
- smoke runs persist a result even when Electron exits too early
- local smoke validation still passes for normal fallback and real-path cases
76 changes: 59 additions & 17 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const ELECTRON_STATE_ROOT = path.join(DESKTOP_OUTPUTS_ROOT, "electron");
const IS_SMOKE_RUN = process.env.QUANTLAB_DESKTOP_SMOKE === "1";
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;
let smokeDidFinishLoadSeen = false;

app.setPath("userData", path.join(ELECTRON_STATE_ROOT, "user-data"));
app.setPath("cache", path.join(ELECTRON_STATE_ROOT, "cache"));
Expand Down Expand Up @@ -830,16 +832,43 @@ function createMainWindow() {
});
}

async function runDesktopSmoke() {
const result = {
function defaultSmokeResult(overrides = {}) {
return {
bridgeReady: false,
shellReady: false,
serverReady: false,
apiReady: false,
localRunsReady: false,
serverUrl: "",
error: "",
...overrides,
};
}

async function persistSmokeResult(resultOverrides = {}) {
if (!SMOKE_OUTPUT_PATH || smokeResultPersisted) return;
const result = defaultSmokeResult(resultOverrides);
await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true });
await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
smokeResultPersisted = true;
}

async function failSmokeAndQuit(errorMessage, overrides = {}) {
try {
await persistSmokeResult({
error: errorMessage,
...overrides,
});
} catch (_error) {
// Best effort persistence; the outer smoke harness also handles missing files.
} finally {
process.exitCode = 1;
app.quit();
}
}

async function runDesktopSmoke() {
const result = defaultSmokeResult();
try {
result.bridgeReady = await mainWindow.webContents.executeJavaScript(
"Boolean(window.quantlabDesktop && typeof window.quantlabDesktop.getWorkspaceState === 'function')",
Expand Down Expand Up @@ -871,10 +900,7 @@ async function runDesktopSmoke() {
result.error = error.message;
}

if (SMOKE_OUTPUT_PATH) {
await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true });
await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
}
await persistSmokeResult(result);

if (!result.bridgeReady || !result.shellReady) {
process.exitCode = 1;
Expand Down Expand Up @@ -993,19 +1019,16 @@ if (singleInstanceLock) {
createMainWindow();
if (!SKIP_RESEARCH_UI_BOOT) startResearchUiServer();
if (IS_SMOKE_RUN) {
mainWindow.webContents.once("did-fail-load", (_event, errorCode, errorDescription) => {
failSmokeAndQuit(`Desktop smoke load failed: ${errorDescription || `code ${errorCode}`}`);
});
mainWindow.webContents.once("render-process-gone", (_event, details) => {
failSmokeAndQuit(`Desktop smoke renderer exited before completion: ${details?.reason || "unknown"}`);
});
mainWindow.webContents.once("did-finish-load", () => {
smokeDidFinishLoadSeen = true;
runDesktopSmoke().catch((error) => {
if (SMOKE_OUTPUT_PATH) {
fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true })
.then(() => fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify({ bridgeReady: false, shellReady: false, serverReady: false, apiReady: false, localRunsReady: false, error: error.message }, null, 2)}\n`, "utf8"))
.finally(() => {
process.exitCode = 1;
app.quit();
});
} else {
process.exitCode = 1;
app.quit();
}
failSmokeAndQuit(error.message);
});
});
}
Expand All @@ -1019,6 +1042,14 @@ if (singleInstanceLock) {
}

app.on("window-all-closed", () => {
if (IS_SMOKE_RUN && !smokeResultPersisted) {
failSmokeAndQuit(
smokeDidFinishLoadSeen
? "Desktop smoke window closed before the result was persisted."
: "Desktop smoke window closed before the renderer finished loading.",
);
return;
}
if (process.platform !== "darwin") {
app.quit();
}
Expand All @@ -1027,3 +1058,14 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => {
stopResearchUiServer();
});

process.on("uncaughtException", (error) => {
if (!IS_SMOKE_RUN) throw error;
failSmokeAndQuit(`Desktop smoke uncaught exception: ${error?.message || String(error)}`);
});

process.on("unhandledRejection", (reason) => {
if (!IS_SMOKE_RUN) throw reason;
const message = reason instanceof Error ? reason.message : String(reason);
failSmokeAndQuit(`Desktop smoke unhandled rejection: ${message}`);
});
24 changes: 20 additions & 4 deletions desktop/scripts/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ async function main() {
const desktopRoot = path.resolve(__dirname, "..");
const projectRoot = path.resolve(desktopRoot, "..");
const electronBinary = require("electron");
const electronArgs = process.platform === "linux" ? ["--no-sandbox", "."] : ["."];
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "quantlab-desktop-smoke-"));
const outputPath = path.join(tempRoot, "result.json");
const desktopOutputsRoot = path.join(tempRoot, "outputs");
Expand Down Expand Up @@ -75,7 +76,7 @@ async function main() {
}

try {
const child = spawn(electronBinary, ["."], {
const child = spawn(electronBinary, electronArgs, {
cwd: desktopRoot,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
Expand Down Expand Up @@ -105,9 +106,24 @@ async function main() {
child.on("exit", (code) => resolve(code ?? 1));
});
clearTimeout(timeout);

const raw = await fs.readFile(outputPath, "utf8");
const result = JSON.parse(raw);
let result = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
const raw = await fs.readFile(outputPath, "utf8");
result = JSON.parse(raw);
break;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (!result) {
throw new Error(
`Desktop smoke did not persist result.json before Electron exited (code ${exitCode}).`
+ `${stdout.trim() ? ` stdout: ${stdout.trim()}` : ""}`
+ `${stderr.trim() ? ` stderr: ${stderr.trim()}` : ""}`,
);
}

if (exitCode !== 0 || !result.bridgeReady || !result.shellReady) {
if (stdout.trim()) console.error(stdout.trim());
Expand Down
Loading